140 lines
5.0 KiB
Go
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
|