Validation and normalization rules for player character identity fields. It normalizes and validates character names and descriptions (Unicode normalization, control/format character checks, length limits, surrogate pairing), enforces a single-script profile for names with allowed CJK+Latin combinations, and finds the lowest free character slot.
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
namespace Hexagon.V2.Application;
public static class CharacterRules
{
public const int MaximumNameLength = 64;
public const int MinimumNameLength = 3;
public const int MaximumDescriptionLength = 512;
public const int MinimumDescriptionLength = 16;
/// <summary>
/// Hard per-account slot cap. Slots are dense in 0..MaximumSlots-1 (commit-time
/// invariant), which lets account listings run as bounded keyed slot probes
/// instead of scanning every character ever created.
/// </summary>
public const int MaximumSlots = 64;
/// <summary>
/// How close two character names may be before the second is refused. Operator-tunable
/// because the cost of getting it wrong falls on players: folding is deliberately lossy and
/// will sometimes judge two genuinely different names too similar.
/// <para>
/// This governs NEW characters only. What makes a name well-formed at all — no control or
/// format characters, one writing system, canonical encoding — is a persistence invariant and
/// is deliberately NOT tunable: if it were, tightening the setting would make an existing
/// store fail its own startup validation, and with no rename path that is a host that cannot
/// boot.
/// </para>
/// </summary>
public enum NameUniqueness
{
/// <summary>Refuse a name whose confusable skeleton matches one already taken.</summary>
Skeleton = 0,
/// <summary>Refuse only a plain repeat, ignoring case, accents, spacing and punctuation.</summary>
Exact = 1,
/// <summary>Allow names to repeat freely.</summary>
None = 2
}
public static OperationResult ValidateCreationRequest( CharacterCreationRequest request, IReadOnlySet<string> allowedFields )
{
var name = NormalizeName( request.Name );
if ( name.Failed ) return OperationResult.Failure( name.Error!.Code, name.Error.Message );
var description = NormalizeDescription( request.Description );
if ( description.Failed ) return OperationResult.Failure( description.Error!.Code, description.Error.Message );
foreach ( var field in request.Fields.Keys )
{
if ( !allowedFields.Contains( field ) )
return OperationResult.Failure( ErrorCode.InvalidArgument, $"Unknown or non-creation field '{field}'." );
}
return OperationResult.Success();
}
/// <summary>
/// Canonical form of a player-authored character name. A name is replicated to other
/// players through recognition records and nameplates, so it is an identity boundary,
/// not free text, and is held to an identifier-grade profile:
/// <list type="bullet">
/// <item>NFKC folding, so a name equals what it renders as — fullwidth and other
/// compatibility look-alikes collapse onto the characters they imitate.</item>
/// <item>No control characters and no Unicode format characters (zero-width joiners,
/// bidirectional overrides and isolates).</item>
/// <item>One writing system, per the Unicode "highly restrictive" identifier profile,
/// so Cyrillic or Cherokee letters cannot be mixed into a Latin name to impersonate
/// another player. See <see cref="NameScriptProfile"/>.</item>
/// </list>
/// Callers must persist this value rather than the raw request, or what was validated
/// and what is stored can differ; <c>DomainInvariantValidator</c> enforces that at commit.
/// </summary>
public static OperationResult<string> NormalizeName( string value )
{
var normalized = NormalizeIdentityText(
value, "Name", MinimumNameLength, MaximumNameLength,
allowLineBreaks: false, foldCompatibility: true );
return normalized.Failed ? normalized : NameScriptProfile.Validate( normalized.Value );
}
/// <summary>
/// Canonical form of a player-authored character description, which replicates to every
/// client. Same control- and format-character rules as <see cref="NormalizeName"/>, but a
/// description is prose rather than identity: line breaks survive, compatibility
/// characters are preserved (NFC, not NFKC), and no script restriction applies.
/// </summary>
public static OperationResult<string> NormalizeDescription( string value ) =>
NormalizeIdentityText(
value, "Description", MinimumDescriptionLength, MaximumDescriptionLength,
allowLineBreaks: true, foldCompatibility: false );
private static OperationResult<string> NormalizeIdentityText(
string value,
string field,
int minimumLength,
int maximumLength,
bool allowLineBreaks,
bool foldCompatibility )
{
if ( value is null )
return OperationResult<string>.Failure( ErrorCode.InvalidArgument, $"{field} is required." );
// Normalize throws on unpaired surrogates, so well-formedness is proven first.
for ( var index = 0; index < value.Length; index++ )
{
if ( char.IsHighSurrogate( value[index] ) )
{
if ( index + 1 >= value.Length || !char.IsLowSurrogate( value[index + 1] ) )
return InvalidUnicode( field );
index++;
}
else if ( char.IsLowSurrogate( value[index] ) )
{
return InvalidUnicode( field );
}
}
var normalized = value
.Normalize( foldCompatibility ? NormalizationForm.FormKC : NormalizationForm.FormC )
.Trim();
if ( normalized.Length < minimumLength || normalized.Length > maximumLength )
return OperationResult<string>.Failure(
ErrorCode.InvalidArgument, $"{field} must be {minimumLength}-{maximumLength} characters." );
for ( var index = 0; index < normalized.Length; index++ )
{
var character = normalized[index];
if ( char.IsControl( character ) && !(allowLineBreaks && character is '\n') )
return OperationResult<string>.Failure(
ErrorCode.InvalidArgument, $"{field} contains control characters." );
// The category is read from the string rather than the char so a format
// character encoded as a surrogate pair is classified as itself.
if ( CharUnicodeInfo.GetUnicodeCategory( normalized, index ) == UnicodeCategory.Format )
return OperationResult<string>.Failure(
ErrorCode.InvalidArgument,
$"{field} contains zero-width or bidirectional formatting characters." );
if ( char.IsHighSurrogate( character ) ) index++;
}
return OperationResult<string>.Success( normalized );
}
private static OperationResult<string> InvalidUnicode( string field ) =>
OperationResult<string>.Failure( ErrorCode.InvalidArgument, $"{field} contains invalid Unicode." );
/// <summary>
/// Lowest unoccupied slot for the account, or -1 when all
/// <see cref="MaximumSlots"/> slots are occupied.
/// </summary>
public static int FindLowestFreeSlot( IEnumerable<CharacterRecord> characters )
{
var occupied = characters.Select( character => character.Slot ).Where( slot => slot >= 0 ).ToHashSet();
var slot = 0;
while ( occupied.Contains( slot ) ) slot++;
return slot < MaximumSlots ? slot : -1;
}
}
/// <summary>
/// The Unicode "highly restrictive" identifier profile (UTS #39), which is what stops a Cyrillic
/// letter from standing in for a Latin one: a name must draw its letters from a single writing
/// system, except for the three combinations real names genuinely need - Japanese, Chinese and
/// Korean, each of which may also carry Latin.
/// <para>
/// Only letters carry a script; digits, spaces, punctuation and combining marks are
/// script-neutral and never constrain a name. A script outside the enumerated set is identified
/// by its 128-code-point block rather than lumped into one "other" bucket, so two unenumerated
/// scripts are still seen as two scripts and cannot be mixed. Every script whose letters span
/// more than one block is enumerated explicitly, so that fallback never splits a real name.
/// </para>
/// </summary>
internal static class NameScriptProfile
{
private const int Latin = 1;
private const int Greek = 2;
private const int Cyrillic = 3;
private const int Armenian = 4;
private const int Hebrew = 5;
private const int Arabic = 6;
private const int Cherokee = 7;
private const int Han = 8;
private const int Hiragana = 9;
private const int Katakana = 10;
private const int Hangul = 11;
private const int Bopomofo = 12;
private const int Devanagari = 13;
private const int Bengali = 14;
private const int Gurmukhi = 15;
private const int Gujarati = 16;
private const int Oriya = 17;
private const int Tamil = 18;
private const int Telugu = 19;
private const int Kannada = 20;
private const int Malayalam = 21;
private const int Sinhala = 22;
private const int Thai = 23;
private const int Lao = 24;
private const int Tibetan = 25;
private const int Myanmar = 26;
private const int Georgian = 27;
private const int Ethiopic = 28;
private const int Khmer = 29;
private const int Mongolian = 30;
private const int Thaana = 31;
private const int Syriac = 32;
/// <summary>An unenumerated script is identified by its 128-code-point block.</summary>
private const int UnenumeratedBase = 1000;
private static readonly int[][] AllowedCombinations =
{
new[] { Latin, Han, Hiragana, Katakana },
new[] { Latin, Han, Bopomofo },
new[] { Latin, Han, Hangul }
};
public static OperationResult<string> Validate( string name )
{
var scripts = Of( name );
if ( scripts.Count <= 1 ) return OperationResult<string>.Success( name );
foreach ( var combination in AllowedCombinations )
if ( scripts.IsSubsetOf( combination ) ) return OperationResult<string>.Success( name );
return OperationResult<string>.Failure(
ErrorCode.InvalidArgument,
"Name mixes writing systems that are not used together, which is how look-alike " +
"letters impersonate another character's name." );
}
public static HashSet<int> Of( string value )
{
var scripts = new HashSet<int>();
for ( var index = 0; index < value.Length; index++ )
{
// Category and code point are both read from the string so a letter encoded as a
// surrogate pair is classified as itself. Pairing is proven before this runs.
var category = CharUnicodeInfo.GetUnicodeCategory( value, index );
var codePoint = char.ConvertToUtf32( value, index );
if ( char.IsHighSurrogate( value[index] ) ) index++;
if ( category is UnicodeCategory.UppercaseLetter or UnicodeCategory.LowercaseLetter
or UnicodeCategory.TitlecaseLetter or UnicodeCategory.ModifierLetter
or UnicodeCategory.OtherLetter )
scripts.Add( ScriptOf( codePoint ) );
}
return scripts;
}
private static int ScriptOf( int codePoint ) => codePoint switch
{
>= 0x0041 and <= 0x005A => Latin,
>= 0x0061 and <= 0x007A => Latin,
>= 0x00C0 and <= 0x024F => Latin,
>= 0x1E00 and <= 0x1EFF => Latin,
>= 0x2C60 and <= 0x2C7F => Latin,
>= 0xA720 and <= 0xA7FF => Latin,
>= 0xFF21 and <= 0xFF3A => Latin,
>= 0xFF41 and <= 0xFF5A => Latin,
>= 0x0370 and <= 0x03FF => Greek,
>= 0x1F00 and <= 0x1FFF => Greek,
>= 0x0400 and <= 0x052F => Cyrillic,
>= 0x2DE0 and <= 0x2DFF => Cyrillic,
>= 0xA640 and <= 0xA69F => Cyrillic,
>= 0x0530 and <= 0x058F => Armenian,
>= 0x0590 and <= 0x05FF => Hebrew,
>= 0x0600 and <= 0x06FF => Arabic,
>= 0x0750 and <= 0x077F => Arabic,
>= 0x08A0 and <= 0x08FF => Arabic,
>= 0xFB50 and <= 0xFDFF => Arabic,
>= 0xFE70 and <= 0xFEFF => Arabic,
>= 0x0700 and <= 0x074F => Syriac,
>= 0x0780 and <= 0x07BF => Thaana,
>= 0x13A0 and <= 0x13FF => Cherokee,
>= 0xAB70 and <= 0xABBF => Cherokee,
>= 0x3040 and <= 0x309F => Hiragana,
>= 0x30A0 and <= 0x30FF => Katakana,
>= 0x31F0 and <= 0x31FF => Katakana,
>= 0xFF66 and <= 0xFF9D => Katakana,
>= 0x3100 and <= 0x312F => Bopomofo,
>= 0x31A0 and <= 0x31BF => Bopomofo,
>= 0x1100 and <= 0x11FF => Hangul,
>= 0x3130 and <= 0x318F => Hangul,
>= 0xA960 and <= 0xA97F => Hangul,
>= 0xAC00 and <= 0xD7AF => Hangul,
>= 0x2E80 and <= 0x2EFF => Han,
>= 0x3400 and <= 0x4DBF => Han,
>= 0x4E00 and <= 0x9FFF => Han,
>= 0xF900 and <= 0xFAFF => Han,
>= 0x20000 and <= 0x2FA1F => Han,
>= 0x0900 and <= 0x097F => Devanagari,
>= 0xA8E0 and <= 0xA8FF => Devanagari,
>= 0x0980 and <= 0x09FF => Bengali,
>= 0x0A00 and <= 0x0A7F => Gurmukhi,
>= 0x0A80 and <= 0x0AFF => Gujarati,
>= 0x0B00 and <= 0x0B7F => Oriya,
>= 0x0B80 and <= 0x0BFF => Tamil,
>= 0x0C00 and <= 0x0C7F => Telugu,
>= 0x0C80 and <= 0x0CFF => Kannada,
>= 0x0D00 and <= 0x0D7F => Malayalam,
>= 0x0D80 and <= 0x0DFF => Sinhala,
>= 0x0E00 and <= 0x0E7F => Thai,
>= 0x0E80 and <= 0x0EFF => Lao,
>= 0x0F00 and <= 0x0FFF => Tibetan,
>= 0x1000 and <= 0x109F => Myanmar,
>= 0xA9E0 and <= 0xA9FF => Myanmar,
>= 0xAA60 and <= 0xAA7F => Myanmar,
>= 0x10A0 and <= 0x10FF => Georgian,
>= 0x1C90 and <= 0x1CBF => Georgian,
>= 0x2D00 and <= 0x2D2F => Georgian,
>= 0x1200 and <= 0x139F => Ethiopic,
>= 0x2D80 and <= 0x2DDF => Ethiopic,
>= 0xAB00 and <= 0xAB2F => Ethiopic,
>= 0x1780 and <= 0x17FF => Khmer,
>= 0x19E0 and <= 0x19FF => Khmer,
>= 0x1800 and <= 0x18AF => Mongolian,
_ => UnenumeratedBase + (codePoint >> 7)
};
}