Files
2026-05-06 10:14:55 +03:00

140 lines
5.0 KiB
Go

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