feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+47 -10
View File
@@ -13,8 +13,16 @@ import (
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
// userNameSuffixAlphabet is the Crockford lowercase Base32 alphabet with
// `i`, `l`, `o`, and `u` excluded to avoid visual confusables. The chosen
// 32 characters also keep each byte pair aligned with a 5-bit group so the
// 5-byte random source encodes into exactly eight suffix characters.
const userNameSuffixAlphabet = "0123456789abcdefghjkmnpqrstvwxyz"
const userNameSuffixLength = 8
// IDGenerator creates opaque stable user identifiers and generated initial
// race names.
// user names.
type IDGenerator struct{}
// NewUserID returns one newly generated opaque user identifier.
@@ -32,20 +40,21 @@ func (IDGenerator) NewUserID() (common.UserID, error) {
return userID, nil
}
// NewInitialRaceName returns one generated race name in the `player-<shortid>`
// form.
func (IDGenerator) NewInitialRaceName() (common.RaceName, error) {
token, err := randomToken(5)
// NewUserName returns one generated user name in the `player-<suffix>` form.
// The suffix is eight characters drawn from the Crockford lowercase Base32
// alphabet (confusable-free: `i`, `l`, `o`, `u` are excluded).
func (IDGenerator) NewUserName() (common.UserName, error) {
suffix, err := randomSuffix(userNameSuffixLength)
if err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
return "", fmt.Errorf("generate user name: %w", err)
}
raceName := common.RaceName("player-" + token)
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
userName := common.UserName("player-" + suffix)
if err := userName.Validate(); err != nil {
return "", fmt.Errorf("generate user name: %w", err)
}
return raceName, nil
return userName, nil
}
// NewEntitlementRecordID returns one generated entitlement history record
@@ -103,3 +112,31 @@ func randomToken(size int) (string, error) {
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
}
// randomSuffix returns a length-character suffix encoded from crypto-random
// bytes through the userNameSuffixAlphabet. Each character consumes five
// random bits, so the caller receives `ceil(length * 5 / 8)` bytes of
// entropy in the underlying buffer.
func randomSuffix(length int) (string, error) {
byteCount := (length*5 + 7) / 8
buffer := make([]byte, byteCount)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
encoded := make([]byte, length)
for index := range encoded {
bitOffset := index * 5
byteIndex := bitOffset / 8
shift := bitOffset % 8
value := uint16(buffer[byteIndex]) << 8
if byteIndex+1 < len(buffer) {
value |= uint16(buffer[byteIndex+1])
}
encoded[index] = userNameSuffixAlphabet[(value>>(16-5-shift))&0x1F]
}
return string(encoded), nil
}
@@ -1,65 +0,0 @@
package local
import (
"fmt"
"strings"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
confusables "github.com/disciplinedware/go-confusables"
"golang.org/x/text/cases"
)
type confusableSkeletoner interface {
Skeleton(string) string
}
type raceNamePolicy struct {
caseFolder cases.Caser
skeletoner confusableSkeletoner
}
var raceNameAntiFraudReplacer = strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
)
// NewRaceNamePolicy returns the local Stage 06 race-name canonicalization
// policy backed by Unicode case folding, explicit ASCII anti-fraud mappings,
// and a TR39 confusable skeleton.
func NewRaceNamePolicy() (ports.RaceNamePolicy, error) {
policy := &raceNamePolicy{
caseFolder: cases.Fold(),
skeletoner: confusables.Default(),
}
if policy.skeletoner == nil {
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
}
return policy, nil
}
// CanonicalKey returns the stable uniqueness key for raceName.
func (policy *raceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, 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")
}
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
folded := policy.caseFolder.String(raceName.String())
antiFraudMapped := raceNameAntiFraudReplacer.Replace(folded)
key := account.RaceNameCanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
if err := key.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
return key, nil
}
@@ -1,72 +0,0 @@
package local
import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestRaceNamePolicyCanonicalKey(t *testing.T) {
t.Parallel()
policy, err := NewRaceNamePolicy()
require.NoError(t, err)
tests := []struct {
name string
left common.RaceName
right common.RaceName
}{
{
name: "case insensitive collision",
left: common.RaceName("Pilot Nova"),
right: common.RaceName("pilot nova"),
},
{
name: "ascii anti fraud collision",
left: common.RaceName("Pilot Nova"),
right: common.RaceName("P1lot N0va"),
},
{
name: "unicode confusable collision",
left: common.RaceName("paypal"),
right: common.RaceName("раураl"),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
leftKey, err := policy.CanonicalKey(tt.left)
require.NoError(t, err)
rightKey, err := policy.CanonicalKey(tt.right)
require.NoError(t, err)
require.Equal(t, rightKey, leftKey)
})
}
}
func TestBuildRaceNameReservationPreservesOriginalDisplayValue(t *testing.T) {
t.Parallel()
policy, err := NewRaceNamePolicy()
require.NoError(t, err)
record, err := shared.BuildRaceNameReservation(
policy,
common.UserID("user-123"),
common.RaceName("P1lot Nova"),
time.Unix(1_775_240_000, 0).UTC(),
)
require.NoError(t, err)
require.Equal(t, common.RaceName("P1lot Nova"), record.RaceName)
require.NotEqual(t, account.RaceNameCanonicalKey(""), record.CanonicalKey)
}