feat(robot): per-game display names from a wide name corpus
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s

Decouple the displayed opponent name from the small pool of durable robot
accounts: the disguised auto-match robot now gets a freshly composed name each
game, stamped on a new game_players.display_name seat snapshot. The snapshot
also captures humans' names, freezing what an opponent sees for the life of a
game (a later rename no longer rewrites past games); readers fall back to the
account's current name for pre-migration rows.

Names come from a wide composed corpus (internal/robot/namevariety.go): Western
locales (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed
Russian pool, and human-style handles. Routing keeps Pick's spirit -- a Russian
game draws Cyrillic + <=20% Latin and never a CJK script; an English game the
full corpus -- via robot.PickNamed.

Loosen account.ValidateDisplayName (and the UI mirror) to admit a trailing run
of up to five digits, so "Player2007"-style handles are valid for humans too
and the disguised robot stays indistinguishable.

Migration 00007 adds game_players.display_name (additive, NOT NULL DEFAULT '');
jet regenerated. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru, PLAN, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-16 12:28:04 +02:00
parent ac1c89c0ee
commit 183e08ec80
24 changed files with 811 additions and 123 deletions
+50 -33
View File
@@ -179,17 +179,23 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
}
}
seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range params.Seats {
seats := make([]seatInsert, len(params.Seats))
for i, id := range params.Seats {
if seen[id] {
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
}
seen[id] = true
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
// Snapshot each seat's display name at creation. For a vs-AI game this stamps the
// robot's seeded account name (unchanged behaviour); a disguised auto-match robot
// instead gets a fresh per-game name when the reaper attaches it (AttachRobot).
acc, err := svc.accounts.GetByID(ctx, id)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
}
return Game{}, err
}
seats[i] = seatInsert{accountID: id, displayName: acc.DisplayName}
}
seed := params.Seed
@@ -231,7 +237,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI,
}
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
if err := svc.store.CreateGame(ctx, ins, seats); err != nil {
return Game{}, err
}
svc.cache.put(id, g, params.Variant.String())
@@ -256,7 +262,8 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
if _, err := svc.accounts.GetByID(ctx, accountID); err != nil {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID)
}
@@ -292,13 +299,14 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
status: StatusOpen,
openDeadline: &deadline,
}
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat
// is left empty (uuid.Nil) for the opponent.
seats := []uuid.UUID{accountID, uuid.Nil}
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first), snapshotting their
// display name; the other seat is left empty (a zero seatInsert) for the opponent.
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
seats := []seatInsert{caller, {}}
if seed&1 == 1 {
seats = []uuid.UUID{uuid.Nil, accountID}
seats = []seatInsert{{}, caller}
}
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats)
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
if err != nil {
return Game{}, false, err
}
@@ -312,11 +320,12 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
return g, joined, nil
}
// AttachRobot seats robotID in the empty opponent seat of open game gameID and flips
// it to active, returning the now-active game and whether it attached (false, with a
// zero Game, when a human joined first). It backs the matchmaking reaper.
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (Game, bool, error) {
attached, err := svc.store.AttachRobot(ctx, gameID, robotID)
// AttachRobot seats robotID in the empty opponent seat of open game gameID, stamping
// displayName (the robot's fresh per-game name) on the seat, and flips it to active,
// returning the now-active game and whether it attached (false, with a zero Game, when a
// human joined first). It backs the matchmaking reaper.
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (Game, bool, error) {
attached, err := svc.store.AttachRobot(ctx, gameID, robotID, displayName)
if err != nil {
return Game{}, false, err
}
@@ -698,21 +707,31 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
svc.pub.Publish(intents...)
}
// displayName resolves the display name of the account at the given seat, or "" when the seat
// is absent or the lookup fails (the enriched push then falls back to its plain text).
// seatDisplayName returns the name shown for a seat: its captured display-name snapshot
// (docs/ARCHITECTURE.md §7), falling back to the account's current display name for a
// pre-snapshot legacy row, or "" when neither is available.
func (svc *Service) seatDisplayName(ctx context.Context, s Seat) string {
if s.DisplayName != "" {
return s.DisplayName
}
if svc.accounts == nil || s.AccountID == uuid.Nil {
return ""
}
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
return acc.DisplayName
}
return ""
}
// displayName resolves the display name shown for the account at the given seat, or ""
// when the seat is absent (the enriched push then falls back to its plain text).
func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string {
if svc.accounts == nil {
return ""
for _, s := range seats {
if s.Seat == seat {
return svc.seatDisplayName(ctx, s)
}
}
id, ok := seatAccount(seats, seat)
if !ok {
return ""
}
acc, err := svc.accounts.GetByID(ctx, id)
if err != nil {
return ""
}
return acc.DisplayName
return ""
}
// scoreLine formats the running scores with recipientSeat's score first, then the remaining
@@ -1376,15 +1395,13 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er
return out, nil
}
// seatNames resolves each seat's display name for GCG export.
// seatNames resolves each seat's display name — its captured snapshot, else the
// account's current name (seatDisplayName) — for game state and GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
names := make([]string, g.Players)
if svc.accounts == nil {
return names
}
for _, s := range g.Seats {
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
names[s.Seat] = acc.DisplayName
if s.Seat >= 0 && s.Seat < len(names) {
names[s.Seat] = svc.seatDisplayName(ctx, s)
}
}
return names