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
+11
View File
@@ -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-<lang>-<index>`.
- **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
+6 -1
View File
@@ -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
+10 -7
View File
@@ -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
// "<dot|underscore> <space>"); 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}+)*\.?$`)
// "<dot|underscore> <space>"). The name may end with EITHER a single trailing "."
// (an initial, "Anna B.") OR a run of 15 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++
}
}
+10 -1
View File
@@ -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
+40
View File
@@ -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)
}
}
+50 -33
View File
@@ -179,17 +179,23 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
}
}
seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range params.Seats {
seats := make([]seatInsert, len(params.Seats))
for i, id := range params.Seats {
if seen[id] {
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
}
seen[id] = true
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
// Snapshot each seat's display name at creation. For a vs-AI game this stamps the
// robot's seeded account name (unchanged behaviour); a disguised auto-match robot
// instead gets a fresh per-game name when the reaper attaches it (AttachRobot).
acc, err := svc.accounts.GetByID(ctx, id)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
}
return Game{}, err
}
seats[i] = seatInsert{accountID: id, displayName: acc.DisplayName}
}
seed := params.Seed
@@ -231,7 +237,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI,
}
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
if err := svc.store.CreateGame(ctx, ins, seats); err != nil {
return Game{}, err
}
svc.cache.put(id, g, params.Variant.String())
@@ -256,7 +262,8 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
if _, err := svc.accounts.GetByID(ctx, accountID); err != nil {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID)
}
@@ -292,13 +299,14 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
status: StatusOpen,
openDeadline: &deadline,
}
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat
// is left empty (uuid.Nil) for the opponent.
seats := []uuid.UUID{accountID, uuid.Nil}
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first), snapshotting their
// display name; the other seat is left empty (a zero seatInsert) for the opponent.
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
seats := []seatInsert{caller, {}}
if seed&1 == 1 {
seats = []uuid.UUID{uuid.Nil, accountID}
seats = []seatInsert{{}, caller}
}
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats)
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
if err != nil {
return Game{}, false, err
}
@@ -312,11 +320,12 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
return g, joined, nil
}
// AttachRobot seats robotID in the empty opponent seat of open game gameID and flips
// it to active, returning the now-active game and whether it attached (false, with a
// zero Game, when a human joined first). It backs the matchmaking reaper.
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (Game, bool, error) {
attached, err := svc.store.AttachRobot(ctx, gameID, robotID)
// AttachRobot seats robotID in the empty opponent seat of open game gameID, stamping
// displayName (the robot's fresh per-game name) on the seat, and flips it to active,
// returning the now-active game and whether it attached (false, with a zero Game, when a
// human joined first). It backs the matchmaking reaper.
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (Game, bool, error) {
attached, err := svc.store.AttachRobot(ctx, gameID, robotID, displayName)
if err != nil {
return Game{}, false, err
}
@@ -698,21 +707,31 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
svc.pub.Publish(intents...)
}
// displayName resolves the display name of the account at the given seat, or "" when the seat
// is absent or the lookup fails (the enriched push then falls back to its plain text).
// seatDisplayName returns the name shown for a seat: its captured display-name snapshot
// (docs/ARCHITECTURE.md §7), falling back to the account's current display name for a
// pre-snapshot legacy row, or "" when neither is available.
func (svc *Service) seatDisplayName(ctx context.Context, s Seat) string {
if s.DisplayName != "" {
return s.DisplayName
}
if svc.accounts == nil || s.AccountID == uuid.Nil {
return ""
}
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
return acc.DisplayName
}
return ""
}
// displayName resolves the display name shown for the account at the given seat, or ""
// when the seat is absent (the enriched push then falls back to its plain text).
func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string {
if svc.accounts == nil {
return ""
for _, s := range seats {
if s.Seat == seat {
return svc.seatDisplayName(ctx, s)
}
}
id, ok := seatAccount(seats, seat)
if !ok {
return ""
}
acc, err := svc.accounts.GetByID(ctx, id)
if err != nil {
return ""
}
return acc.DisplayName
return ""
}
// scoreLine formats the running scores with recipientSeat's score first, then the remaining
@@ -1376,15 +1395,13 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er
return out, nil
}
// seatNames resolves each seat's display name for GCG export.
// seatNames resolves each seat's display name — its captured snapshot, else the
// account's current name (seatDisplayName) — for game state and GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
names := make([]string, g.Players)
if svc.accounts == nil {
return names
}
for _, s := range g.Seats {
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
names[s.Seat] = acc.DisplayName
if s.Seat >= 0 && s.Seat < len(names) {
names[s.Seat] = svc.seatDisplayName(ctx, s)
}
}
return names
+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
+5
View File
@@ -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
+2 -2
View File
@@ -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)
@@ -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)
}
}
+3
View File
@@ -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
+3 -3
View File
@@ -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
+6 -1
View File
@@ -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 }
@@ -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
}
@@ -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,
@@ -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;
+297
View File
@@ -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) // 19
case 1:
return strconv.Itoa(rand.IntN(90) + 10) // 1099
default:
return strconv.Itoa(rand.IntN(40) + 1985) // 19852024
}
}
// 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{
{"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
{"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
{"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
{"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
{"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
{"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
{"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
{"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
{"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
{"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
{"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"}, {"Васильев", "Васильева"},
{"Михайлов", "Михайлова"}, {"Петров", "Петрова"}, {"Тарасов", "Тарасова"},
{"Белов", "Белова"}, {"Комаров", "Комарова"}, {"Гусев", "Гусева"},
{"Титов", "Титова"},
}
+114
View File
@@ -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))
}
}
+15
View File
@@ -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 {
+19 -7
View File
@@ -392,13 +392,25 @@ replay. A pool of durable accounts — each a `kind='robot'` identity (§4), key
`robot-<lang>-<index>` 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.
+7 -4
View File
@@ -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 &
+5 -3
View File
@@ -151,7 +151,9 @@ nudge) приходят от бота **этой партии** — по язы
человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза,
подстроенная под день игрока. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
языку партии (в русской партии — в основном русские имена); не общается в чате и
языку партии — каждую партию новое, из широкого международного пула реальных имён и
никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном
русские имена, без восточноазиатских письменностей; в международной — весь спектр); не общается в чате и
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
ровно как у человека, который не отвечает.
@@ -186,8 +188,8 @@ push доставляется через платформу.
### Профиль и настройки
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
«_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не
более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
+10 -1
View File
@@ -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
+8 -6
View File
@@ -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 "<dot|underscore> <space>"; a single trailing "." is allowed. Same
// rule as the Go displayNameRe.
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
// except "<dot|underscore> <space>". The name may end with EITHER a single trailing "."
// (an initial) OR a run of 15 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;
}