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
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:
@@ -0,0 +1,40 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestSeatNamesUsesSnapshot checks seatNames returns each seat's stored display-name
|
||||
// snapshot directly, without consulting the account store (svc.accounts is nil here, so
|
||||
// any lookup would be skipped or panic).
|
||||
func TestSeatNamesUsesSnapshot(t *testing.T) {
|
||||
svc := &Service{}
|
||||
g := Game{
|
||||
Players: 2,
|
||||
Seats: []Seat{
|
||||
{Seat: 0, AccountID: uuid.New(), DisplayName: "Аня2007"},
|
||||
{Seat: 1, AccountID: uuid.New(), DisplayName: "DarkWolf"},
|
||||
},
|
||||
}
|
||||
got := svc.seatNames(context.Background(), g)
|
||||
want := []string{"Аня2007", "DarkWolf"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("seatNames = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeatDisplayNameFallbackEmpty checks the resolver yields "" — rather than panicking
|
||||
// — for a seat with no snapshot when no account store is available (the empty open seat
|
||||
// and pre-snapshot legacy-row paths).
|
||||
func TestSeatDisplayNameFallbackEmpty(t *testing.T) {
|
||||
svc := &Service{}
|
||||
if got := svc.seatDisplayName(context.Background(), Seat{Seat: 0, AccountID: uuid.New()}); got != "" {
|
||||
t.Errorf("seatDisplayName with no snapshot and no store = %q, want empty", got)
|
||||
}
|
||||
if got := svc.seatDisplayName(context.Background(), Seat{Seat: 1, AccountID: uuid.Nil}); got != "" {
|
||||
t.Errorf("empty-seat name = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -108,18 +108,27 @@ type activeGame struct {
|
||||
turnTimeoutSecs int
|
||||
}
|
||||
|
||||
// seatInsert is one seat to create: the account to seat (uuid.Nil for the still-empty
|
||||
// opponent seat of an open auto-match game) and the display-name snapshot to stamp on
|
||||
// it — the player's name as of when the seat was taken (account.go, docs/ARCHITECTURE.md §7).
|
||||
type seatInsert struct {
|
||||
accountID uuid.UUID
|
||||
displayName string
|
||||
}
|
||||
|
||||
// CreateGame inserts the games row and one game_players row per seat (seat 0
|
||||
// first) inside a single transaction.
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
return insertGameTx(ctx, tx, ins, seats)
|
||||
})
|
||||
}
|
||||
|
||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||
// first) on tx. A seat whose account id is uuid.Nil is written with a NULL
|
||||
// account_id — the still-empty opponent seat of a StatusOpen auto-match game.
|
||||
func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.UUID) error {
|
||||
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
||||
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
||||
// opponent seat of a StatusOpen auto-match game.
|
||||
func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatInsert) error {
|
||||
status := ins.status
|
||||
if status == "" {
|
||||
status = StatusActive
|
||||
@@ -138,14 +147,14 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
for seat, accountID := range seats {
|
||||
var acc any = accountID
|
||||
if accountID == uuid.Nil {
|
||||
for seat, si := range seats {
|
||||
var acc any = si.accountID
|
||||
if si.accountID == uuid.Nil {
|
||||
acc = postgres.NULL
|
||||
}
|
||||
pi := table.GamePlayers.INSERT(
|
||||
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).VALUES(ins.id, seat, acc)
|
||||
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID, table.GamePlayers.DisplayName,
|
||||
).VALUES(ins.id, seat, acc, si.displayName)
|
||||
if _, err := pi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert seat %d: %w", seat, err)
|
||||
}
|
||||
@@ -175,8 +184,10 @@ func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
// used only when a game is created. A transaction-scoped advisory lock on the
|
||||
// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
|
||||
// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
|
||||
// for the still-empty opponent, in the chosen order) used only when a game is created.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameInsert, seats []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
// for the still-empty opponent, in the chosen order) used only when a game is created;
|
||||
// callerName is the caller's display-name snapshot, stamped on their seat whether they
|
||||
// open a fresh game or fill another player's open one.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
@@ -207,7 +218,7 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameIns
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
|
||||
case e == nil:
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID); er != nil {
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
|
||||
return er
|
||||
}
|
||||
gameID, joined = other, true
|
||||
@@ -225,10 +236,11 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameIns
|
||||
return gameID, joined, created, err
|
||||
}
|
||||
|
||||
// AttachRobot fills the empty opponent seat of open game gameID with robotID and
|
||||
// flips it to active, returning whether it attached. It is a no-op (false) when the
|
||||
// game is no longer open — a human joined first — so the reaper never double-fills.
|
||||
func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (bool, error) {
|
||||
// AttachRobot fills the empty opponent seat of open game gameID with robotID, stamping
|
||||
// displayName (the robot's per-game name) on the seat, and flips it to active, returning
|
||||
// whether it attached. It is a no-op (false) when the game is no longer open — a human
|
||||
// joined first — so the reaper never double-fills.
|
||||
func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (bool, error) {
|
||||
attached := false
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
var status string
|
||||
@@ -242,7 +254,7 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (boo
|
||||
if status != StatusOpen {
|
||||
return nil
|
||||
}
|
||||
if e := fillOpenSeat(ctx, tx, gameID, robotID); e != nil {
|
||||
if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil {
|
||||
return e
|
||||
}
|
||||
attached = true
|
||||
@@ -251,12 +263,13 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (boo
|
||||
return attached, err
|
||||
}
|
||||
|
||||
// fillOpenSeat seats accountID in an open game's empty opponent seat and flips the
|
||||
// game to active, stamping a fresh turn clock. The caller holds the game row.
|
||||
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID) error {
|
||||
// fillOpenSeat seats accountID in an open game's empty opponent seat — stamping
|
||||
// displayName as the seat's display-name snapshot — and flips the game to active with a
|
||||
// fresh turn clock. The caller holds the game row.
|
||||
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.game_players SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
|
||||
gameID, accountID); err != nil {
|
||||
`UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`,
|
||||
gameID, accountID, displayName); err != nil {
|
||||
return fmt.Errorf("fill opponent seat: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
@@ -1126,11 +1139,12 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
accountID = *p.AccountID
|
||||
}
|
||||
out.Seats = append(out.Seats, Seat{
|
||||
Seat: int(p.Seat),
|
||||
AccountID: accountID,
|
||||
Score: int(p.Score),
|
||||
HintsUsed: int(p.HintsUsed),
|
||||
IsWinner: p.IsWinner,
|
||||
Seat: int(p.Seat),
|
||||
AccountID: accountID,
|
||||
Score: int(p.Score),
|
||||
HintsUsed: int(p.HintsUsed),
|
||||
IsWinner: p.IsWinner,
|
||||
DisplayName: p.DisplayName,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -144,6 +144,11 @@ type Seat struct {
|
||||
Score int
|
||||
HintsUsed int
|
||||
IsWinner bool
|
||||
// DisplayName is the seat's display-name snapshot, captured when the seat was taken
|
||||
// (the human's then-current name, or a disguised robot's per-game name). Empty for a
|
||||
// still-empty open seat or a pre-snapshot legacy row, where readers fall back to the
|
||||
// account's current display name (docs/ARCHITECTURE.md §7).
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// OpenGame identifies an auto-match game waiting for an opponent whose robot
|
||||
|
||||
Reference in New Issue
Block a user