feat: backend service
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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
|
||||
Reference in New Issue
Block a user