Utility that computes a comparison key (skeleton) for player character names by normalizing Unicode, optionally mapping confusable characters to prototypes using a ConfusableMappings table, stripping non-letter/digit categories, and lowercasing. It provides Of (with confusable folding) and Exact (without confusable folding).
#nullable enable
using System.Globalization;
using System.Text;
namespace Hexagon.V2.Application;
/// <summary>
/// Reduces a canonical character name to a comparison key that collapses look-alike spellings
/// onto each other, following the UTS #39 skeleton algorithm over the full Unicode confusables
/// table in <see cref="ConfusableMappings"/>. Two names with the same skeleton render similarly
/// enough that one could pass for the other, so the framework reserves the skeleton rather than
/// the name and refuses the second of any such pair.
/// <para>
/// The script profile already stops a name from MIXING writing systems. This closes what it
/// cannot see: look-alikes within one script, and a name written entirely in another script to
/// imitate a Latin one.
/// </para>
/// <para>
/// Folding is deliberately lossy and will occasionally judge two genuinely different names too
/// close. That trade is intentional: a rejected name costs a player one retry, while a
/// successful impersonation is not recoverable by the player being impersonated.
/// </para>
/// </summary>
public static class CharacterNameSkeleton
{
/// <summary>
/// The comparison key for a canonical name, or an empty string when the name carries no
/// identity-bearing characters at all. Callers must treat empty as a rejected name.
/// </summary>
public static string Of( string canonicalName ) => Reduce( canonicalName, foldConfusables: true );
/// <summary>
/// The same reduction WITHOUT confusable folding: it still ignores case, accents, spacing and
/// punctuation, so it catches a plain repeat of a name, but "Alice" and "A1ice" stay distinct.
/// Used when an operator judges full folding too aggressive. Like <see cref="Of"/> it yields
/// only letters and digits, which is what keeps it usable as a reservation key.
/// </summary>
public static string Exact( string canonicalName ) => Reduce( canonicalName, foldConfusables: false );
private static string Reduce( string canonicalName, bool foldConfusables )
{
if ( string.IsNullOrEmpty( canonicalName ) ) return string.Empty;
// UTS #39: decompose, replace every confusable with its prototype, recompose to NFD.
// Case folding comes last, because the table's prototypes are case-sensitive - it maps
// "I" onto "l", which lowercasing first would have already destroyed.
var decomposed = canonicalName.Normalize( NormalizationForm.FormD );
var mapped = (foldConfusables ? MapConfusables( decomposed ) : decomposed)
.Normalize( NormalizationForm.FormD );
var builder = new StringBuilder( mapped.Length );
for ( var index = 0; index < mapped.Length; index++ )
{
var category = CharUnicodeInfo.GetUnicodeCategory( mapped, index );
var codePoint = char.ConvertToUtf32( mapped, index );
if ( char.IsHighSurrogate( mapped[index] ) ) index++;
// Only letters and digits carry identity. Marks, spaces, punctuation and symbols are
// dropped, so "Al Ice", "Al-Ice" and "AlIce" are one identity rather than three, and
// the accent in "Jose" decomposed above is decoration rather than a second name.
if ( category is UnicodeCategory.UppercaseLetter or UnicodeCategory.LowercaseLetter
or UnicodeCategory.TitlecaseLetter or UnicodeCategory.ModifierLetter
or UnicodeCategory.OtherLetter or UnicodeCategory.DecimalDigitNumber )
builder.Append( char.ConvertFromUtf32( codePoint ) );
}
return builder.ToString().ToLowerInvariant();
}
private static string MapConfusables( string value )
{
var builder = new StringBuilder( value.Length );
for ( var index = 0; index < value.Length; index++ )
{
var codePoint = char.ConvertToUtf32( value, index );
if ( char.IsHighSurrogate( value[index] ) ) index++;
if ( ConfusableMappings.TryMap( codePoint, out var single, out var sequence ) )
{
if ( sequence is not null ) builder.Append( sequence );
else builder.Append( char.ConvertFromUtf32( single ) );
continue;
}
var supplement = SupplementaryFold( codePoint );
if ( supplement is not null ) builder.Append( supplement );
else builder.Append( char.ConvertFromUtf32( codePoint ) );
}
return builder.ToString();
}
/// <summary>
/// The two confusions the Unicode table does not express, kept deliberately small and
/// separate so it stays obvious what is standard and what is this framework's own judgement.
/// <list type="bullet">
/// <item>Digit-for-letter substitution. UTS #39 maps only '0' and '1', judging the rest not
/// visually confusable; in a game where names are typed to be read by other players,
/// leetspeak is the ordinary way to imitate a name, so the remaining digits fold too.</item>
/// <item>"w" onto "vv". A UTS #39 source is always a single code point, so the table can
/// express "m" onto "rn" - which it does, and which is why that pair needs nothing here -
/// but it cannot express the reverse direction for a two-letter sequence.</item>
/// </list>
/// </summary>
private static string? SupplementaryFold( int codePoint ) => codePoint switch
{
'2' => "z",
'3' => "e",
'4' => "a",
'5' => "s",
'6' => "b",
'7' => "t",
'8' => "b",
'9' => "g",
'w' => "vv",
'W' => "vv",
_ => null
};
}