package lobby import ( "fmt" "strings" "unicode" "unicode/utf8" confusables "github.com/disciplinedware/go-confusables" "golang.org/x/text/cases" "golang.org/x/text/language" ) // raceNameMaxRuneLen bounds the display length of a race-name. Must // match the documented user-facing limit; the value is mirrored as an // `if len(...)` check rather than enforced at the storage boundary so // migrations stay simple. const raceNameMaxRuneLen = 32 // CanonicalKey is the platform-wide race-name uniqueness key produced by // `Policy.Canonical`. Two display names that yield the same CanonicalKey // are considered the "same" race name for ownership purposes regardless // of casing or visually-confusable substitutions. type CanonicalKey string // String returns the canonical key as its underlying string. func (k CanonicalKey) String() string { return string(k) } // IsZero reports whether the key carries no usable value. func (k CanonicalKey) IsZero() bool { return strings.TrimSpace(string(k)) == "" } // confusableSkeletoner is satisfied by the default // `disciplinedware/go-confusables` runtime; tests substitute a // deterministic stub via `WithSkeletoner`. type confusableSkeletoner interface { Skeleton(string) string } // Policy holds the canonicalisation pipeline used by the Race Name // Directory. The pipeline is `case-fold → anti-fraud digit-letter // replace → confusable skeleton`. Each step is idempotent. type Policy struct { caseFolder cases.Caser skeletoner confusableSkeletoner } // antiFraudReplacer collapses the documented ASCII digit-to-letter // pairs so `P1lot` and `Pilot` canonicalise to the same key. The set // is intentionally small — adding entries broadens the equivalence // classes platform-wide and is a deliberate policy decision. var antiFraudReplacer = strings.NewReplacer( "1", "i", "0", "o", "8", "b", ) // NewPolicy returns the default race-name canonicalisation policy. // Returns an error when the `disciplinedware/go-confusables` default // skeletoner cannot be obtained — should never happen in practice but // the constructor surfaces it explicitly so tests can assert on // failure. func NewPolicy() (*Policy, error) { p := &Policy{ caseFolder: cases.Fold(cases.Compact), skeletoner: confusables.Default(), } if p.skeletoner == nil { return nil, fmt.Errorf("lobby: build race-name policy: confusables.Default() returned nil") } return p, nil } // WithSkeletoner overrides the underlying TR39 confusable skeletoner. // Tests use this to substitute a deterministic stub; production wiring // uses the default obtained from `NewPolicy`. func (p *Policy) WithSkeletoner(s confusableSkeletoner) *Policy { if p == nil { return nil } if s == nil { return p } out := *p out.skeletoner = s return &out } // Canonical returns the canonical key for raceName. The function trims // surrounding whitespace, applies Unicode case-folding, runs the // anti-fraud replacer, and then computes the TR39 confusable skeleton. // Returns ErrInvalidInput when raceName is empty after trimming or the // resulting key is empty. // // `language.Und` is passed to the case folder because case-folding for // race names is intentionally locale-independent — two players from // different locales must agree on which names collide. func (p *Policy) Canonical(raceName string) (CanonicalKey, error) { if p == nil || p.skeletoner == nil { return "", fmt.Errorf("%w: lobby policy not initialised", ErrInvalidInput) } trimmed := strings.TrimSpace(raceName) if trimmed == "" { return "", fmt.Errorf("%w: race name must not be empty", ErrInvalidInput) } if utf8.RuneCountInString(trimmed) > raceNameMaxRuneLen { return "", fmt.Errorf("%w: race name exceeds %d characters", ErrInvalidInput, raceNameMaxRuneLen) } folded := p.caseFolder.String(trimmed) mapped := antiFraudReplacer.Replace(folded) skeleton := p.skeletoner.Skeleton(mapped) if strings.TrimSpace(skeleton) == "" { return "", fmt.Errorf("%w: race name canonical key is empty", ErrInvalidInput) } return CanonicalKey(skeleton), nil } // ValidateDisplayName enforces the structural invariants on the // caller-supplied display form: non-empty, ≤ raceNameMaxRuneLen runes, // no control characters. Returns the trimmed form on success. func ValidateDisplayName(raceName string) (string, error) { trimmed := strings.TrimSpace(raceName) if trimmed == "" { return "", fmt.Errorf("%w: race name must not be empty", ErrInvalidInput) } if utf8.RuneCountInString(trimmed) > raceNameMaxRuneLen { return "", fmt.Errorf("%w: race name exceeds %d characters", ErrInvalidInput, raceNameMaxRuneLen) } for _, r := range trimmed { if unicode.IsControl(r) { return "", fmt.Errorf("%w: race name must not contain control characters", ErrInvalidInput) } } return trimmed, nil } // languageForFolder is the static language tag passed to cases.Fold; it // remains untyped at construction time and is resolved lazily inside // `cases.Fold(...)`. Kept here so tests can reference it explicitly. var languageForFolder = language.Und