From 183e08ec80cab02a3d5f1d2c195b84f50dfff5d5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 16 Jun 2026 12:28:04 +0200 Subject: [PATCH 1/3] feat(robot): per-game display names from a wide name corpus 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. --- PLAN.md | 11 + backend/README.md | 7 +- backend/internal/account/profile.go | 17 +- backend/internal/account/validate_test.go | 11 +- backend/internal/game/seatname_test.go | 40 +++ backend/internal/game/service.go | 83 +++-- backend/internal/game/store.go | 68 ++-- backend/internal/game/types.go | 5 + backend/internal/inttest/open_match_test.go | 4 +- .../inttest/seat_display_name_test.go | 102 ++++++ backend/internal/lobby/lobby.go | 3 + backend/internal/lobby/matchmaker.go | 6 +- backend/internal/lobby/matchmaker_test.go | 7 +- .../jet/backend/model/game_players.go | 13 +- .../jet/backend/table/game_players.go | 45 +-- .../migrations/00007_seat_display_name.sql | 16 + backend/internal/robot/namevariety.go | 297 ++++++++++++++++++ backend/internal/robot/namevariety_test.go | 114 +++++++ backend/internal/robot/robot.go | 15 + docs/ARCHITECTURE.md | 26 +- docs/FUNCTIONAL.md | 11 +- docs/FUNCTIONAL_ru.md | 8 +- ui/src/lib/profileValidation.test.ts | 11 +- ui/src/lib/profileValidation.ts | 14 +- 24 files changed, 811 insertions(+), 123 deletions(-) create mode 100644 backend/internal/game/seatname_test.go create mode 100644 backend/internal/inttest/seat_display_name_test.go create mode 100644 backend/internal/postgres/migrations/00007_seat_display_name.sql create mode 100644 backend/internal/robot/namevariety.go create mode 100644 backend/internal/robot/namevariety_test.go diff --git a/PLAN.md b/PLAN.md index fee5f9b..852ad66 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1258,6 +1258,17 @@ cannot submit; three-way admin filter. first + surname initial / first + full surname), composed deterministically per pool slot (stable across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20% Latin, an English game the Latin pool. Robot identities are keyed `robot--`. + - **Robot names — per-game variety** (post-release, 2026-06): the seeded pools above now only back + each account's *fallback* name. The disguised reaper stamps a **fresh per-game name** on the robot's + seat — a new `game_players.display_name` snapshot, which also captures humans' names so a later rename + no longer rewrites past games (a reader falls back to the account name for a pre-migration row). The + name is drawn 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 (stem + optional `.`/`_` + optional trailing number). Routing keeps the spirit of `Pick` + (Russian: Cyrillic + ≤20% Latin, never a CJK script; English: the full corpus) via `robot.PickNamed`. + `account.ValidateDisplayName` was loosened to admit a **trailing run of ≤5 digits** (mirrored in + `ui/src/lib/profileValidation.ts`), so "Player2007"-style handles are valid for humans too and the + disguised robot stays indistinguishable. - **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime diff --git a/backend/README.md b/backend/README.md index 4eb52a9..58921c9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -47,7 +47,12 @@ services are exposed via `Server` accessors for those handlers. The robot opponent (`internal/robot`). A pool of durable accounts — each a `kind='robot'` identity, provisioned at startup with chat and friend -requests blocked — backs human-like, per-language composed names. A background driver plays the +requests blocked — carries human-like names. The disguised reaper stamps a **fresh per-game name** on +the robot's seat (a `game_players.display_name` snapshot — which also freezes humans' names per game, so +a later rename never rewrites past games) drawn from a wide composed corpus (Western locales, native +Japanese/Chinese, a gender-agreed Russian pool, and handles); a Russian game stays Cyrillic with ≤20% +Latin and no CJK script, an English game uses the full corpus (`namevariety.go`, `PickNamed`). A +background driver plays the robot's moves through the public game API as an ordinary seated player (so only `internal/engine` imports the solver): it decides once per game whether to play to win (≈ 40%), targets a small score margin — with an occasional off-strategy move that tapers to diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index 019045b..d2fa936 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -23,9 +23,10 @@ import ( // is unbounded; auto-provisioned platform names bypass this editor validation). const maxDisplayName = 32 -// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — -// every name rune that is neither a letter nor a space) an editable display name may -// carry, so a still-well-formed name cannot be made of mostly punctuation. +// maxDisplayNameSpecials caps the total special characters (every name rune that is +// neither a letter, a space, nor a digit — i.e. the "." / "_" separators) an editable +// display name may carry, so a still-well-formed name cannot be made of mostly +// punctuation. A trailing digit run is bounded separately by displayNameRe. const maxDisplayNameSpecials = 5 // maxAwayWindow bounds the daily away window's duration (midnight-wrap aware). @@ -34,9 +35,11 @@ const maxAwayWindow = 12 * time.Hour // displayNameRe enforces the editable display-name format: Unicode letters // joined by single space / "." / "_" separators, where a "." or "_" may be followed // by a single space. No leading separator and no two adjacent separators (except -// " "); a single trailing "." is allowed, so -// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not. -var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`) +// " "). The name may end with EITHER a single trailing "." +// (an initial, "Anna B.") OR a run of 1–5 digits (a handle's number or year, +// "Player2007"), but not both; digits never appear elsewhere. So "Name_P. Last", +// "Anna B." and "Аня2007" are valid, while "Name P._Last" and "Dark2Wolf" are not. +var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*(?:\.|[0-9]{1,5})?$`) // ErrInvalidProfile is returned when a profile update carries an unacceptable // field (an unknown language, an invalid timezone, or an over-long display name). @@ -117,7 +120,7 @@ func ValidateDisplayName(raw string) (string, error) { } specials := 0 for _, r := range name { - if r != ' ' && !unicode.IsLetter(r) { + if r != ' ' && !unicode.IsLetter(r) && !unicode.IsDigit(r) { specials++ } } diff --git a/backend/internal/account/validate_test.go b/backend/internal/account/validate_test.go index e0b72b9..7ccf2ad 100644 --- a/backend/internal/account/validate_test.go +++ b/backend/internal/account/validate_test.go @@ -24,7 +24,16 @@ func TestValidateDisplayName(t *testing.T) { "trailing underscore": {"Name_", "", false}, "trailing dot ok": {"Anna B.", "Anna B.", true}, "double trailing dot": {"Name..", "", false}, - "digit rejected": {"Name2", "", false}, + "trailing digit ok": {"Name2", "Name2", true}, + "trailing year ok": {"Аня2007", "Аня2007", true}, + "five digits ok": {"Player12345", "Player12345", true}, + "six digits rejected": {"Player123456", "", false}, + "mid digit rejected": {"Dark2Wolf", "", false}, + "all digits rejected": {"12345", "", false}, + "digit then dot": {"Name2.", "", false}, + "dot then digit": {"Anna B.2", "", false}, + "sep plus digits ok": {"Night.Fox2007", "Night.Fox2007", true}, // "." is the only special; digits do not count + "max specials+digits": {"a.a.a.a.a.a2007", "a.a.a.a.a.a2007", true}, // 5 dots + a digit run still passes "blank": {" ", "", false}, "too long": {strings.Repeat("a", 33), "", false}, "five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots diff --git a/backend/internal/game/seatname_test.go b/backend/internal/game/seatname_test.go new file mode 100644 index 0000000..442c7a7 --- /dev/null +++ b/backend/internal/game/seatname_test.go @@ -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) + } +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 3da6844..1e21942 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -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 diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 68e6557..ad831d8 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -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 diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 6773840..c5dc1d0 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -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 diff --git a/backend/internal/inttest/open_match_test.go b/backend/internal/inttest/open_match_test.go index 4f7f272..e266e7f 100644 --- a/backend/internal/inttest/open_match_test.go +++ b/backend/internal/inttest/open_match_test.go @@ -161,11 +161,11 @@ func TestOpenGameResignRejectedUntilOpponent(t *testing.T) { t.Fatalf("resign while open = %v, want ErrNoOpponentYet", err) } - robotID, err := robots.Pick(engine.VariantEnglish) + robotID, name, err := robots.PickNamed(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } - if _, attached, err := svc.AttachRobot(ctx, g.ID, robotID); err != nil || !attached { + if _, attached, err := svc.AttachRobot(ctx, g.ID, robotID, name); err != nil || !attached { t.Fatalf("attach robot = (attached %v, err %v), want attached", attached, err) } res, err := svc.Resign(ctx, g.ID, starter) diff --git a/backend/internal/inttest/seat_display_name_test.go b/backend/internal/inttest/seat_display_name_test.go new file mode 100644 index 0000000..4c51020 --- /dev/null +++ b/backend/internal/inttest/seat_display_name_test.go @@ -0,0 +1,102 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// The seat display-name snapshot freezes the name an opponent sees for the life of a +// game (a later rename no longer rewrites past games) and lets a disguised robot carry a +// fresh per-game name (docs/ARCHITECTURE.md §7). + +// seatByAccount returns the seat held by id and whether it was found. +func seatByAccount(g game.Game, id uuid.UUID) (game.Seat, bool) { + for _, s := range g.Seats { + if s.AccountID == id { + return s, true + } + } + return game.Seat{}, false +} + +// TestSeatNameFrozenAfterRename checks a human seat captures the player's display name +// when the seat is taken and keeps it after the account is later renamed. +func TestSeatNameFrozenAfterRename(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + accounts := account.NewStore(testDB) + + starter := provisionAccount(t) + if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{ + DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC", + }); err != nil { + t.Fatalf("set name: %v", err) + } + + g := openGame(t, svc, starter, evenOpeningSeed(t)) + seat, ok := seatByAccount(g, starter) + if !ok || seat.DisplayName != "Original Name" { + t.Fatalf("opened seat snapshot = %q (found %v), want %q", seat.DisplayName, ok, "Original Name") + } + + // Rename the account; the snapshot on the already-taken seat must not follow. + if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{ + DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC", + }); err != nil { + t.Fatalf("rename: %v", err) + } + reloaded, err := svc.GameByID(ctx, g.ID) + if err != nil { + t.Fatalf("reload game: %v", err) + } + got, _ := seatByAccount(reloaded, starter) + if got.DisplayName != "Original Name" { + t.Errorf("seat snapshot after rename = %q, want it frozen at %q", got.DisplayName, "Original Name") + } +} + +// TestRobotSeatGetsComposedName checks the disguised auto-match robot's seat stores the +// fresh per-game name composed at attach time (PickNamed -> AttachRobot -> seat), and +// that the name is a valid human display name so the robot stays indistinguishable. +func TestRobotSeatGetsComposedName(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + robots := newRobotService(t, svc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + + g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t)) + robotID, name, err := robots.PickNamed(engine.VariantEnglish) + if err != nil { + t.Fatalf("pick named: %v", err) + } + if _, err := account.ValidateDisplayName(name); err != nil { + t.Fatalf("composed robot name %q is not a valid display name: %v", name, err) + } + if _, attached, err := svc.AttachRobot(ctx, g.ID, robotID, name); err != nil || !attached { + t.Fatalf("attach robot = (attached %v, err %v), want attached", attached, err) + } + + reloaded, err := svc.GameByID(ctx, g.ID) + if err != nil { + t.Fatalf("reload game: %v", err) + } + seat, ok := seatByAccount(reloaded, robotID) + if !ok { + t.Fatal("robot seat not found after attach") + } + if seat.DisplayName != name { + t.Errorf("robot seat snapshot = %q, want the composed per-game name %q", seat.DisplayName, name) + } +} diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 598ddab..b088691 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -33,6 +33,9 @@ type GameCreator interface { // available so the matchmaker can defer substitution. type RobotProvider interface { Pick(variant engine.Variant) (uuid.UUID, error) + // PickNamed selects a robot and composes a fresh per-game display name for it, for + // the disguised auto-match path (the name is stamped on the robot's seat). + PickNamed(variant engine.Variant) (uuid.UUID, string, error) } // Blocker reports whether two accounts have a block between them (either diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 18ed0bf..030c1b8 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -19,7 +19,7 @@ import ( // it. type GameMatcher interface { OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error) - AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error) + AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error) ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) // Create seats the given accounts in an active game at once; the AI-match path uses @@ -148,12 +148,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { return } for _, og := range due { - robotID, err := m.robots.Pick(og.Variant) + robotID, name, err := m.robots.PickNamed(og.Variant) if err != nil { m.log.Warn("robot substitution deferred", zap.Error(err)) continue } - g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID) + g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID, name) if err != nil { m.log.Warn("robot substitution failed", zap.String("game", og.ID.String()), zap.Error(err)) continue diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index ce3de67..60cbfd4 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -45,7 +45,7 @@ func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreatePa return s.openGame, s.openJoined, s.openErr } -func (s *stubMatcher) AttachRobot(_ context.Context, gameID, _ uuid.UUID) (game.Game, bool, error) { +func (s *stubMatcher) AttachRobot(_ context.Context, gameID, _ uuid.UUID, _ string) (game.Game, bool, error) { if s.attachErr != nil { return game.Game{}, false, s.attachErr } @@ -85,6 +85,11 @@ func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) { return f.id, nil } +func (f *fakeRobots) PickNamed(variant engine.Variant) (uuid.UUID, string, error) { + id, err := f.Pick(variant) + return id, "Robot", err +} + // capturePub records every published intent. type capturePub struct{ intents []notify.Intent } diff --git a/backend/internal/postgres/jet/backend/model/game_players.go b/backend/internal/postgres/jet/backend/model/game_players.go index 8018a41..1aaf584 100644 --- a/backend/internal/postgres/jet/backend/model/game_players.go +++ b/backend/internal/postgres/jet/backend/model/game_players.go @@ -12,10 +12,11 @@ import ( ) type GamePlayers struct { - GameID uuid.UUID `sql:"primary_key"` - Seat int16 `sql:"primary_key"` - AccountID *uuid.UUID - Score int32 - HintsUsed int16 - IsWinner bool + GameID uuid.UUID `sql:"primary_key"` + Seat int16 `sql:"primary_key"` + AccountID *uuid.UUID + Score int32 + HintsUsed int16 + IsWinner bool + DisplayName string } diff --git a/backend/internal/postgres/jet/backend/table/game_players.go b/backend/internal/postgres/jet/backend/table/game_players.go index 1f720b7..e9c8a28 100644 --- a/backend/internal/postgres/jet/backend/table/game_players.go +++ b/backend/internal/postgres/jet/backend/table/game_players.go @@ -17,12 +17,13 @@ type gamePlayersTable struct { postgres.Table // Columns - GameID postgres.ColumnString - Seat postgres.ColumnInteger - AccountID postgres.ColumnString - Score postgres.ColumnInteger - HintsUsed postgres.ColumnInteger - IsWinner postgres.ColumnBool + GameID postgres.ColumnString + Seat postgres.ColumnInteger + AccountID postgres.ColumnString + Score postgres.ColumnInteger + HintsUsed postgres.ColumnInteger + IsWinner postgres.ColumnBool + DisplayName postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -64,27 +65,29 @@ func newGamePlayersTable(schemaName, tableName, alias string) *GamePlayersTable func newGamePlayersTableImpl(schemaName, tableName, alias string) gamePlayersTable { var ( - GameIDColumn = postgres.StringColumn("game_id") - SeatColumn = postgres.IntegerColumn("seat") - AccountIDColumn = postgres.StringColumn("account_id") - ScoreColumn = postgres.IntegerColumn("score") - HintsUsedColumn = postgres.IntegerColumn("hints_used") - IsWinnerColumn = postgres.BoolColumn("is_winner") - allColumns = postgres.ColumnList{GameIDColumn, SeatColumn, AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn} - mutableColumns = postgres.ColumnList{AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn} - defaultColumns = postgres.ColumnList{ScoreColumn, HintsUsedColumn, IsWinnerColumn} + GameIDColumn = postgres.StringColumn("game_id") + SeatColumn = postgres.IntegerColumn("seat") + AccountIDColumn = postgres.StringColumn("account_id") + ScoreColumn = postgres.IntegerColumn("score") + HintsUsedColumn = postgres.IntegerColumn("hints_used") + IsWinnerColumn = postgres.BoolColumn("is_winner") + DisplayNameColumn = postgres.StringColumn("display_name") + allColumns = postgres.ColumnList{GameIDColumn, SeatColumn, AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn, DisplayNameColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn, DisplayNameColumn} + defaultColumns = postgres.ColumnList{ScoreColumn, HintsUsedColumn, IsWinnerColumn, DisplayNameColumn} ) return gamePlayersTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - GameID: GameIDColumn, - Seat: SeatColumn, - AccountID: AccountIDColumn, - Score: ScoreColumn, - HintsUsed: HintsUsedColumn, - IsWinner: IsWinnerColumn, + GameID: GameIDColumn, + Seat: SeatColumn, + AccountID: AccountIDColumn, + Score: ScoreColumn, + HintsUsed: HintsUsedColumn, + IsWinner: IsWinnerColumn, + DisplayName: DisplayNameColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00007_seat_display_name.sql b/backend/internal/postgres/migrations/00007_seat_display_name.sql new file mode 100644 index 0000000..b06f23c --- /dev/null +++ b/backend/internal/postgres/migrations/00007_seat_display_name.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- A per-seat snapshot of the player's display name, captured when the seat is taken: +-- the human's then-current display name, or a disguised robot's freshly composed +-- per-game name. Storing the name on the seat (rather than always reading the account) +-- freezes what the opponent sees for the life of the game — a later rename no longer +-- rewrites past games — and lets the small robot pool present an ever-changing crowd of +-- differently named opponents. An empty value means "no snapshot": the reader falls back +-- to the account's current display name (legacy rows / pre-migration games). See +-- docs/ARCHITECTURE.md §7. +SET search_path = backend, pg_catalog; + +ALTER TABLE game_players ADD COLUMN display_name text NOT NULL DEFAULT ''; + +-- +goose Down +SET search_path = backend, pg_catalog; +ALTER TABLE game_players DROP COLUMN display_name; diff --git a/backend/internal/robot/namevariety.go b/backend/internal/robot/namevariety.go new file mode 100644 index 0000000..be0dade --- /dev/null +++ b/backend/internal/robot/namevariety.go @@ -0,0 +1,297 @@ +package robot + +import ( + "math/rand/v2" + "strconv" + + "scrabble/backend/internal/engine" +) + +// This file holds the wide, multi-locale corpus and the generator that gives each +// auto-match robot a fresh, per-game display name (composeRandomName, reached through +// Service.PickNamed). It is deliberately separate from the seeding pools in names.go: +// those are a fixed 32-per-language set bound 1:1 to the durable robot accounts (a +// stable fallback name), whereas this corpus only sources throwaway per-game names and +// can grow freely. Because the chosen name is stored on the seat rather than on the +// account, the small pool of accounts presents as an ever-changing crowd of opponents. +// +// Every composed name stays within account.ValidateDisplayName's editable format +// (Unicode letters, single "." / "_" separators, an optional trailing run of up to five +// digits), so a disguised robot remains indistinguishable from a human +// (docs/ARCHITECTURE.md §7). A Russian-variant game draws Cyrillic names with at most +// latinShareInRussian% Latin and never a CJK script; an English game draws the full +// international corpus (Western Latin names, native Japanese/Chinese names, Latin handles). + +// nickSharePercent is the share of per-game names rendered as a handle (e.g. +// "DarkWolf07") rather than a person's real name. +const nickSharePercent = 35 + +// cjkShareIntl is the share of an international game's real names rendered in a native +// Japanese or Chinese script; the remainder are Western Latin names. +const cjkShareIntl = 25 + +// composeRandomName builds a fresh display name appropriate to the game variant (see +// the file overview for the routing). The result always satisfies +// account.ValidateDisplayName. +func composeRandomName(variant engine.Variant) string { + if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit { + if rand.IntN(100) < latinShareInRussian { + return latinPersona() + } + return cyrillicPersona() + } + return intlPersona() +} + +// cyrillicPersona returns a Cyrillic real name or handle. +func cyrillicPersona() string { + if rand.IntN(100) < nickSharePercent { + return cyrillicNick() + } + return cyrillicReal() +} + +// latinPersona returns a Western Latin real name or handle (also the Latin minority of +// Russian games and the handles of international games). +func latinPersona() string { + if rand.IntN(100) < nickSharePercent { + return latinNick() + } + return latinReal() +} + +// intlPersona returns an international name: a Latin handle, a native Japanese/Chinese +// real name, or a Western Latin real name. +func intlPersona() string { + if rand.IntN(100) < nickSharePercent { + return latinNick() + } + if rand.IntN(100) < cjkShareIntl { + return cjkReal() + } + return latinReal() +} + +// latinReal composes a Western full name within a single locale so it reads naturally. +func latinReal() string { + loc := latinLocales[rand.IntN(len(latinLocales))] + first := loc.first[rand.IntN(len(loc.first))] + surname := loc.surnames[rand.IntN(len(loc.surnames))] + return composeName(first, surname, rand.IntN(3)) +} + +// cyrillicReal composes a Russian full name, agreeing the surname form with the first +// name's grammatical gender, in one of the three rendering forms. +func cyrillicReal() string { + fn := firstNamesRUWide[rand.IntN(len(firstNamesRUWide))] + sp := surnamesRUWide[rand.IntN(len(surnamesRUWide))] + surname := sp.m + if fn.female { + surname = sp.f + } + return composeName(fn.name, surname, rand.IntN(3)) +} + +// cjkReal composes a native Japanese or Chinese name. +func cjkReal() string { + if rand.IntN(2) == 0 { + return japaneseName() + } + return chineseName() +} + +// japaneseName composes a Japanese name: family-then-given (space-joined), or a bare +// family or given name as a handle. +func japaneseName() string { + family := jpSurnames[rand.IntN(len(jpSurnames))] + given := jpGiven[rand.IntN(len(jpGiven))] + switch rand.IntN(10) { + case 0, 1, 2: + return given + case 3: + return family + default: + return family + " " + given + } +} + +// chineseName composes a Chinese name: family then given, written without a separator. +func chineseName() string { + family := cnSurnames[rand.IntN(len(cnSurnames))] + given := cnGiven[rand.IntN(len(cnGiven))] + return family + given +} + +// latinNick builds a Latin handle from the adjective and noun stem pools. +func latinNick() string { + return composeNick(adjLatin, nounLatin) +} + +// cyrillicNick builds a Cyrillic handle from the adjective and noun stem pools. +func cyrillicNick() string { + return composeNick(adjCyr, nounCyr) +} + +// composeNick assembles a handle from an adjective and noun pool: a bare noun, an +// adjective and noun joined by "_" or "." or camel-cased, optionally followed by a +// trailing number. It keeps at most one separator so the result, the number aside, +// reads like a name a human could pick. +func composeNick(adjectives, nouns []string) string { + adj := adjectives[rand.IntN(len(adjectives))] + noun := nouns[rand.IntN(len(nouns))] + var base string + switch rand.IntN(4) { + case 0: + base = noun + case 1: + base = adj + "_" + noun + case 2: + base = adj + "." + noun + default: + base = adj + noun + } + if rand.IntN(2) == 0 { + base += nickNumber() + } + return base +} + +// nickNumber returns a short trailing number a handle commonly carries — a single +// digit, a two-digit suffix, or a four-digit year — always within the validator's +// five-digit cap. +func nickNumber() string { + switch rand.IntN(3) { + case 0: + return strconv.Itoa(rand.IntN(9) + 1) // 1–9 + case 1: + return strconv.Itoa(rand.IntN(90) + 10) // 10–99 + default: + return strconv.Itoa(rand.IntN(40) + 1985) // 1985–2024 + } +} + +// latinLocale is one Western locale's first names and surnames, paired only within the +// locale so a composed full name reads naturally. +type latinLocale struct { + first []string + surnames []string +} + +// latinLocales are the Western (Latin-script) locales the international and Russian-Latin +// pools draw from: English (reusing the seeding pools), German, Spanish, Italian, +// French, Portuguese. +var latinLocales = []latinLocale{ + {first: firstNamesFullEN, surnames: surnamesEN}, + { // German + first: []string{"Lukas", "Leon", "Finn", "Jonas", "Felix", "Maximilian", "Paul", "Elias", "Noah", "Ben", "Klaus", "Wolfgang", "Hannah", "Emma", "Mia", "Sofia", "Lena", "Marie", "Laura", "Greta", "Ingrid", "Stefan"}, + surnames: []string{"Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner", "Becker", "Hoffmann", "Koch", "Richter", "Bauer", "Klein", "Wolf", "Schröder", "Neumann", "Braun", "Werner", "Krüger", "Hartmann", "Lange", "Schulze"}, + }, + { // Spanish + first: []string{"Mateo", "Hugo", "Martín", "Lucas", "Daniel", "Pablo", "Álvaro", "Diego", "Javier", "Carlos", "Lucía", "Sofía", "María", "Martina", "Paula", "Valeria", "Carmen", "Elena", "Sara", "Pilar", "Andrés", "Sergio"}, + surnames: []string{"García", "Martínez", "López", "Sánchez", "González", "Rodríguez", "Fernández", "Gómez", "Jiménez", "Ruiz", "Hernández", "Díaz", "Moreno", "Álvarez", "Romero", "Alonso", "Navarro", "Torres", "Ramos", "Serrano", "Castro", "Rubio"}, + }, + { // Italian + first: []string{"Francesco", "Alessandro", "Lorenzo", "Mattia", "Andrea", "Leonardo", "Gabriele", "Riccardo", "Tommaso", "Matteo", "Sofia", "Giulia", "Aurora", "Alice", "Emma", "Giorgia", "Martina", "Chiara", "Giuseppe", "Marco", "Roberto", "Stefano"}, + surnames: []string{"Rossi", "Russo", "Ferrari", "Esposito", "Bianchi", "Romano", "Colombo", "Ricci", "Marino", "Greco", "Bruno", "Gallo", "Conti", "Costa", "Giordano", "Mancini", "Rizzo", "Lombardi", "Moretti", "Barbieri", "Fontana", "Caruso"}, + }, + { // French + first: []string{"Gabriel", "Louis", "Raphaël", "Jules", "Adam", "Arthur", "Nathan", "Léo", "Emma", "Jade", "Louise", "Alice", "Chloé", "Léa", "Manon", "Camille", "Pierre", "Sophie", "Julien", "Céline", "Antoine", "Claire"}, + surnames: []string{"Martin", "Bernard", "Dubois", "Robert", "Richard", "Petit", "Durand", "Leroy", "Moreau", "Simon", "Laurent", "Lefebvre", "Michel", "Bertrand", "Roux", "Vincent", "Fournier", "Morel", "Girard", "Mercier", "Lambert", "Bonnet"}, + }, + { // Portuguese + first: []string{"João", "Pedro", "Tiago", "Miguel", "Tomás", "Rodrigo", "Diogo", "Afonso", "Duarte", "Maria", "Leonor", "Matilde", "Beatriz", "Carolina", "Mariana", "Inês", "Sofia", "Lara", "António", "Rita", "Catarina", "Bruno"}, + surnames: []string{"Silva", "Santos", "Ferreira", "Pereira", "Oliveira", "Costa", "Rodrigues", "Martins", "Sousa", "Fernandes", "Gonçalves", "Gomes", "Lopes", "Marques", "Alves", "Almeida", "Ribeiro", "Pinto", "Carvalho", "Teixeira", "Moreira", "Correia"}, + }, +} + +// jpSurnames and jpGiven are common Japanese family and given names in kanji. +var jpSurnames = []string{ + "佐藤", "鈴木", "高橋", "田中", "渡辺", "伊藤", "山本", "中村", "小林", "加藤", + "吉田", "山田", "山口", "松本", "井上", "木村", "斎藤", "清水", "山崎", "池田", + "橋本", "阿部", +} + +var jpGiven = []string{ + "大翔", "蓮", "陽翔", "悠真", "颯太", "湊", "翔太", "大和", "健太", "樹", + "葵", "陽菜", "凛", "結衣", "美咲", "優奈", "七海", "真央", "愛", "杏", +} + +// cnSurnames and cnGiven are common Chinese family and given names in Han characters. +var cnSurnames = []string{ + "王", "李", "张", "刘", "陈", "杨", "黄", "赵", "周", "吴", + "徐", "孙", "胡", "朱", "高", "林", "何", "郭", "马", "罗", + "梁", "宋", "郑", "韩", +} + +var cnGiven = []string{ + "伟", "芳", "娜", "敏", "静", "丽", "强", "磊", "军", "洋", + "勇", "艳", "杰", "娟", "涛", "明", "超", "霞", "刚", "婷", + "秀英", "建华", +} + +// adjLatin and nounLatin are the adjective and noun stems for Latin handles. +var adjLatin = []string{ + "Dark", "Night", "Shadow", "Frost", "Silent", "Swift", "Wild", "Royal", "Lucky", "Crimson", + "Mystic", "Cosmic", "Epic", "Noble", "Brave", "Lone", "Iron", "Steel", "Golden", "Silver", + "Neon", "Cyber", "Turbo", "Rapid", "Hidden", "Frozen", "Electric", "Savage", +} + +var nounLatin = []string{ + "Wolf", "Fox", "Raven", "Tiger", "Eagle", "Dragon", "Falcon", "Hawk", "Lion", "Bear", + "Panther", "Viper", "Cobra", "Shark", "Phoenix", "Knight", "Ranger", "Hunter", "Rider", "Ghost", + "Phantom", "Comet", "Nova", "Blade", "Arrow", "Storm", "Wizard", "Ninja", +} + +// adjCyr and nounCyr are the adjective and noun stems for Cyrillic handles. +var adjCyr = []string{ + "Тёмный", "Ночной", "Снежный", "Лунный", "Звёздный", "Огненный", "Ледяной", "Стальной", "Дикий", "Тихий", + "Быстрый", "Гордый", "Грозный", "Хитрый", "Смелый", "Весёлый", "Рыжий", "Серый", "Золотой", "Дерзкий", + "Лютый", "Бравый", "Мудрый", "Шальной", "Вольный", "Яростный", "Седой", "Жгучий", +} + +var nounCyr = []string{ + "Волк", "Лис", "Ворон", "Тигр", "Орёл", "Дракон", "Сокол", "Ястреб", "Лев", "Медведь", + "Барс", "Кот", "Кит", "Странник", "Охотник", "Рыцарь", "Маг", "Призрак", "Воин", "Комета", + "Клинок", "Ветер", "Гром", "Шторм", "Пилот", "Капитан", "Мастер", "Енот", +} + +// firstNamesRUWide is a broad pool of Russian first names — full and colloquial forms +// mixed, each tagged by grammatical gender — for the per-game generator (wider than the +// 32-slot seeding pool in names.go). +var firstNamesRUWide = []genderedName{ + {"Александр", false}, {"Саша", false}, {"Дмитрий", false}, {"Дима", false}, + {"Сергей", false}, {"Серёжа", false}, {"Алексей", false}, {"Лёша", false}, + {"Михаил", false}, {"Миша", false}, {"Николай", false}, {"Коля", false}, + {"Владимир", false}, {"Володя", false}, {"Константин", false}, {"Костя", false}, + {"Павел", false}, {"Паша", false}, {"Андрей", false}, {"Андрюша", false}, + {"Иван", false}, {"Ваня", false}, {"Пётр", false}, {"Петя", false}, + {"Роман", false}, {"Рома", false}, {"Антон", false}, {"Евгений", false}, + {"Женя", false}, {"Максим", false}, {"Кирилл", false}, {"Артём", false}, + {"Анна", true}, {"Аня", true}, {"Мария", true}, {"Маша", true}, + {"Анастасия", true}, {"Настя", true}, {"Наталья", true}, {"Наташа", true}, + {"Татьяна", true}, {"Таня", true}, {"Екатерина", true}, {"Катя", true}, + {"Юлия", true}, {"Юля", true}, {"Ольга", true}, {"Оля", true}, + {"Елена", true}, {"Лена", true}, {"Ирина", true}, {"Ира", true}, + {"Светлана", true}, {"Света", true}, {"Дарья", true}, {"Даша", true}, + {"София", true}, {"Соня", true}, {"Полина", true}, {"Алина", true}, + {"Вера", true}, {"Ксения", true}, +} + +// surnamesRUWide is a broad pool of Russian surnames in masculine and feminine forms +// for the per-game generator. +var surnamesRUWide = []surnamePair{ + {"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"}, + {"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"}, + {"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"}, + {"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"}, + {"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"}, + {"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"}, + {"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"}, + {"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"}, + {"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"}, + {"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"}, + {"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"}, {"Васильев", "Васильева"}, + {"Михайлов", "Михайлова"}, {"Петров", "Петрова"}, {"Тарасов", "Тарасова"}, + {"Белов", "Белова"}, {"Комаров", "Комарова"}, {"Гусев", "Гусева"}, + {"Титов", "Титова"}, +} diff --git a/backend/internal/robot/namevariety_test.go b/backend/internal/robot/namevariety_test.go new file mode 100644 index 0000000..73f40b0 --- /dev/null +++ b/backend/internal/robot/namevariety_test.go @@ -0,0 +1,114 @@ +package robot + +import ( + "testing" + "unicode" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" +) + +// anyRune reports whether any rune of s satisfies f. +func anyRune(s string, f func(rune) bool) bool { + for _, r := range s { + if f(r) { + return true + } + } + return false +} + +func isCyrillic(r rune) bool { return unicode.Is(unicode.Cyrillic, r) } +func isLatin(r rune) bool { return unicode.Is(unicode.Latin, r) } +func isCJK(r rune) bool { + return unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) +} + +// TestComposeRandomNameValid is the disguise invariant: every per-game name a robot can +// receive, in every variant, must be a name a human could also pick — i.e. it must pass +// account.ValidateDisplayName — so a disguised robot stays indistinguishable. +func TestComposeRandomNameValid(t *testing.T) { + t.Parallel() + for _, v := range []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit} { + for i := 0; i < 5000; i++ { + name := composeRandomName(v) + if _, err := account.ValidateDisplayName(name); err != nil { + t.Fatalf("variant %s produced an invalid display name %q: %v", v, name, err) + } + } + } +} + +// TestRussianVariantScriptMix checks a Russian game stays Cyrillic-dominant with a small +// Latin minority and never a CJK script, so a Russian-speaking audience is not paired +// with visibly foreign-scripted opponents. +func TestRussianVariantScriptMix(t *testing.T) { + t.Parallel() + const n = 6000 + var cyr, lat, cjk int + for i := 0; i < n; i++ { + name := composeRandomName(engine.VariantRussianScrabble) + switch { + case anyRune(name, isCyrillic): + cyr++ + case anyRune(name, isCJK): + cjk++ + case anyRune(name, isLatin): + lat++ + } + } + if cjk != 0 { + t.Errorf("a Russian game must never draw a CJK name, got %d of %d", cjk, n) + } + if lat == 0 { + t.Errorf("a Russian game should show some Latin names, got 0 of %d", n) + } + if cyr <= lat { + t.Errorf("Cyrillic names should dominate a Russian game: cyrillic=%d latin=%d", cyr, lat) + } + // The Latin share is governed by latinShareInRussian (~20%); allow a wide band. + if share := lat * 100 / n; share < 8 || share > 32 { + t.Errorf("Latin share %d%% out of the expected ~%d%% band", share, latinShareInRussian) + } +} + +// TestEnglishVariantScriptMix checks an English game draws an international crowd: mostly +// Western Latin names, a visible Japanese/Chinese minority, and never Cyrillic. +func TestEnglishVariantScriptMix(t *testing.T) { + t.Parallel() + const n = 6000 + var lat, cjk, cyr int + for i := 0; i < n; i++ { + name := composeRandomName(engine.VariantEnglish) + switch { + case anyRune(name, isCJK): + cjk++ + case anyRune(name, isCyrillic): + cyr++ + case anyRune(name, isLatin): + lat++ + } + } + if cyr != 0 { + t.Errorf("an English game must not draw Cyrillic names, got %d of %d", cyr, n) + } + if cjk == 0 { + t.Errorf("an English game should show some CJK names, got 0 of %d", n) + } + if lat <= cjk { + t.Errorf("Latin names should dominate an English game: latin=%d cjk=%d", lat, cjk) + } +} + +// TestComposeRandomNameVariety checks the corpus yields a large spread of distinct names +// (the point of the feature), so the same opponent rarely recurs. +func TestComposeRandomNameVariety(t *testing.T) { + t.Parallel() + seen := make(map[string]struct{}) + for i := 0; i < 2000; i++ { + seen[composeRandomName(engine.VariantEnglish)] = struct{}{} + } + if len(seen) < 1000 { + t.Errorf("expected a wide spread of names, got only %d distinct of 2000", len(seen)) + } +} diff --git a/backend/internal/robot/robot.go b/backend/internal/robot/robot.go index 37f59f4..d73ef05 100644 --- a/backend/internal/robot/robot.go +++ b/backend/internal/robot/robot.go @@ -175,6 +175,21 @@ func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) { return primary[rand.IntN(len(primary))], nil } +// PickNamed selects a robot account like Pick and, alongside it, composes a fresh, +// variant-appropriate per-game display name (composeRandomName) for the matchmaker to +// stamp on the robot's seat. Unlike the account's seeded fallback name, this name is +// generated anew for every match, so the small pool of durable accounts presents as an +// ever-changing crowd of opponents; it stays within account.ValidateDisplayName's +// format so a disguised robot remains indistinguishable from a human +// (docs/ARCHITECTURE.md §7). +func (s *Service) PickNamed(variant engine.Variant) (uuid.UUID, string, error) { + id, err := s.Pick(variant) + if err != nil { + return uuid.Nil, "", err + } + return id, composeRandomName(variant), nil +} + // poolIDs returns a snapshot of the whole pool (both languages) for the driver scan, // which is variant-agnostic — it acts on every robot's active games. func (s *Service) poolIDs() []uuid.UUID { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fc19c3f..32ef324 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -392,13 +392,25 @@ replay. A pool of durable accounts — each a `kind='robot'` identity (§4), key `robot--` and provisioned at startup with **chat blocked but friend requests open** — a request to a robot is accepted as pending and expires unanswered (the robot never responds), mirroring a human who ignores it; the chat -block backs the human-like names (there is no DM surface; chat is per-game). Names are -**composed per language** from a first-name pool (32 full + 32 colloquial forms) and -a surname pool (gender-agreed for Russian) in one of three forms (first only / -first + surname initial / first + full surname), deterministically per pool slot so -they stay stable across restarts. Substitution is **variant-aware**: a Russian game -(Russian Scrabble or Эрудит) draws a Russian-named robot with at most ~20% Latin, an -English game the Latin pool. +block backs the human-like names (there is no DM surface; chat is per-game). + +**Per-game names.** A seated player's display name is **snapshotted on the seat** +(`game_players.display_name`) when the seat is taken — a human's then-current name, or a +disguised robot's freshly composed name — so the name an opponent sees is frozen for the life +of the game (a later rename never rewrites past games) and a small pool of accounts presents +as an ever-changing crowd. A reader falls back to the account's current name when a seat +carries no snapshot (a pre-migration row). Each durable robot account still seeds a stable +fallback name (32 composed per language), but the disguised reaper stamps a **fresh per-game +name** drawn from a wide composed corpus: Western first/surname pools per locale (English, +German, Spanish, Italian, French, Portuguese) in one of three forms (first only / first + +surname initial / first + full surname), native Japanese/Chinese names, a gender-agreed +Russian pool, and human-style handles (a stem, an optional `.`/`_`, an optional trailing +number). Selection is **variant-aware**: a Russian game (Russian Scrabble or Эрудит) draws a +Cyrillic name or handle with at most ~20% Latin analogue and **never a CJK script**; an +English game draws the full international corpus. Every composed name stays within the +editable display-name format (`account.ValidateDisplayName`) — which now admits a **trailing +run of up to five digits** (so "Player2007"-style handles are valid for humans too) — so the +disguised robot stays indistinguishable from a person. - **Balance**: at game start it decides once whether to play to win, with `P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index a5449d8..725623d 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -146,8 +146,10 @@ yet holds to the plan once the bag empties, and plays at a human pace — short times for most moves, the occasional long one, and a night-time pause that tracks the player's own day. It answers a nudge within a few minutes and nudges back when the player has been away a long time. It -carries a human-like, language-appropriate name (a Russian game draws mostly Russian -names); it does not chat, and **silently ignores friend requests** — a request to a +carries a human-like, language-appropriate name — a fresh one each game, drawn from a wide +international pool of real names and handles, so the arena feels populated by many different +players (a Russian game shows mostly Russian names, never East-Asian scripts; an international +game the full mix); it does not chat, and **silently ignores friend requests** — a request to a robot stays pending and expires, exactly like a human who never responds. The same robot also backs the **honest-AI quick game** the player chooses directly (above). There @@ -182,8 +184,9 @@ message raises an **unread badge** on the game's score bar and the 💬 until th ### Profile & settings Edit the display name (letters joined by a single space / "." / "_" separator, with an -optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_" -punctuation, spaces aside), the timezone (chosen as a UTC offset), the +optional trailing "." or a trailing run of up to five digits, up to 32 characters and at most +5 special characters — the "." / "_" punctuation, spaces and digits aside), the timezone +(chosen as a UTC offset), the daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. The profile form is edited inline (no separate edit mode). Linking an email or Telegram and merging accounts are covered under "Accounts, linking & diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 575ca4c..e851e94 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -151,7 +151,9 @@ nudge) приходят от бота **этой партии** — по язы человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее -языку партии (в русской партии — в основном русские имена); не общается в чате и +языку партии — каждую партию новое, из широкого международного пула реальных имён и +никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном +русские имена, без восточноазиатских письменностей; в международной — весь спектр); не общается в чате и **молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает, ровно как у человека, который не отвечает. @@ -186,8 +188,8 @@ push доставляется через платформу. ### Профиль и настройки Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / -«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов — -пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от +«_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не +более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется сразу (без отдельного режима редактирования). Привязка email и Telegram, а также diff --git a/ui/src/lib/profileValidation.test.ts b/ui/src/lib/profileValidation.test.ts index c54a4f0..eb87458 100644 --- a/ui/src/lib/profileValidation.test.ts +++ b/ui/src/lib/profileValidation.test.ts @@ -16,7 +16,16 @@ describe('validDisplayName', () => { ['Name.', true], ['Name..', false], ['Name_', false], - ['Name2', false], + ['Name2', true], // a trailing digit is allowed + ['Аня2007', true], // a trailing year + ['Player12345', true], // 5 trailing digits — the max + ['Player123456', false], // 6 trailing digits — over the limit + ['Dark2Wolf', false], // a digit in the middle + ['12345', false], // must start with a letter + ['Name2.', false], // digits and a trailing dot cannot combine + ['Anna B.2', false], // a trailing dot and digits cannot combine + ['Night.Fox2007', true], // one "." special; digits do not count + ['a.a.a.a.a.a2007', true], // 5 dots + a digit run still passes ['', false], ['a'.repeat(33), false], ['a.a.a.a.a.a', true], // 5 dots — at the special-char limit diff --git a/ui/src/lib/profileValidation.ts b/ui/src/lib/profileValidation.ts index c51ce3a..a7d12de 100644 --- a/ui/src/lib/profileValidation.ts +++ b/ui/src/lib/profileValidation.ts @@ -5,8 +5,9 @@ /** maxDisplayName caps the editable display name in runes. */ export const maxDisplayName = 32; -/** maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — every - * rune that is neither a letter nor a space) a display name may carry. Mirrors the Go rule. */ +/** maxDisplayNameSpecials caps the total special characters (every rune that is neither a + * letter, a space, nor a digit — i.e. the "." / "_" separators) a display name may carry. + * A trailing digit run is bounded separately by displayNameRe. Mirrors the Go rule. */ export const maxDisplayNameSpecials = 5; /** maxAwayMinutes bounds the daily away window's length (12 h). */ @@ -14,16 +15,17 @@ export const maxAwayMinutes = 12 * 60; // Unicode letters joined by single space / "." / "_" separators, where a "." or "_" // may be followed by a single space. No leading separator and no adjacent separators -// except " "; a single trailing "." is allowed. Same -// rule as the Go displayNameRe. -const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u; +// except " ". The name may end with EITHER a single trailing "." +// (an initial) OR a run of 1–5 digits (a number or year), but not both; digits never +// appear elsewhere. Same rule as the Go displayNameRe. +const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*(?:\.|[0-9]{1,5})?$/u; /** displayNameError returns true when the trimmed name is a valid display name. */ export function validDisplayName(raw: string): boolean { const name = raw.trim(); const chars = [...name]; if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false; - const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c)).length; + const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c) && !/[0-9]/.test(c)).length; return specials <= maxDisplayNameSpecials; } -- 2.52.0 From 6d665450621506597e9cdcf18dfea91c6e1749e5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 16 Jun 2026 15:22:27 +0200 Subject: [PATCH 2/3] fix(robot): show the per-game name in REST game views, not the account name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat display-name snapshot only reached the live-event path (seatNames); the REST DTO layer still resolved seat names from the account store (fillSeatNames), so the game screen and lobby list showed the robot's seeded account name ("Женя") while the your_turn toast showed its per-game name ("Звёздный_Барс2"). Carry the snapshot into gameDTOFromGame and have fillSeatNames fall back to the account only for a seat with no snapshot (a pre-snapshot legacy row). Friends and invitations keep account names (the persistent identity, not a per-game disguise). --- backend/internal/server/dto.go | 23 ++++++++++------- backend/internal/server/dto_test.go | 33 ++++++++++++++++++++++++ backend/internal/server/handlers_game.go | 9 +++++-- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index b73c3b5..7f1aa2f 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -77,8 +77,9 @@ type moveRecordDTO struct { Total int `json:"total"` } -// seatDTO is one seat's public standing. DisplayName is resolved from the account -// store by the handler (the game domain keys seats by account id only). +// seatDTO is one seat's public standing. DisplayName is the seat's display-name snapshot +// (the per-game name, frozen when the seat was taken); fillSeatNames fills it from the +// account store only when a seat has no snapshot yet (a pre-snapshot legacy row). type seatDTO struct { Seat int `json:"seat"` AccountID string `json:"account_id"` @@ -199,19 +200,21 @@ const awayTimeLayout = "15:04" func gameDTOFromGame(g game.Game) gameDTO { seats := make([]seatDTO, 0, len(g.Seats)) for _, s := range g.Seats { - // An open game's still-empty opponent seat has no account: emit an empty id (the - // display name is left empty by fillSeatNames) so the client shows "searching for - // opponent" rather than the nil-UUID. + // An open game's still-empty opponent seat has no account: emit an empty id (and an + // empty display name) so the client shows "searching for opponent" rather than the + // nil-UUID. DisplayName carries the seat's per-game snapshot; fillSeatNames fills + // only the seats still without one from the account store. accountID := "" if s.AccountID != uuid.Nil { accountID = s.AccountID.String() } seats = append(seats, seatDTO{ - Seat: s.Seat, - AccountID: accountID, - Score: s.Score, - HintsUsed: s.HintsUsed, - IsWinner: s.IsWinner, + Seat: s.Seat, + AccountID: accountID, + DisplayName: s.DisplayName, + Score: s.Score, + HintsUsed: s.HintsUsed, + IsWinner: s.IsWinner, }) } last := g.TurnStartedAt diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index f000d31..6967717 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "net/http" "testing" "time" @@ -111,3 +112,35 @@ func TestMoveRecordDTOFrom(t *testing.T) { t.Fatalf("move dto mismatch: %+v", dto) } } + +// TestGameDTOCarriesSeatSnapshot checks the DTO carries each seat's display-name +// snapshot (so a disguised robot's per-game name reaches the REST views, not just live +// events) rather than leaving it for an account lookup. +func TestGameDTOCarriesSeatSnapshot(t *testing.T) { + g := game.Game{ + Variant: engine.VariantEnglish, Players: 2, TurnTimeout: time.Hour, + Seats: []game.Seat{ + {Seat: 0, AccountID: uuid.New(), DisplayName: "Звёздный_Барс2"}, + {Seat: 1, AccountID: uuid.New(), DisplayName: "Тест"}, + }, + } + dto := gameDTOFromGame(g) + if dto.Seats[0].DisplayName != "Звёздный_Барс2" || dto.Seats[1].DisplayName != "Тест" { + t.Fatalf("seat snapshot not carried into DTO: %+v", dto.Seats) + } +} + +// TestFillSeatNamesKeepsSnapshot checks fillSeatNames leaves a seat whose snapshot is +// already set untouched (no account lookup), so the per-game name is not overwritten by +// the robot's seeded account name. +func TestFillSeatNamesKeepsSnapshot(t *testing.T) { + s := &Server{} // accounts is unused: a present snapshot must skip the lookup + dto := &gameDTO{Seats: []seatDTO{ + {Seat: 0, AccountID: uuid.New().String(), DisplayName: "Звёздный_Барс2"}, + {Seat: 1, AccountID: uuid.New().String(), DisplayName: "Тест"}, + }} + s.fillSeatNames(context.Background(), dto, map[string]string{}) + if dto.Seats[0].DisplayName != "Звёздный_Барс2" || dto.Seats[1].DisplayName != "Тест" { + t.Fatalf("fillSeatNames overwrote a seat snapshot: %+v", dto.Seats) + } +} diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index ba9158f..4791eff 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -66,10 +66,15 @@ type complaintRequest struct { Note string `json:"note"` } -// fillSeatNames resolves each seat's display name from the account store, memoising -// across seats and games within one request. +// fillSeatNames fills each seat's display name from the account store — memoising across +// seats and games within one request — for the seats that carry no per-game snapshot yet +// (a pre-snapshot legacy row). A seat whose snapshot is already set is left untouched, so +// a disguised robot keeps its per-game name rather than reverting to its account name. func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]string) { for i := range g.Seats { + if g.Seats[i].DisplayName != "" { + continue // the seat already carries its per-game snapshot + } id := g.Seats[i].AccountID name, ok := memo[id] if !ok { -- 2.52.0 From 14b5f61af9d8133d4d0a6125cbcbde82c83fff7f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 16 Jun 2026 15:51:00 +0200 Subject: [PATCH 3/3] feat(robot): gender-agree Cyrillic handle adjectives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cyrillic adjective+noun handles paired a masculine adjective with any noun, so a feminine noun read ungrammatically ("Вольный Комета"). Carry masculine + feminine adjective forms (cyrAdjective) and tag each noun's gender (cyrNoun); cyrillicNick now renders the adjective in the agreeing form ("Рыжая Комета", "Дикий Волк"), and a few feminine nouns (Комета/Звезда/Молния/Пантера/Рысь/Буря/Сова/Акула) are added for variety. --- backend/internal/robot/namevariety.go | 65 ++++++++++++++++++--------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/backend/internal/robot/namevariety.go b/backend/internal/robot/namevariety.go index be0dade..53e831a 100644 --- a/backend/internal/robot/namevariety.go +++ b/backend/internal/robot/namevariety.go @@ -122,23 +122,28 @@ func chineseName() string { return family + given } -// latinNick builds a Latin handle from the adjective and noun stem pools. +// latinNick builds a Latin handle from the adjective and noun stem pools. English +// adjectives do not inflect, so any pairing reads naturally. func latinNick() string { - return composeNick(adjLatin, nounLatin) + return assembleHandle(adjLatin[rand.IntN(len(adjLatin))], nounLatin[rand.IntN(len(nounLatin))]) } -// cyrillicNick builds a Cyrillic handle from the adjective and noun stem pools. +// cyrillicNick builds a Cyrillic handle, agreeing the adjective's form with the noun's +// grammatical gender ("Дикий Волк", "Дикая Рысь"). func cyrillicNick() string { - return composeNick(adjCyr, nounCyr) + noun := nounCyr[rand.IntN(len(nounCyr))] + adj := adjCyr[rand.IntN(len(adjCyr))] + form := adj.m + if noun.female { + form = adj.f + } + return assembleHandle(form, noun.word) } -// composeNick assembles a handle from an adjective and noun pool: a bare noun, an -// adjective and noun joined by "_" or "." or camel-cased, optionally followed by a -// trailing number. It keeps at most one separator so the result, the number aside, -// reads like a name a human could pick. -func composeNick(adjectives, nouns []string) string { - adj := adjectives[rand.IntN(len(adjectives))] - noun := nouns[rand.IntN(len(nouns))] +// assembleHandle joins an adjective and noun into a handle: a bare noun, the two joined +// by "_" or "." or camel-cased, optionally followed by a trailing number. It keeps at +// most one separator so the result, the number aside, reads like a name a human could pick. +func assembleHandle(adj, noun string) string { var base string switch rand.IntN(4) { case 0: @@ -242,17 +247,37 @@ var nounLatin = []string{ "Phantom", "Comet", "Nova", "Blade", "Arrow", "Storm", "Wizard", "Ninja", } -// adjCyr and nounCyr are the adjective and noun stems for Cyrillic handles. -var adjCyr = []string{ - "Тёмный", "Ночной", "Снежный", "Лунный", "Звёздный", "Огненный", "Ледяной", "Стальной", "Дикий", "Тихий", - "Быстрый", "Гордый", "Грозный", "Хитрый", "Смелый", "Весёлый", "Рыжий", "Серый", "Золотой", "Дерзкий", - "Лютый", "Бравый", "Мудрый", "Шальной", "Вольный", "Яростный", "Седой", "Жгучий", +// cyrAdjective is a Russian adjective in its masculine and feminine forms, so a handle's +// stem can agree with the gender of its noun. +type cyrAdjective struct{ m, f string } + +// cyrNoun is a Russian noun for a handle, tagged feminine so the paired adjective agrees. +type cyrNoun struct { + word string + female bool } -var nounCyr = []string{ - "Волк", "Лис", "Ворон", "Тигр", "Орёл", "Дракон", "Сокол", "Ястреб", "Лев", "Медведь", - "Барс", "Кот", "Кит", "Странник", "Охотник", "Рыцарь", "Маг", "Призрак", "Воин", "Комета", - "Клинок", "Ветер", "Гром", "Шторм", "Пилот", "Капитан", "Мастер", "Енот", +// adjCyr and nounCyr are the adjective and noun stems for Cyrillic handles; cyrillicNick +// renders the adjective in the form that agrees with the chosen noun's gender. +var adjCyr = []cyrAdjective{ + {"Тёмный", "Тёмная"}, {"Ночной", "Ночная"}, {"Снежный", "Снежная"}, {"Лунный", "Лунная"}, + {"Звёздный", "Звёздная"}, {"Огненный", "Огненная"}, {"Ледяной", "Ледяная"}, {"Стальной", "Стальная"}, + {"Дикий", "Дикая"}, {"Тихий", "Тихая"}, {"Быстрый", "Быстрая"}, {"Гордый", "Гордая"}, + {"Грозный", "Грозная"}, {"Хитрый", "Хитрая"}, {"Смелый", "Смелая"}, {"Весёлый", "Весёлая"}, + {"Рыжий", "Рыжая"}, {"Серый", "Серая"}, {"Золотой", "Золотая"}, {"Дерзкий", "Дерзкая"}, + {"Лютый", "Лютая"}, {"Бравый", "Бравая"}, {"Мудрый", "Мудрая"}, {"Шальной", "Шальная"}, + {"Вольный", "Вольная"}, {"Яростный", "Яростная"}, {"Седой", "Седая"}, {"Жгучий", "Жгучая"}, +} + +var nounCyr = []cyrNoun{ + {"Волк", false}, {"Лис", false}, {"Ворон", false}, {"Тигр", false}, {"Орёл", false}, + {"Дракон", false}, {"Сокол", false}, {"Ястреб", false}, {"Лев", false}, {"Медведь", false}, + {"Барс", false}, {"Кот", false}, {"Кит", false}, {"Странник", false}, {"Охотник", false}, + {"Рыцарь", false}, {"Маг", false}, {"Призрак", false}, {"Воин", false}, {"Клинок", false}, + {"Ветер", false}, {"Гром", false}, {"Шторм", false}, {"Пилот", false}, {"Капитан", false}, + {"Мастер", false}, {"Енот", false}, + {"Комета", true}, {"Звезда", true}, {"Молния", true}, {"Пантера", true}, {"Рысь", true}, + {"Буря", true}, {"Сова", true}, {"Акула", true}, } // firstNamesRUWide is a broad pool of Russian first names — full and colloquial forms -- 2.52.0