feat(robot): per-game opponent names from a wide corpus + frozen seat-name snapshot #73
@@ -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
@@ -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
|
||||
|
||||
@@ -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 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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestSeatNamesUsesSnapshot checks seatNames returns each seat's stored display-name
|
||||
// snapshot directly, without consulting the account store (svc.accounts is nil here, so
|
||||
// any lookup would be skipped or panic).
|
||||
func TestSeatNamesUsesSnapshot(t *testing.T) {
|
||||
svc := &Service{}
|
||||
g := Game{
|
||||
Players: 2,
|
||||
Seats: []Seat{
|
||||
{Seat: 0, AccountID: uuid.New(), DisplayName: "Аня2007"},
|
||||
{Seat: 1, AccountID: uuid.New(), DisplayName: "DarkWolf"},
|
||||
},
|
||||
}
|
||||
got := svc.seatNames(context.Background(), g)
|
||||
want := []string{"Аня2007", "DarkWolf"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("seatNames = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeatDisplayNameFallbackEmpty checks the resolver yields "" — rather than panicking
|
||||
// — for a seat with no snapshot when no account store is available (the empty open seat
|
||||
// and pre-snapshot legacy-row paths).
|
||||
func TestSeatDisplayNameFallbackEmpty(t *testing.T) {
|
||||
svc := &Service{}
|
||||
if got := svc.seatDisplayName(context.Background(), Seat{Seat: 0, AccountID: uuid.New()}); got != "" {
|
||||
t.Errorf("seatDisplayName with no snapshot and no store = %q, want empty", got)
|
||||
}
|
||||
if got := svc.seatDisplayName(context.Background(), Seat{Seat: 1, AccountID: uuid.Nil}); got != "" {
|
||||
t.Errorf("empty-seat name = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -179,17 +179,23 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
}
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool, len(params.Seats))
|
||||
for _, id := range params.Seats {
|
||||
seats := make([]seatInsert, len(params.Seats))
|
||||
for i, id := range params.Seats {
|
||||
if seen[id] {
|
||||
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
|
||||
}
|
||||
seen[id] = true
|
||||
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
|
||||
// Snapshot each seat's display name at creation. For a vs-AI game this stamps the
|
||||
// robot's seeded account name (unchanged behaviour); a disguised auto-match robot
|
||||
// instead gets a fresh per-game name when the reaper attaches it (AttachRobot).
|
||||
acc, err := svc.accounts.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
|
||||
}
|
||||
return Game{}, err
|
||||
}
|
||||
seats[i] = seatInsert{accountID: id, displayName: acc.DisplayName}
|
||||
}
|
||||
|
||||
seed := params.Seed
|
||||
@@ -231,7 +237,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
vsAI: params.VsAI,
|
||||
}
|
||||
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
||||
if err := svc.store.CreateGame(ctx, ins, seats); err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
svc.cache.put(id, g, params.Variant.String())
|
||||
@@ -256,7 +262,8 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
|
||||
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
|
||||
if _, err := svc.accounts.GetByID(ctx, accountID); err != nil {
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID)
|
||||
}
|
||||
@@ -292,13 +299,14 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
}
|
||||
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat
|
||||
// is left empty (uuid.Nil) for the opponent.
|
||||
seats := []uuid.UUID{accountID, uuid.Nil}
|
||||
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first), snapshotting their
|
||||
// display name; the other seat is left empty (a zero seatInsert) for the opponent.
|
||||
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
|
||||
seats := []seatInsert{caller, {}}
|
||||
if seed&1 == 1 {
|
||||
seats = []uuid.UUID{uuid.Nil, accountID}
|
||||
seats = []seatInsert{{}, caller}
|
||||
}
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats)
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
@@ -312,11 +320,12 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
return g, joined, nil
|
||||
}
|
||||
|
||||
// AttachRobot seats robotID in the empty opponent seat of open game gameID and flips
|
||||
// it to active, returning the now-active game and whether it attached (false, with a
|
||||
// zero Game, when a human joined first). It backs the matchmaking reaper.
|
||||
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (Game, bool, error) {
|
||||
attached, err := svc.store.AttachRobot(ctx, gameID, robotID)
|
||||
// AttachRobot seats robotID in the empty opponent seat of open game gameID, stamping
|
||||
// displayName (the robot's fresh per-game name) on the seat, and flips it to active,
|
||||
// returning the now-active game and whether it attached (false, with a zero Game, when a
|
||||
// human joined first). It backs the matchmaking reaper.
|
||||
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (Game, bool, error) {
|
||||
attached, err := svc.store.AttachRobot(ctx, gameID, robotID, displayName)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
@@ -698,22 +707,32 @@ 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).
|
||||
func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string {
|
||||
if svc.accounts == nil {
|
||||
return ""
|
||||
}
|
||||
id, ok := seatAccount(seats, seat)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
// 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 {
|
||||
for _, s := range seats {
|
||||
if s.Seat == seat {
|
||||
return svc.seatDisplayName(ctx, s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scoreLine formats the running scores with recipientSeat's score first, then the remaining
|
||||
// seats in seat order, colon-joined (e.g. "120:95:80") — the recipient-first form used in the
|
||||
@@ -1376,15 +1395,13 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seatNames resolves each seat's display name for GCG export.
|
||||
// seatNames resolves each seat's display name — its captured snapshot, else the
|
||||
// account's current name (seatDisplayName) — for game state and GCG export.
|
||||
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
|
||||
names := make([]string, g.Players)
|
||||
if svc.accounts == nil {
|
||||
return names
|
||||
}
|
||||
for _, s := range g.Seats {
|
||||
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
|
||||
names[s.Seat] = acc.DisplayName
|
||||
if s.Seat >= 0 && s.Seat < len(names) {
|
||||
names[s.Seat] = svc.seatDisplayName(ctx, s)
|
||||
}
|
||||
}
|
||||
return names
|
||||
|
||||
@@ -108,18 +108,27 @@ type activeGame struct {
|
||||
turnTimeoutSecs int
|
||||
}
|
||||
|
||||
// seatInsert is one seat to create: the account to seat (uuid.Nil for the still-empty
|
||||
// opponent seat of an open auto-match game) and the display-name snapshot to stamp on
|
||||
// it — the player's name as of when the seat was taken (account.go, docs/ARCHITECTURE.md §7).
|
||||
type seatInsert struct {
|
||||
accountID uuid.UUID
|
||||
displayName string
|
||||
}
|
||||
|
||||
// CreateGame inserts the games row and one game_players row per seat (seat 0
|
||||
// first) inside a single transaction.
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
return insertGameTx(ctx, tx, ins, seats)
|
||||
})
|
||||
}
|
||||
|
||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||
// first) on tx. A seat whose account id is uuid.Nil is written with a NULL
|
||||
// account_id — the still-empty opponent seat of a StatusOpen auto-match game.
|
||||
func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.UUID) error {
|
||||
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
||||
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
||||
// opponent seat of a StatusOpen auto-match game.
|
||||
func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatInsert) error {
|
||||
status := ins.status
|
||||
if status == "" {
|
||||
status = StatusActive
|
||||
@@ -138,14 +147,14 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
for seat, accountID := range seats {
|
||||
var acc any = accountID
|
||||
if accountID == uuid.Nil {
|
||||
for seat, si := range seats {
|
||||
var acc any = si.accountID
|
||||
if si.accountID == uuid.Nil {
|
||||
acc = postgres.NULL
|
||||
}
|
||||
pi := table.GamePlayers.INSERT(
|
||||
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).VALUES(ins.id, seat, acc)
|
||||
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID, table.GamePlayers.DisplayName,
|
||||
).VALUES(ins.id, seat, acc, si.displayName)
|
||||
if _, err := pi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert seat %d: %w", seat, err)
|
||||
}
|
||||
@@ -175,8 +184,10 @@ func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
// used only when a game is created. A transaction-scoped advisory lock on the
|
||||
// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
|
||||
// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
|
||||
// for the still-empty opponent, in the chosen order) used only when a game is created.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameInsert, seats []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
// for the still-empty opponent, in the chosen order) used only when a game is created;
|
||||
// callerName is the caller's display-name snapshot, stamped on their seat whether they
|
||||
// open a fresh game or fill another player's open one.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
@@ -207,7 +218,7 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameIns
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
|
||||
case e == nil:
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID); er != nil {
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
|
||||
return er
|
||||
}
|
||||
gameID, joined = other, true
|
||||
@@ -225,10 +236,11 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameIns
|
||||
return gameID, joined, created, err
|
||||
}
|
||||
|
||||
// AttachRobot fills the empty opponent seat of open game gameID with robotID and
|
||||
// flips it to active, returning whether it attached. It is a no-op (false) when the
|
||||
// game is no longer open — a human joined first — so the reaper never double-fills.
|
||||
func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (bool, error) {
|
||||
// AttachRobot fills the empty opponent seat of open game gameID with robotID, stamping
|
||||
// displayName (the robot's per-game name) on the seat, and flips it to active, returning
|
||||
// whether it attached. It is a no-op (false) when the game is no longer open — a human
|
||||
// joined first — so the reaper never double-fills.
|
||||
func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (bool, error) {
|
||||
attached := false
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
var status string
|
||||
@@ -242,7 +254,7 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (boo
|
||||
if status != StatusOpen {
|
||||
return nil
|
||||
}
|
||||
if e := fillOpenSeat(ctx, tx, gameID, robotID); e != nil {
|
||||
if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil {
|
||||
return e
|
||||
}
|
||||
attached = true
|
||||
@@ -251,12 +263,13 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (boo
|
||||
return attached, err
|
||||
}
|
||||
|
||||
// fillOpenSeat seats accountID in an open game's empty opponent seat and flips the
|
||||
// game to active, stamping a fresh turn clock. The caller holds the game row.
|
||||
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID) error {
|
||||
// fillOpenSeat seats accountID in an open game's empty opponent seat — stamping
|
||||
// displayName as the seat's display-name snapshot — and flips the game to active with a
|
||||
// fresh turn clock. The caller holds the game row.
|
||||
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.game_players SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
|
||||
gameID, accountID); err != nil {
|
||||
`UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`,
|
||||
gameID, accountID, displayName); err != nil {
|
||||
return fmt.Errorf("fill opponent seat: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
@@ -1131,6 +1144,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
Score: int(p.Score),
|
||||
HintsUsed: int(p.HintsUsed),
|
||||
IsWinner: p.IsWinner,
|
||||
DisplayName: p.DisplayName,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -144,6 +144,11 @@ type Seat struct {
|
||||
Score int
|
||||
HintsUsed int
|
||||
IsWinner bool
|
||||
// DisplayName is the seat's display-name snapshot, captured when the seat was taken
|
||||
// (the human's then-current name, or a disguised robot's per-game name). Empty for a
|
||||
// still-empty open seat or a pre-snapshot legacy row, where readers fall back to the
|
||||
// account's current display name (docs/ARCHITECTURE.md §7).
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// OpenGame identifies an auto-match game waiting for an opponent whose robot
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -18,4 +18,5 @@ type GamePlayers struct {
|
||||
Score int32
|
||||
HintsUsed int16
|
||||
IsWinner bool
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type gamePlayersTable struct {
|
||||
Score postgres.ColumnInteger
|
||||
HintsUsed postgres.ColumnInteger
|
||||
IsWinner postgres.ColumnBool
|
||||
DisplayName postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -70,9 +71,10 @@ func newGamePlayersTableImpl(schemaName, tableName, alias string) gamePlayersTab
|
||||
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}
|
||||
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{
|
||||
@@ -85,6 +87,7 @@ func newGamePlayersTableImpl(schemaName, tableName, alias string) gamePlayersTab
|
||||
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;
|
||||
@@ -0,0 +1,322 @@
|
||||
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. English
|
||||
// adjectives do not inflect, so any pairing reads naturally.
|
||||
func latinNick() string {
|
||||
return assembleHandle(adjLatin[rand.IntN(len(adjLatin))], nounLatin[rand.IntN(len(nounLatin))])
|
||||
}
|
||||
|
||||
// cyrillicNick builds a Cyrillic handle, agreeing the adjective's form with the noun's
|
||||
// grammatical gender ("Дикий Волк", "Дикая Рысь").
|
||||
func cyrillicNick() string {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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:
|
||||
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",
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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{
|
||||
{"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
|
||||
{"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
|
||||
{"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
|
||||
{"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
|
||||
{"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
|
||||
{"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
|
||||
{"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
|
||||
{"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
|
||||
{"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
|
||||
{"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
|
||||
{"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"}, {"Васильев", "Васильева"},
|
||||
{"Михайлов", "Михайлова"}, {"Петров", "Петрова"}, {"Тарасов", "Тарасова"},
|
||||
{"Белов", "Белова"}, {"Комаров", "Комарова"}, {"Гусев", "Гусева"},
|
||||
{"Титов", "Титова"},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,9 +200,10 @@ 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()
|
||||
@@ -209,6 +211,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
||||
seats = append(seats, seatDTO{
|
||||
Seat: s.Seat,
|
||||
AccountID: accountID,
|
||||
DisplayName: s.DisplayName,
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
IsWinner: s.IsWinner,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+19
-7
@@ -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
@@ -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 &
|
||||
|
||||
@@ -151,7 +151,9 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза,
|
||||
подстроенная под день игрока. На nudge отвечает за несколько минут и
|
||||
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
|
||||
языку партии (в русской партии — в основном русские имена); не общается в чате и
|
||||
языку партии — каждую партию новое, из широкого международного пула реальных имён и
|
||||
никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном
|
||||
русские имена, без восточноазиатских письменностей; в международной — весь спектр); не общается в чате и
|
||||
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
|
||||
ровно как у человека, который не отвечает.
|
||||
|
||||
@@ -186,8 +188,8 @@ push доставляется через платформу.
|
||||
|
||||
### Профиль и настройки
|
||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
|
||||
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
|
||||
«_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не
|
||||
более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от
|
||||
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
|
||||
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user