feat: game lobby service
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
package racename
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/util"
|
||||
|
||||
confusables "github.com/disciplinedware/go-confusables"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
// confusableSkeletoner abstracts the underlying TR39 confusable-skeleton
|
||||
// computer so tests may substitute a deterministic stub.
|
||||
type confusableSkeletoner interface {
|
||||
Skeleton(string) string
|
||||
}
|
||||
|
||||
// Policy produces canonical uniqueness keys and validates user-supplied race
|
||||
// names under the Race Name Directory rules: Unicode case folding, explicit
|
||||
// ASCII anti-fraud digit-to-letter mappings, and a TR39 confusable skeleton.
|
||||
type Policy struct {
|
||||
caseFolder cases.Caser
|
||||
skeletoner confusableSkeletoner
|
||||
}
|
||||
|
||||
// antiFraudReplacer collapses the frozen ASCII anti-fraud digit-to-letter
|
||||
// pairs so `P1lot` and `Pilot` canonicalize together.
|
||||
var antiFraudReplacer = strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
)
|
||||
|
||||
// NewPolicy returns the default race-name canonicalization policy.
|
||||
func NewPolicy() (*Policy, error) {
|
||||
policy := &Policy{
|
||||
caseFolder: cases.Fold(),
|
||||
skeletoner: confusables.Default(),
|
||||
}
|
||||
if policy.skeletoner == nil {
|
||||
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// Canonical returns the stable uniqueness key for raceName.
|
||||
//
|
||||
// raceName is expected to be non-empty; surrounding whitespace is trimmed
|
||||
// before canonicalization so callers that preserve original casing pass the
|
||||
// untrimmed display form directly.
|
||||
func (policy *Policy) Canonical(raceName string) (CanonicalKey, error) {
|
||||
switch {
|
||||
case policy == nil:
|
||||
return "", fmt.Errorf("canonicalize race name: nil policy")
|
||||
case policy.skeletoner == nil:
|
||||
return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner")
|
||||
}
|
||||
trimmed := strings.TrimSpace(raceName)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("canonicalize race name: race name must not be empty")
|
||||
}
|
||||
|
||||
folded := policy.caseFolder.String(trimmed)
|
||||
antiFraudMapped := antiFraudReplacer.Replace(folded)
|
||||
key := CanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
|
||||
if err := key.Validate(); err != nil {
|
||||
return "", fmt.Errorf("canonicalize race name: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ValidateName reports whether raceName is structurally valid for use in the
|
||||
// Race Name Directory. It delegates to galaxy/util.ValidateTypeName and
|
||||
// returns the trimmed canonical display value on success.
|
||||
func ValidateName(raceName string) (string, error) {
|
||||
trimmed, ok := util.ValidateTypeName(raceName)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("race name is invalid")
|
||||
}
|
||||
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// Canonicalize validates raceName as a Race Name Directory display value
|
||||
// and returns its canonical uniqueness key. It composes ValidateName with
|
||||
// the Canonical pipeline so every RND write and lookup shares a single
|
||||
// entry point for both character-set and confusable-pair policy.
|
||||
//
|
||||
// Invalid raceName values surface the error returned by ValidateName;
|
||||
// callers at the RaceNameDirectory port boundary map these to
|
||||
// ports.ErrInvalidName.
|
||||
func (policy *Policy) Canonicalize(raceName string) (CanonicalKey, error) {
|
||||
trimmed, err := ValidateName(raceName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("canonicalize race name: %w", err)
|
||||
}
|
||||
|
||||
return policy.Canonical(trimmed)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package racename
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPolicyCanonicalCollisions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
}{
|
||||
{
|
||||
name: "case insensitive collision",
|
||||
left: "Pilot Nova",
|
||||
right: "pilot nova",
|
||||
},
|
||||
{
|
||||
name: "ascii anti fraud collision",
|
||||
left: "Pilot Nova",
|
||||
right: "P1lot N0va",
|
||||
},
|
||||
{
|
||||
name: "unicode confusable collision",
|
||||
left: "paypal",
|
||||
right: "раураl",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
leftKey, err := policy.Canonical(tt.left)
|
||||
require.NoError(t, err)
|
||||
rightKey, err := policy.Canonical(tt.right)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rightKey, leftKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyCanonicalRejectsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = policy.Canonical("")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = policy.Canonical(" ")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPolicyCanonicalTrimsWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
trimmed, err := policy.Canonical("Pilot Nova")
|
||||
require.NoError(t, err)
|
||||
padded, err := policy.Canonical(" Pilot Nova ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, trimmed, padded)
|
||||
}
|
||||
|
||||
func TestValidateNameDelegatesToUtil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
trimmed, err := ValidateName(" PilotNova ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "PilotNova", trimmed)
|
||||
|
||||
_, err = ValidateName("")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = ValidateName(" ")
|
||||
require.Error(t, err)
|
||||
|
||||
// Internal whitespace is rejected by util.ValidateTypeName.
|
||||
_, err = ValidateName("Pilot Nova")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCanonicalKeyValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Error(t, CanonicalKey("").Validate())
|
||||
require.Error(t, CanonicalKey(" abc").Validate())
|
||||
require.Error(t, CanonicalKey("abc ").Validate())
|
||||
require.NoError(t, CanonicalKey("abc").Validate())
|
||||
require.True(t, CanonicalKey("").IsZero())
|
||||
require.False(t, CanonicalKey("abc").IsZero())
|
||||
require.Equal(t, "abc", CanonicalKey("abc").String())
|
||||
}
|
||||
|
||||
func TestPolicyCanonicalizeValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := policy.Canonicalize(" PilotNova ")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, key.Validate())
|
||||
require.False(t, key.IsZero())
|
||||
|
||||
paddedKey, err := policy.Canonicalize("PilotNova")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, paddedKey, key)
|
||||
}
|
||||
|
||||
func TestPolicyCanonicalizeEquivalences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
}{
|
||||
{
|
||||
name: "case insensitive collision",
|
||||
left: "PilotNova",
|
||||
right: "pilotnova",
|
||||
},
|
||||
{
|
||||
name: "ascii anti fraud collision",
|
||||
left: "PilotNova",
|
||||
right: "P1l0tN0va",
|
||||
},
|
||||
{
|
||||
name: "unicode confusable collision",
|
||||
left: "paypal",
|
||||
right: "раураl",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
leftKey, err := policy.Canonicalize(tt.left)
|
||||
require.NoError(t, err)
|
||||
rightKey, err := policy.Canonicalize(tt.right)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, leftKey, rightKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyCanonicalizeRejectsInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "empty", input: ""},
|
||||
{name: "whitespace only", input: " "},
|
||||
{name: "internal space", input: "Pilot Nova"},
|
||||
{name: "leading dash", input: "-Pilot"},
|
||||
{name: "trailing dash", input: "Pilot-"},
|
||||
}
|
||||
|
||||
for _, tt := range invalid {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := policy.Canonicalize(tt.input)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package racename defines the Lobby Race Name Directory canonical-key
|
||||
// policy and shared value types used by the platform-wide race-name
|
||||
// uniqueness arbiter.
|
||||
package racename
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CanonicalKey stores the policy-produced uniqueness key used to arbitrate
|
||||
// race-name ownership across the Race Name Directory.
|
||||
type CanonicalKey string
|
||||
|
||||
// String returns CanonicalKey as its stored canonical string.
|
||||
func (key CanonicalKey) String() string {
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// IsZero reports whether CanonicalKey does not contain a usable value.
|
||||
func (key CanonicalKey) IsZero() bool {
|
||||
return strings.TrimSpace(string(key)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether CanonicalKey is non-empty and trimmed.
|
||||
func (key CanonicalKey) Validate() error {
|
||||
switch {
|
||||
case key.IsZero():
|
||||
return fmt.Errorf("race name canonical key must not be empty")
|
||||
case strings.TrimSpace(string(key)) != string(key):
|
||||
return fmt.Errorf("race name canonical key must not contain surrounding whitespace")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user