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
+41 -27
View File
@@ -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