diff --git a/PLAN.md b/PLAN.md index 20d6949..e1f8fbc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -38,7 +38,7 @@ independent (see ARCHITECTURE §9.1). | 2 | Engine package over scrabble-solver | **done** | | 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** | | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** | -| 5 | Robot opponent | todo | +| 5 | Robot opponent | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | todo | | 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo | | 8 | Telegram integration (bot side-service, deep-link, push) | todo | @@ -311,6 +311,50 @@ Open details: deployment target/host; dashboards; load expectations. (both Go workflows already clone the solver sibling and export `BACKEND_DICT_DIR`). +- **Stage 5** (interview + implementation): + - Scope, as in Stages 1–4: **domain layer, no HTTP** — the robot consumes the + public game API as an ordinary seated player (`internal/robot`), so only + `internal/engine` still imports the solver. New: `engine.Candidates()` (decoded + ranked plays) and a thin `game.Service.Candidates` + `RobotTurns` read. + - **Account model** (interview): a pool of **durable accounts**, each a single + `identities` row `kind='robot'` (migration `00004` widens the kind CHECK — a + CHECK-only change, no jetgen). A curated ~16-name pool in code; `EnsurePool` + provisions them idempotently at boot (a hard dependency, like the registry) with + `block_chat`/`block_friend_requests` set, which is **all** the friend/DM blocking + needs (no special-casing). + - **Driver + state** (interview): a background sweeper goroutine + (`robot.Service.Run`/`Drive`, mirroring the timeout sweeper); **every per-game + and per-turn choice is derived deterministically from the game `seed`** (FNV-1a + mix, restart-stable — not `hash/maphash`), so the robot keeps **no extra state**. + `playToWin = mix(seed,"win")%100 < 40`; per-turn `delay`; sleep `drift`. + - **Timing** (interview): per-move delay `2 + 88·u^k` minutes, `u~U(0,1)`, + **k≈3.5 → median ~10 min**, clamped to [2,90]. A daytime nudge on the robot's + turn pulls the move into a 2–10 min reply window; the robot proactively nudges + after **12 h** idle on the human's turn (reusing `social.Nudge`'s once-per-hour + guard; `social.LastNudgeAt` added to detect the human's nudge). + - **Sleep** (interview — resolves the §7-vs-`account.go` mismatch): the robot + sleeps 00:00–07:00 in the **opponent's timezone shifted by a per-game drift ∈ + [−3,+3]h** (so its night overlaps the human's rather than running anti-phase), + computed on the fly per game — **no profile mutation, no concurrency cap**. The + `account.go` away-window comment was corrected accordingly. + - **Margin** (interview): pick the candidate whose resulting margin (own+move−opp) + is closest to **[1,30]** when playing to win / **[−30,−1]** when playing to lose, + tie-broken toward the conservative edge; no legal play → exchange the full rack + when the bag can refill it, else pass. + - **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`) + substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`), + `NewMatchmaker` now takes a `RobotProvider`. A waiter learns of a match — human + pairing **or** substitution — through a new `Poll` + results map; production + delivery is a **match-found notification** (session/in-app push + side-service), + Stage 6/8 — noted in §10. + - **Metrics** (interview, 1+2): robots are durable accounts, so `account_stats` + is the authoritative, complete balance ground-truth (target ~40% robot wins); + an OTel counter (`robot_games_finished_total`, exporter `none` today) and a + structured log cover robot-finished games for live observation. + - **Config**: `BACKEND_ROBOT_DRIVE_INTERVAL` (30 s), `BACKEND_LOBBY_ROBOT_WAIT` + (10 s), `BACKEND_LOBBY_REAPER_INTERVAL` (1 s). No CI change (both Go workflows + already clone the solver sibling and export `BACKEND_DICT_DIR`). + ## Deferred TODOs (cross-stage) - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, diff --git a/backend/README.md b/backend/README.md index f5c917d..fe22d78 100644 --- a/backend/README.md +++ b/backend/README.md @@ -44,10 +44,22 @@ remains. As before this is a service/store layer — chat and nudges are persist but their live delivery, and all REST endpoints, arrive with the `gateway` (Stage 6); the services are exposed via `Server` accessors for those handlers. +Stage 5 adds 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 a human-like name pool. 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, and times its moves with a right-skewed +delay, a night-sleep window anchored to the opponent's timezone, and nudge +behaviour — all derived deterministically from the game seed, so it keeps no extra +state. The matchmaker now substitutes a pooled robot after a 10-second wait and +exposes `Poll` so a waiting player can collect the started game (the live +match-found notification arrives with the `gateway`). + ## Package layout ``` -cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> lobby+social -> server +cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> robot pool+driver -> lobby+social -> server cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container internal/config/ # env configuration (composes postgres + telemetry + game config) internal/telemetry/ # OpenTelemetry providers + per-request timing middleware @@ -60,7 +72,8 @@ internal/server/ # gin engine, route groups, X-User-ID middleware, probes internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter -internal/lobby/ # in-memory matchmaking pool + friend-game invitations +internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations +internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver ``` ## Configuration (environment) @@ -81,6 +94,9 @@ internal/lobby/ # in-memory matchmaking pool + friend-game invitations | `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. | | `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. | | `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. | +| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. | +| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. | +| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. | | `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). | | `BACKEND_SMTP_PORT` | `587` | Email relay port. | | `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index c31ed6b..44cee3d 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -23,6 +23,7 @@ import ( "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/postgres" + "scrabble/backend/internal/robot" "scrabble/backend/internal/server" "scrabble/backend/internal/session" "scrabble/backend/internal/social" @@ -54,7 +55,8 @@ func main() { // run wires the process dependencies in order — telemetry, database (with // migrations), engine dictionaries, session cache, game domain (with its -// turn-timeout sweeper), HTTP server — and blocks until ctx is cancelled. +// turn-timeout sweeper), the robot opponent (pool + move driver) and the +// matchmaking reaper, HTTP server — and blocks until ctx is cancelled. func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { tel, err := telemetry.New(ctx, cfg.Telemetry) if err != nil { @@ -103,15 +105,27 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Info("game turn-timeout sweeper started", zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) - // Stage 4 lobby & social domains. They have no active driver yet — their REST - // and stream surface is added with the gateway in Stage 6 — so they are handed - // to the server (like the route groups) for the handlers to come. + // Stage 4 lobby & social domains. Their REST and stream surface is added with + // the gateway in Stage 6, so they are handed to the server (like the route + // groups) for the handlers to come. mailer := newMailer(cfg.SMTP, logger) emails := account.NewEmailService(accounts, mailer) socialSvc := social.NewService(social.NewStore(db), accounts, games) - matchmaker := lobby.NewMatchmaker(games) + + // Stage 5 robot opponent: provision its durable account pool (a hard startup + // dependency, like the dictionaries) and start its move driver. The matchmaker + // substitutes a pooled robot for a missing human after the wait window. + robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger) + if err := robots.EnsurePool(ctx); err != nil { + return fmt.Errorf("provision robot pool: %w", err) + } + go robots.Run(ctx, cfg.Robot.DriveInterval) + logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval)) + + matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger) + go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) - logger.Info("lobby and social domains ready") + logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait)) srv := server.New(cfg.HTTPAddr, server.Deps{ Logger: logger, diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index e18ba05..df418a8 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -21,10 +21,12 @@ import ( // Identity kinds recognised by the backend. Email is modelled as an identity // alongside platform identities; its confirmed flag is driven by the email -// confirm-code flow in a later stage. +// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled +// robot opponent is a durable account bound to one robot identity (Stage 5). const ( KindTelegram = "telegram" KindEmail = "email" + KindRobot = "robot" ) // uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation. @@ -34,10 +36,12 @@ const uniqueViolation = "23505" var ErrNotFound = errors.New("account: not found") // Account is a durable internal account. AwayStart and AwayEnd bound the daily -// local-time window (in TimeZone) during which the player is asleep: the -// turn-timeout sweeper does not auto-resign them inside it, and the robot reuses -// it for its own sleep in a later stage. HintBalance is the player's wallet of -// purchasable hints, spent after a game's per-seat allowance. +// local-time window (in TimeZone) during which the player is asleep, so the +// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's +// own sleep is anchored to its human opponent's timezone with a per-game drift, +// computed in internal/robot, not from a robot account's away window.) HintBalance +// is the player's wallet of purchasable hints, spent after a game's per-seat +// allowance. type Account struct { ID uuid.UUID DisplayName string diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a9f6639..53e1e51 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -10,7 +10,9 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/game" + "scrabble/backend/internal/lobby" "scrabble/backend/internal/postgres" + "scrabble/backend/internal/robot" "scrabble/backend/internal/telemetry" ) @@ -26,6 +28,10 @@ type Config struct { Telemetry telemetry.Config // Game configures the game subsystem (dictionaries, sweeper, live-game cache). Game game.Config + // Lobby configures matchmaking robot substitution (wait window, reaper cadence). + Lobby lobby.Config + // Robot configures the robot opponent driver (scan cadence). + Robot robot.Config // SMTP configures the email relay used for confirm-codes. An empty Host // selects the development log mailer (the code is logged, not sent). SMTP account.SMTPConfig @@ -71,6 +77,19 @@ func Load() (Config, error) { return Config{}, err } + lb := lobby.DefaultConfig() + if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil { + return Config{}, err + } + if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil { + return Config{}, err + } + + rb := robot.DefaultConfig() + if rb.DriveInterval, err = envDuration("BACKEND_ROBOT_DRIVE_INTERVAL", rb.DriveInterval); err != nil { + return Config{}, err + } + smtp := account.SMTPConfig{ Host: os.Getenv("BACKEND_SMTP_HOST"), Port: envOr("BACKEND_SMTP_PORT", "587"), @@ -85,6 +104,8 @@ func Load() (Config, error) { Postgres: pg, Telemetry: tel, Game: gm, + Lobby: lb, + Robot: rb, SMTP: smtp, } if err := c.validate(); err != nil { @@ -112,6 +133,12 @@ func (c Config) validate() error { if err := c.Game.Validate(); err != nil { return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err) } + if err := c.Lobby.Validate(); err != nil { + return fmt.Errorf("config: %w", err) + } + if err := c.Robot.Validate(); err != nil { + return fmt.Errorf("config: %w", err) + } return nil } diff --git a/backend/internal/engine/domain.go b/backend/internal/engine/domain.go index 7312d34..eeafd0d 100644 --- a/backend/internal/engine/domain.go +++ b/backend/internal/engine/domain.go @@ -107,6 +107,21 @@ func (g *Game) HintView() (MoveRecord, bool) { return g.decodeMove(move), true } +// Candidates returns every legal play for the current player as decoded +// MoveRecords, ranked by descending score (so the first entry equals HintView's +// move). It is empty when the player has no legal play. The robot opponent picks +// from these by margin without importing the solver; each record carries the +// move's score, so a caller can choose by resulting score difference rather than +// always taking the maximum. +func (g *Game) Candidates() []MoveRecord { + moves := g.GenerateMoves() + out := make([]MoveRecord, len(moves)) + for i, m := range moves { + out[i] = g.decodeMove(m) + } + return out +} + // Hand returns the player's current rack decoded to concrete letters, with "?" // for each undesignated blank. The order mirrors the internal hand. It supplies // the GCG rack field and the per-player game-state view. diff --git a/backend/internal/engine/domain_test.go b/backend/internal/engine/domain_test.go index bafe552..56571d1 100644 --- a/backend/internal/engine/domain_test.go +++ b/backend/internal/engine/domain_test.go @@ -43,6 +43,37 @@ func TestSubmitPlayMatchesHint(t *testing.T) { } } +// TestCandidatesRankedAndMatchesHint checks that Candidates decodes every +// generated move, ranks them by descending score, and leads with the same move +// HintView reveals. +func TestCandidatesRankedAndMatchesHint(t *testing.T) { + g := openingGame(t) + cands := g.Candidates() + if len(cands) == 0 { + t.Fatal("opening game has no candidates") + } + if got, want := len(cands), len(g.GenerateMoves()); got != want { + t.Errorf("candidate count = %d, want %d (one per generated move)", got, want) + } + for i := 1; i < len(cands); i++ { + if cands[i-1].Score < cands[i].Score { + t.Errorf("candidates not ranked: [%d].Score=%d < [%d].Score=%d", i-1, cands[i-1].Score, i, cands[i].Score) + } + } + hint, ok := g.HintView() + if !ok { + t.Fatal("opening game has no hint") + } + if cands[0].Score != hint.Score { + t.Errorf("top candidate score = %d, want hint score %d", cands[0].Score, hint.Score) + } + for _, c := range cands { + if c.Action != ActionPlay { + t.Errorf("candidate action = %v, want play", c.Action) + } + } +} + // TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but // leaves the board, scores, turn and bag untouched. func TestEvaluatePlayDoesNotCommit(t *testing.T) { diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index e601a72..a1394fa 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -426,6 +426,43 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil } +// Candidates returns the to-move player's legal plays for a seated player on +// their turn, ranked by descending score. It is the read the robot opponent uses +// to choose a move by margin; it spends nothing and mutates no state. It returns +// ErrNotAPlayer, ErrFinished or ErrNotYourTurn like the other turn-scoped reads. +func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) { + pre, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return nil, err + } + seat, ok := pre.seatOf(accountID) + if !ok { + return nil, ErrNotAPlayer + } + if pre.Status != StatusActive { + return nil, ErrFinished + } + if pre.ToMove != seat { + return nil, ErrNotYourTurn + } + + unlock := svc.locks.lock(gameID) + defer unlock() + g, err := svc.liveGame(ctx, pre) + if err != nil { + return nil, err + } + return g.Candidates(), nil +} + +// RobotTurns returns the robot driver's view of every active game seating one of +// robotIDs. It is the robot scheduler's periodic scan, mirroring the timeout +// sweeper's ActiveGames read; the driver derives each robot's deadline from the +// returned seed and turn cursor. +func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]RobotTurn, error) { + return svc.store.RobotTurns(ctx, robotIDs) +} + // GameState returns a seated player's view of the game: the shared summary plus // their private rack, the bag size and their remaining hint budget. func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 555a45e..48687de 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -346,6 +346,51 @@ func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) { return out, nil } +// RobotTurns returns one row per active game seating any of the given accounts, +// for the robot scheduler. It joins games to game_players on the robot's seat and +// carries the game's turn cursor and bag seed; the driver filters these against +// each robot's per-game deadline. An empty id list returns no rows. +func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, error) { + if len(ids) == 0 { + return nil, nil + } + exprs := make([]postgres.Expression, len(ids)) + for i, id := range ids { + exprs[i] = postgres.UUID(id) + } + stmt := postgres.SELECT( + table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt, + table.Games.MoveCount, table.Games.Seed, + table.GamePlayers.Seat, table.GamePlayers.AccountID, + ).FROM( + table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)), + ).WHERE( + table.Games.Status.EQ(postgres.String(StatusActive)). + AND(table.GamePlayers.AccountID.IN(exprs...)), + ).ORDER_BY(table.Games.TurnStartedAt.ASC()) + + var rows []struct { + model.Games + model.GamePlayers + } + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("game: list robot turns: %w", err) + } + out := make([]RobotTurn, 0, len(rows)) + for _, r := range rows { + out = append(out, RobotTurn{ + GameID: r.Games.GameID, + RobotID: r.GamePlayers.AccountID, + RobotSeat: int(r.GamePlayers.Seat), + ToMove: int(r.Games.ToMove), + TurnStartedAt: r.Games.TurnStartedAt, + MoveCount: int(r.Games.MoveCount), + Seed: r.Games.Seed, + }) + } + return out, nil +} + // GameSeed returns the bag seed a game was dealt from, used to replay it. The // seed is server-only state and never travels in the public Game view. func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 25b5ff7..a466229 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -161,6 +161,21 @@ type HistoryView struct { Moves []HistoryMove } +// RobotTurn is the robot driver's view of one active game seating a robot: the +// seat the robot holds, whose turn it currently is, when that turn started, the +// move index and the bag seed. Seed is backend-internal state (never exposed in +// the public Game view); the robot derives its deterministic per-game behaviour +// from it, so the scheduler stays stateless and restart-safe. +type RobotTurn struct { + GameID uuid.UUID + RobotID uuid.UUID + RobotSeat int + ToMove int + TurnStartedAt time.Time + MoveCount int + Seed int64 +} + // Complaint is a word-check complaint awaiting admin review (Stage 9). type Complaint struct { ID uuid.UUID diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index b6c9ed7..65ead11 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -32,7 +32,7 @@ func englishInvite() lobby.InvitationSettings { func TestMatchmakingPairsAndStartsGame(t *testing.T) { ctx := context.Background() - mm := lobby.NewMatchmaker(newGameService()) + mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second) a, b := provisionAccount(t), provisionAccount(t) r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish) diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go new file mode 100644 index 0000000..a212484 --- /dev/null +++ b/backend/internal/inttest/robot_test.go @@ -0,0 +1,267 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/lobby" + "scrabble/backend/internal/robot" +) + +// newRobotService builds a robot service over games (shared so its moves and the +// test's human moves use the same live-game cache and per-game locks), a fresh +// social service for nudges, and a no-op meter. +func newRobotService(t *testing.T, games *game.Service) *robot.Service { + t.Helper() + return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop()) +} + +// newMatchmaker builds a matchmaker starting real games and substituting from +// robots after wait. +func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker { + t.Helper() + return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop()) +} + +// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or +// idle) at a chosen instant, independent of wall time. +func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) { + t.Helper() + if _, err := testDB.ExecContext(context.Background(), + `UPDATE backend.games SET turn_started_at = $2 WHERE game_id = $1`, id, at); err != nil { + t.Fatalf("set turn_started_at: %v", err) + } +} + +// isRobotAccount reports whether the account carries a robot identity. +func isRobotAccount(t *testing.T, id uuid.UUID) bool { + t.Helper() + var n int + if err := testDB.QueryRowContext(context.Background(), + `SELECT count(*) FROM backend.identities WHERE account_id = $1 AND kind = 'robot'`, id).Scan(&n); err != nil { + t.Fatalf("count robot identity: %v", err) + } + return n > 0 +} + +// countNudges counts the nudges senderID has sent in a game. +func countNudges(t *testing.T, gameID, senderID uuid.UUID) int { + t.Helper() + var n int + if err := testDB.QueryRowContext(context.Background(), + `SELECT count(*) FROM backend.chat_messages WHERE game_id = $1 AND sender_id = $2 AND kind = 'nudge'`, + gameID, senderID).Scan(&n); err != nil { + t.Fatalf("count nudges: %v", err) + } + return n +} + +// daytime is a fixed instant whose hour is awake for every sleep drift (the +// always-awake band is [10,21) local), used to drive robot moves deterministically. +var daytime = time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC) + +// TestRobotPoolProvisionsRobotAccounts checks EnsurePool creates durable, +// chat/friend-blocked robot accounts (exercising the kind='robot' migration) and +// is idempotent. +func TestRobotPoolProvisionsRobotAccounts(t *testing.T) { + ctx := context.Background() + r := newRobotService(t, newGameService()) + if err := r.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + if err := r.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool (idempotent): %v", err) + } + id, err := r.Pick() + if err != nil { + t.Fatalf("pick: %v", err) + } + if !isRobotAccount(t, id) { + t.Errorf("picked account %s is not a robot identity", id) + } + acc, err := account.NewStore(testDB).GetByID(ctx, id) + if err != nil { + t.Fatalf("get robot account: %v", err) + } + if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests { + t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests) + } +} + +// TestRobotPlaysAutoMatchToEnd drives a robot through a full two-player game (the +// human plays greedily) and checks it finishes with a robot statistics row. The +// robot is forced due each turn by resetting the turn clock and driving at a fixed +// daytime instant, so the game does not depend on wall time. +func TestRobotPlaysAutoMatchToEnd(t *testing.T) { + ctx := context.Background() + svc := newGameService() + robots := newRobotService(t, svc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + robotID, err := robots.Pick() + if err != nil { + t.Fatalf("pick: %v", err) + } + human := provisionAccount(t) + seed := openingSeed(t) + + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID}, + TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + robotSeat := 1 // seats = [human, robot] + + finished := false + for i := 0; i < 400 && !finished; i++ { + _, toMove, status, err := svc.Participants(ctx, g.ID) + if err != nil { + t.Fatalf("participants: %v", err) + } + if status != game.StatusActive { + finished = true + break + } + if toMove == robotSeat { + setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour)) // well past any sampled delay + robots.Drive(ctx, daytime) + continue + } + playHuman(t, ctx, svc, g.ID, human) + } + if !finished { + t.Fatal("robot game did not finish within the move budget") + } + + if _, _, _, mg, _, ok := readStats(t, robotID); !ok || mg < 0 { + t.Errorf("robot must have a statistics row after a finished game (found=%v, maxGame=%d)", ok, mg) + } +} + +// TestMatchmakerSubstitutesRobotEndToEnd checks a waiting human is paired with a +// real robot account after the wait window, discoverable through Poll. +func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { + ctx := context.Background() + robots := newRobotService(t, newGameService()) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + mm := newMatchmaker(t, robots, 10*time.Second) + human := provisionAccount(t) + + before := time.Now() + r, err := mm.Enqueue(ctx, human, engine.VariantEnglish) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + if r.Matched { + t.Fatal("first enqueue must wait") + } + + mm.Reap(ctx, before.Add(11*time.Second)) + got, err := mm.Poll(ctx, human) + if err != nil { + t.Fatalf("poll: %v", err) + } + if !got.Matched { + t.Fatal("expected a substituted game after the wait window") + } + + seats, _, status, err := newGameService().Participants(ctx, got.Game.ID) + if err != nil { + t.Fatalf("participants: %v", err) + } + if status != game.StatusActive || len(seats) != 2 { + t.Fatalf("substituted game: status %q seats %v", status, seats) + } + var human0, robot0 bool + for _, s := range seats { + switch { + case s == human: + human0 = true + case isRobotAccount(t, s): + robot0 = true + } + } + if !human0 || !robot0 { + t.Errorf("substituted seats must be the human and a robot, got %v", seats) + } +} + +// TestRobotProactiveNudge checks the robot nudges the human after the idle +// threshold on the human's turn. +func TestRobotProactiveNudge(t *testing.T) { + ctx := context.Background() + svc := newGameService() + robots := newRobotService(t, svc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + robotID, err := robots.Pick() + if err != nil { + t.Fatalf("pick: %v", err) + } + human := provisionAccount(t) + seed := openingSeed(t) + + // Seat the human first so it is the human's turn and the robot is the awaiter. + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID}, + TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Midnight start, driven 13 hours later (>12h idle) at a daytime hour awake for + // every drift. + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + setTurnStarted(t, g.ID, start) + robots.Drive(ctx, start.Add(13*time.Hour)) + + if n := countNudges(t, g.ID, robotID); n != 1 { + t.Errorf("robot nudges = %d, want 1 after 13h idle on the human's turn", n) + } +} + +// playHuman makes a greedy human move: the top candidate, else an exchange, else a +// pass. +func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, human uuid.UUID) { + t.Helper() + cands, err := svc.Candidates(ctx, gameID, human) + if err != nil { + t.Fatalf("human candidates: %v", err) + } + if len(cands) > 0 { + if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Dir, cands[0].Tiles); err != nil { + t.Fatalf("human play: %v", err) + } + return + } + st, err := svc.GameState(ctx, gameID, human) + if err != nil { + t.Fatalf("human state: %v", err) + } + if len(st.Rack) > 0 && st.BagLen >= len(st.Rack) { + if _, err := svc.Exchange(ctx, gameID, human, st.Rack); err != nil { + t.Fatalf("human exchange: %v", err) + } + return + } + if _, err := svc.Pass(ctx, gameID, human); err != nil { + t.Fatalf("human pass: %v", err) + } +} diff --git a/backend/internal/lobby/config.go b/backend/internal/lobby/config.go new file mode 100644 index 0000000..a016b43 --- /dev/null +++ b/backend/internal/lobby/config.go @@ -0,0 +1,36 @@ +package lobby + +import ( + "fmt" + "time" +) + +// Config configures the matchmaking pool's robot substitution. +type Config struct { + // RobotWait is how long an auto-match player waits for a human before a robot + // is substituted. Sourced from BACKEND_LOBBY_ROBOT_WAIT. + RobotWait time.Duration + // ReaperInterval is how often the substitution reaper scans for over-waited + // players. Sourced from BACKEND_LOBBY_REAPER_INTERVAL. + ReaperInterval time.Duration +} + +// DefaultConfig returns the matchmaking defaults: a 10-second wait +// (docs/ARCHITECTURE.md §7) scanned every second. +func DefaultConfig() Config { + return Config{ + RobotWait: 10 * time.Second, + ReaperInterval: time.Second, + } +} + +// Validate reports whether the configuration is usable. +func (c Config) Validate() error { + if c.RobotWait <= 0 { + return fmt.Errorf("lobby: robot wait must be positive, got %s", c.RobotWait) + } + if c.ReaperInterval <= 0 { + return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval) + } + return nil +} diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 5258608..80395a8 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -21,6 +21,13 @@ type GameCreator interface { Create(ctx context.Context, params game.CreateParams) (game.Game, error) } +// RobotProvider supplies a robot account to substitute for a missing human in +// auto-match. robot.Service satisfies it; it returns an error when no robot is +// available so the matchmaker can defer substitution. +type RobotProvider interface { + Pick() (uuid.UUID, error) +} + // Blocker reports whether two accounts have a block between them (either // direction). social.Service satisfies it; the lobby uses it to refuse // invitations between blocked accounts. diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 44dcb4b..b1043c6 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -7,34 +7,56 @@ import ( "time" "github.com/google/uuid" + "go.uber.org/zap" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) // Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs -// the next two humans into a two-player game. It holds no database state and is -// lost on restart (players simply re-queue). It is safe for concurrent use. +// the next two humans into a two-player game, or — when no human arrives within +// the wait window — substitutes a robot. It holds no database state and is lost on +// restart (players simply re-queue). It is safe for concurrent use. // // Auto-match is anonymous, so the pool does not consult per-user blocks (those -// govern friends, chat and invitations between known players). Robot substitution -// for a missing human is added in a later stage. +// govern friends, chat and invitations between known players). +// +// A player who is queued learns of a match — by a waiting human being paired, or +// by robot substitution — through Poll, the interim delivery seam: production +// delivery is a notification (session/in-app push and the platform side-service, +// docs/ARCHITECTURE.md §10), wired with the gateway in a later stage. type Matchmaker struct { - games GameCreator + games GameCreator + robots RobotProvider + waitDelay time.Duration + clock func() time.Time + log *zap.Logger - mu sync.Mutex - queues map[engine.Variant][]uuid.UUID - queued map[uuid.UUID]engine.Variant - rng *rand.Rand + mu sync.Mutex + queues map[engine.Variant][]uuid.UUID + queued map[uuid.UUID]engine.Variant + waitingSince map[uuid.UUID]time.Time + results map[uuid.UUID]game.Game + rng *rand.Rand } -// NewMatchmaker constructs a Matchmaker that starts matched games through games. -func NewMatchmaker(games GameCreator) *Matchmaker { +// NewMatchmaker constructs a Matchmaker that starts matched games through games +// and substitutes a robot from robots when a player waits longer than waitDelay. +func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Duration, log *zap.Logger) *Matchmaker { + if log == nil { + log = zap.NewNop() + } return &Matchmaker{ - games: games, - queues: make(map[engine.Variant][]uuid.UUID), - queued: make(map[uuid.UUID]engine.Variant), - rng: rand.New(rand.NewSource(time.Now().UnixNano())), + games: games, + robots: robots, + waitDelay: waitDelay, + clock: func() time.Time { return time.Now().UTC() }, + log: log, + queues: make(map[engine.Variant][]uuid.UUID), + queued: make(map[uuid.UUID]engine.Variant), + waitingSince: make(map[uuid.UUID]time.Time), + results: make(map[uuid.UUID]game.Game), + rng: rand.New(rand.NewSource(time.Now().UnixNano())), } } @@ -47,7 +69,8 @@ type EnqueueResult struct { // Enqueue joins accountID to the variant pool. If an opponent already waits, the // two are paired (seat order randomised for first-move fairness) and a game starts -// immediately; otherwise the account waits. An account already waiting in any pool +// immediately; otherwise the account waits, and a later pairing or robot +// substitution is delivered through Poll. An account already waiting in any pool // gets ErrAlreadyQueued. func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) { m.mu.Lock() @@ -59,31 +82,42 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e if len(q) == 0 { m.queues[variant] = append(q, accountID) m.queued[accountID] = variant + m.waitingSince[accountID] = m.clock() m.mu.Unlock() return EnqueueResult{}, nil } opponent := q[0] - m.queues[variant] = q[1:] - delete(m.queued, opponent) + m.removeLocked(opponent, variant) seats := []uuid.UUID{opponent, accountID} if m.rng.Intn(2) == 0 { seats[0], seats[1] = seats[1], seats[0] } m.mu.Unlock() - g, err := m.games.Create(ctx, game.CreateParams{ - Variant: variant, - Seats: seats, - TurnTimeout: game.DefaultTurnTimeout, - HintsAllowed: autoMatchHintsAllowed, - HintsPerPlayer: autoMatchHintsPerPlayer, - }) + g, err := m.games.Create(ctx, autoMatchParams(variant, seats)) if err != nil { return EnqueueResult{}, err } + // The opponent was waiting; record the game so they can collect it via Poll. + m.mu.Lock() + m.results[opponent] = g + m.mu.Unlock() return EnqueueResult{Matched: true, Game: g}, nil } +// Poll reports whether accountID has been matched since it queued, returning the +// started game once (the result is drained on read). It reports Matched=false +// while the account is still waiting or has no pending result. +func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + if g, ok := m.results[accountID]; ok { + delete(m.results, accountID) + return EnqueueResult{Matched: true, Game: g}, nil + } + return EnqueueResult{}, nil +} + // Cancel removes accountID from whatever pool it waits in, reporting whether it // was queued. func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool { @@ -93,14 +127,7 @@ func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool { if !ok { return false } - delete(m.queued, accountID) - q := m.queues[variant] - for i, id := range q { - if id == accountID { - m.queues[variant] = append(q[:i], q[i+1:]...) - break - } - } + m.removeLocked(accountID, variant) return true } @@ -110,3 +137,91 @@ func (m *Matchmaker) QueueLen(variant engine.Variant) int { defer m.mu.Unlock() return len(m.queues[variant]) } + +// RunReaper substitutes a robot for any player that has waited past waitDelay, +// scanning every interval until ctx is cancelled. It is started once from main. +func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.Reap(ctx, m.clock()) + } + } +} + +// Reap pairs every player that has waited past waitDelay with a freshly picked +// robot and starts the game, recording it for the player's Poll. RunReaper calls +// it on a timer; it takes now explicitly so tests and ops can drive a single pass +// at a chosen instant. A waiter is only dequeued once a robot is secured, so a +// momentarily empty pool just defers substitution to a later tick. +func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { + type sub struct { + human uuid.UUID + variant engine.Variant + seats []uuid.UUID + } + m.mu.Lock() + var due []uuid.UUID + for acc, since := range m.waitingSince { + if now.Sub(since) >= m.waitDelay { + due = append(due, acc) + } + } + var subs []sub + for _, acc := range due { + robotID, err := m.robots.Pick() + if err != nil { + m.log.Warn("robot substitution deferred", zap.Error(err)) + continue + } + variant := m.queued[acc] + m.removeLocked(acc, variant) + seats := []uuid.UUID{acc, robotID} + if m.rng.Intn(2) == 0 { + seats[0], seats[1] = seats[1], seats[0] + } + subs = append(subs, sub{human: acc, variant: variant, seats: seats}) + } + m.mu.Unlock() + + for _, s := range subs { + g, err := m.games.Create(ctx, autoMatchParams(s.variant, s.seats)) + if err != nil { + m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err)) + continue + } + m.mu.Lock() + m.results[s.human] = g + m.mu.Unlock() + } +} + +// removeLocked drops accountID from the queue, the queued index and the waiting +// clock. The caller holds m.mu. +func (m *Matchmaker) removeLocked(accountID uuid.UUID, variant engine.Variant) { + delete(m.queued, accountID) + delete(m.waitingSince, accountID) + q := m.queues[variant] + for i, id := range q { + if id == accountID { + m.queues[variant] = append(q[:i], q[i+1:]...) + break + } + } +} + +// autoMatchParams builds the create parameters for a two-player auto-match with +// the casual defaults. +func autoMatchParams(variant engine.Variant, seats []uuid.UUID) game.CreateParams { + return game.CreateParams{ + Variant: variant, + Seats: seats, + TurnTimeout: game.DefaultTurnTimeout, + HintsAllowed: autoMatchHintsAllowed, + HintsPerPlayer: autoMatchHintsPerPlayer, + } +} diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 67a0627..6d59772 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -4,8 +4,10 @@ import ( "context" "errors" "testing" + "time" "github.com/google/uuid" + "go.uber.org/zap" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" @@ -25,6 +27,28 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil } +// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model +// an empty pool. +type fakeRobots struct { + id uuid.UUID + err error +} + +func (f *fakeRobots) Pick() (uuid.UUID, error) { + if f.err != nil { + return uuid.Nil, f.err + } + return f.id, nil +} + +// testWaitDelay is long enough that the reaper never fires in the pairing tests +// (which do not run it); the substitution tests drive reap directly. +const testWaitDelay = 10 * time.Second + +func newTestMatchmaker(creator GameCreator, robotID uuid.UUID) *Matchmaker { + return NewMatchmaker(creator, &fakeRobots{id: robotID}, testWaitDelay, zap.NewNop()) +} + func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool { for _, w := range want { found := false @@ -43,7 +67,7 @@ func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool { func TestMatchmakerPairsTwoHumans(t *testing.T) { creator := &fakeCreator{} - mm := NewMatchmaker(creator) + mm := newTestMatchmaker(creator, uuid.New()) ctx := context.Background() a, b := uuid.New(), uuid.New() @@ -78,10 +102,22 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) { if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer { t.Errorf("auto-match defaults not applied: %+v", p) } + + // The waiting opponent learns of the match through Poll, exactly once. + got, err := mm.Poll(ctx, a) + if err != nil { + t.Fatalf("poll a: %v", err) + } + if !got.Matched || got.Game.ID != r2.Game.ID { + t.Errorf("poll a = %+v, want the matched game %s", got, r2.Game.ID) + } + if again, _ := mm.Poll(ctx, a); again.Matched { + t.Error("poll result must drain after the first read") + } } func TestMatchmakerAlreadyQueued(t *testing.T) { - mm := NewMatchmaker(&fakeCreator{}) + mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) ctx := context.Background() a := uuid.New() if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { @@ -93,7 +129,7 @@ func TestMatchmakerAlreadyQueued(t *testing.T) { } func TestMatchmakerCancel(t *testing.T) { - mm := NewMatchmaker(&fakeCreator{}) + mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) ctx := context.Background() a := uuid.New() if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { @@ -112,7 +148,7 @@ func TestMatchmakerCancel(t *testing.T) { func TestMatchmakerVariantsAreSeparate(t *testing.T) { creator := &fakeCreator{} - mm := NewMatchmaker(creator) + mm := newTestMatchmaker(creator, uuid.New()) ctx := context.Background() if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil { t.Fatalf("enqueue en: %v", err) @@ -130,7 +166,7 @@ func TestMatchmakerVariantsAreSeparate(t *testing.T) { func TestMatchmakerFIFO(t *testing.T) { creator := &fakeCreator{} - mm := NewMatchmaker(creator) + mm := newTestMatchmaker(creator, uuid.New()) ctx := context.Background() a, b, c := uuid.New(), uuid.New(), uuid.New() for _, id := range []uuid.UUID{a, b, c} { @@ -149,3 +185,75 @@ func TestMatchmakerFIFO(t *testing.T) { t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish)) } } + +func TestMatchmakerReaperSubstitutesRobot(t *testing.T) { + creator := &fakeCreator{} + robotID := uuid.New() + mm := newTestMatchmaker(creator, robotID) + base := time.Now() + mm.clock = func() time.Time { return base } + ctx := context.Background() + a := uuid.New() + + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + t.Fatalf("enqueue: %v", err) + } + + mm.Reap(ctx, base.Add(5*time.Second)) // before the wait window + if len(creator.created) != 0 || mm.QueueLen(engine.VariantEnglish) != 1 { + t.Fatalf("must not substitute before the wait: created=%d queued=%d", len(creator.created), mm.QueueLen(engine.VariantEnglish)) + } + + mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // past the wait window + if len(creator.created) != 1 { + t.Fatalf("created %d games, want 1 after substitution", len(creator.created)) + } + if !seatsContain(creator.created[0].Seats, a, robotID) { + t.Errorf("substituted game seats = %v, want human %s and robot %s", creator.created[0].Seats, a, robotID) + } + if mm.QueueLen(engine.VariantEnglish) != 0 { + t.Errorf("waiter should be dequeued after substitution") + } + got, err := mm.Poll(ctx, a) + if err != nil || !got.Matched { + t.Errorf("poll after substitution = %+v err=%v, want matched", got, err) + } +} + +func TestMatchmakerReaperSkipsCancelled(t *testing.T) { + creator := &fakeCreator{} + mm := newTestMatchmaker(creator, uuid.New()) + base := time.Now() + mm.clock = func() time.Time { return base } + ctx := context.Background() + a := uuid.New() + + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + t.Fatalf("enqueue: %v", err) + } + mm.Cancel(ctx, a) + mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) + if len(creator.created) != 0 { + t.Errorf("a cancelled waiter must not be substituted; created %d", len(creator.created)) + } +} + +func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) { + creator := &fakeCreator{} + mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop()) + base := time.Now() + mm.clock = func() time.Time { return base } + ctx := context.Background() + a := uuid.New() + + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + t.Fatalf("enqueue: %v", err) + } + mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) + if len(creator.created) != 0 { + t.Errorf("no robot available: must not create a game; created %d", len(creator.created)) + } + if mm.QueueLen(engine.VariantEnglish) != 1 { + t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish)) + } +} diff --git a/backend/internal/postgres/migrations/00004_robot.sql b/backend/internal/postgres/migrations/00004_robot.sql new file mode 100644 index 0000000..8d33521 --- /dev/null +++ b/backend/internal/postgres/migrations/00004_robot.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Stage 5 robot opponent: admit a 'robot' identity kind so the robot pool can be +-- provisioned as durable accounts (one identity row per named robot). This widens +-- the identities kind CHECK only; no table or column changes, so the generated +-- jet code is unaffected. +SET search_path = backend, pg_catalog; + +ALTER TABLE identities DROP CONSTRAINT identities_kind_chk; +ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot')); + +-- +goose Down +SET search_path = backend, pg_catalog; + +ALTER TABLE identities DROP CONSTRAINT identities_kind_chk; +ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email')); diff --git a/backend/internal/robot/driver.go b/backend/internal/robot/driver.go new file mode 100644 index 0000000..083da77 --- /dev/null +++ b/backend/internal/robot/driver.go @@ -0,0 +1,201 @@ +package robot + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.uber.org/zap" + + "scrabble/backend/internal/game" +) + +// Run drives the robot until ctx is cancelled, scanning for due turns every +// interval. It mirrors the game turn-timeout sweeper and is started once from +// main; it simply calls Drive on each tick. +func (s *Service) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.Drive(ctx, s.clock()) + } + } +} + +// Drive performs one scan: it handles every active game seating a pool robot as +// of now. Run calls it on a timer; it takes now explicitly so tests and ops can +// drive a single pass at a chosen instant (mirroring game.Service.SweepTimeouts). +func (s *Service) Drive(ctx context.Context, now time.Time) { + turns, err := s.games.RobotTurns(ctx, s.poolIDs()) + if err != nil { + s.log.Warn("robot scan failed", zap.Error(err)) + return + } + for _, rt := range turns { + if err := s.handle(ctx, rt, now); err != nil { + s.log.Warn("robot turn failed", zap.String("game", rt.GameID.String()), zap.Error(err)) + } + } +} + +// handle resolves the opponent (a two-player auto-match), honours the robot's +// sleep window, then either makes a move on the robot's turn or considers a +// proactive nudge on the human's turn. The seat→account mapping is fixed for the +// game's life, so reading it at a different instant than the scan is consistent; +// the turn cursor comes from the scan snapshot (rt), and the submit/nudge calls +// re-validate against the live state and skip benignly if it has moved on. +func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time) error { + seats, _, status, err := s.games.Participants(ctx, rt.GameID) + if err != nil { + return err + } + if status != game.StatusActive { + return nil + } + oppID, ok := opponentOf(seats, rt.RobotSeat) + if !ok { + return nil + } + opp, err := s.accounts.GetByID(ctx, oppID) + if err != nil { + return err + } + if asleep(opp.TimeZone, sleepDrift(rt.Seed), now) { + return nil + } + + if rt.ToMove == rt.RobotSeat { + return s.maybeMove(ctx, rt, oppID, now) + } + return s.maybeNudge(ctx, rt, now) +} + +// maybeMove acts when the robot's think time has elapsed. A daytime nudge from +// the opponent during the current turn pulls the move in to the short reply +// window; otherwise the robot waits out its sampled delay. +func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.UUID, now time.Time) error { + if now.Before(rt.TurnStartedAt.Add(moveDelay(rt.Seed, rt.MoveCount))) { + last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, oppID) + if err != nil { + return err + } + if !ok || !last.After(rt.TurnStartedAt) { + return nil // not yet due and no nudge this turn + } + if now.Before(last.Add(nudgeReplyDelay(rt.Seed, rt.MoveCount))) { + return nil // within the reply window + } + } + return s.act(ctx, rt, now) +} + +// maybeNudge sends a proactive nudge once the human has been idle past the +// threshold. The social service enforces the once-per-hour-per-game limit and +// rejects a nudge on the robot's own turn, so any such rejection is benign. +func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error { + if now.Sub(rt.TurnStartedAt) < proactiveNudgeIdle { + return nil + } + if _, err := s.social.Nudge(ctx, rt.GameID, rt.RobotID); err != nil { + s.log.Debug("robot nudge skipped", zap.String("game", rt.GameID.String()), zap.Error(err)) + } + return nil +} + +// act reads the live turn, chooses a move by margin and submits it. State that +// has moved on since the scan (a finished game, a turn that is no longer the +// robot's) surfaces as a benign error and is skipped. +func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) error { + st, err := s.games.GameState(ctx, rt.GameID, rt.RobotID) + if err != nil { + return skipBenign(err) + } + cands, err := s.games.Candidates(ctx, rt.GameID, rt.RobotID) + if err != nil { + return skipBenign(err) + } + + myScore := st.Game.Seats[st.Seat].Score + oppScore := bestOpponentScore(st.Game.Seats, st.Seat) + d := selectMove(cands, myScore, oppScore, playToWin(rt.Seed), defaultBand, st.Rack, st.BagLen) + + var res game.MoveResult + switch d.kind { + case decidePlay: + res, err = s.games.SubmitPlay(ctx, rt.GameID, rt.RobotID, d.move.Dir, d.move.Tiles) + case decideExchange: + res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange) + default: + res, err = s.games.Pass(ctx, rt.GameID, rt.RobotID) + } + if err != nil { + return skipBenign(err) + } + s.recordFinish(ctx, rt.GameID, rt.RobotID, res.Game) + return nil +} + +// recordFinish counts and logs a robot game that the robot's move has just +// finished. account_stats remains the authoritative, complete balance metric +// (it also captures games the human finishes); this live counter only sees +// robot-finished games. +func (s *Service) recordFinish(ctx context.Context, gameID, robotID uuid.UUID, g game.Game) { + if g.Status != game.StatusFinished { + return + } + result := "draw" + for _, seat := range g.Seats { + if seat.IsWinner { + if seat.AccountID == robotID { + result = "win" + } else { + result = "loss" + } + break + } + } + s.finished.Add(ctx, 1, metric.WithAttributes(attribute.String("result", result))) + s.log.Info("robot game finished", + zap.String("game", gameID.String()), + zap.String("result", result), + zap.String("reason", g.EndReason)) +} + +// opponentOf returns the account at the single non-robot seat of a two-player +// auto-match, and false when none differs from the robot seat. +func opponentOf(seats []uuid.UUID, robotSeat int) (uuid.UUID, bool) { + for seat, id := range seats { + if seat != robotSeat { + return id, true + } + } + return uuid.Nil, false +} + +// bestOpponentScore is the highest score among the seats other than the robot's. +func bestOpponentScore(seats []game.Seat, robotSeat int) int { + best := 0 + for _, s := range seats { + if s.Seat != robotSeat && s.Score > best { + best = s.Score + } + } + return best +} + +// skipBenign swallows the errors that mean the game moved on since the scan (it +// finished, or it is no longer the robot's turn), so the driver simply tries +// again next tick. +func skipBenign(err error) error { + if errors.Is(err, game.ErrFinished) || errors.Is(err, game.ErrNotYourTurn) || errors.Is(err, game.ErrNotAPlayer) { + return nil + } + return err +} diff --git a/backend/internal/robot/robot.go b/backend/internal/robot/robot.go new file mode 100644 index 0000000..f4ef5cc --- /dev/null +++ b/backend/internal/robot/robot.go @@ -0,0 +1,177 @@ +// Package robot is the human-like computer opponent. It substitutes for a missing +// human in two-player auto-match: a pool of durable accounts (one robot identity +// each) is provisioned at startup, and a background driver makes their moves with +// human-like timing, a night sleep window and nudge behaviour +// (docs/ARCHITECTURE.md §7). +// +// The robot consumes the public game API as an ordinary seated player and works +// on decoded values only, so it never imports the solver (only internal/engine +// does). All of a robot's per-game and per-turn choices are derived +// deterministically from the game's bag seed (see strategy.go), so the driver +// holds no per-game state and is restart-safe. +package robot + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "sync" + "time" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/social" +) + +// ErrNoRobotAvailable is returned by Pick when the pool is empty (EnsurePool has +// not run or failed). +var ErrNoRobotAvailable = errors.New("robot: no robot available in the pool") + +// GameDriver is the slice of the game domain the robot needs: scanning its active +// games, reading a turn's candidates and state, and making moves as a seated +// player. game.Service satisfies it. +type GameDriver interface { + RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]game.RobotTurn, error) + Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) + Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) + GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error) + SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (game.MoveResult, error) + Pass(ctx context.Context, gameID, accountID uuid.UUID) (game.MoveResult, error) + Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error) +} + +// Nudger is the slice of the social domain the robot needs: sending a proactive +// nudge and reading the opponent's last nudge to answer it. social.Service +// satisfies it. +type Nudger interface { + Nudge(ctx context.Context, gameID, senderID uuid.UUID) (social.Message, error) + LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) +} + +// robotNames is the curated, human-like name pool. Each name backs one durable +// robot account, addressed by a stable robot identity (its lower-cased name). +var robotNames = []string{ + "Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan", + "Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage", +} + +// Config configures the robot subsystem. +type Config struct { + // DriveInterval is how often the driver scans for robot turns. Sourced from + // BACKEND_ROBOT_DRIVE_INTERVAL. + DriveInterval time.Duration +} + +// DefaultConfig returns the robot configuration defaults. +func DefaultConfig() Config { + return Config{DriveInterval: 30 * time.Second} +} + +// Validate reports whether the configuration is usable. +func (c Config) Validate() error { + if c.DriveInterval <= 0 { + return fmt.Errorf("robot: drive interval must be positive, got %s", c.DriveInterval) + } + return nil +} + +// Service owns the robot pool and the move driver. It is safe for concurrent use. +type Service struct { + games GameDriver + accounts *account.Store + social Nudger + finished metric.Int64Counter + clock func() time.Time + log *zap.Logger + + mu sync.RWMutex + pool []uuid.UUID +} + +// NewService constructs a robot Service. games and social are the domain seams it +// drives; accounts provisions the pool and resolves opponent timezones; meter +// records the balance counter; log carries driver diagnostics. +func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter metric.Meter, log *zap.Logger) *Service { + if log == nil { + log = zap.NewNop() + } + counter, err := meter.Int64Counter( + "robot_games_finished_total", + metric.WithDescription("Robot games finished, labelled by result from the robot's view (win/loss/draw)."), + ) + if err != nil { + log.Warn("robot: create finished counter", zap.Error(err)) + counter, _ = noop.NewMeterProvider().Meter("robot").Int64Counter("robot_games_finished_total") + } + return &Service{ + games: games, + accounts: accounts, + social: soc, + finished: counter, + clock: func() time.Time { return time.Now().UTC() }, + log: log, + } +} + +// EnsurePool idempotently provisions the named robot accounts and records their +// ids as the pool. Each robot is a durable account bound to a robot identity, +// with chat and friend requests blocked so it never engages socially +// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary +// registry: a failure fails the boot. +func (s *Service) EnsurePool(ctx context.Context) error { + ids := make([]uuid.UUID, 0, len(robotNames)) + for _, name := range robotNames { + acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name)) + if err != nil { + return fmt.Errorf("robot: provision %q: %w", name, err) + } + if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests { + if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{ + DisplayName: name, + PreferredLanguage: acc.PreferredLanguage, + TimeZone: acc.TimeZone, + AwayStart: acc.AwayStart, + AwayEnd: acc.AwayEnd, + BlockChat: true, + BlockFriendRequests: true, + }); err != nil { + return fmt.Errorf("robot: profile %q: %w", name, err) + } + } + ids = append(ids, acc.ID) + } + s.mu.Lock() + s.pool = ids + s.mu.Unlock() + return nil +} + +// Pick returns a random robot account from the pool, for the matchmaker to +// substitute into an auto-match. It satisfies lobby.RobotProvider. +func (s *Service) Pick() (uuid.UUID, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if len(s.pool) == 0 { + return uuid.Nil, ErrNoRobotAvailable + } + return s.pool[rand.IntN(len(s.pool))], nil +} + +// poolIDs returns a snapshot of the pool for the driver scan. +func (s *Service) poolIDs() []uuid.UUID { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]uuid.UUID(nil), s.pool...) +} + +// externalID is the stable robot identity for a pool name. +func externalID(name string) string { + return "robot-" + name +} diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go new file mode 100644 index 0000000..b828ea9 --- /dev/null +++ b/backend/internal/robot/strategy.go @@ -0,0 +1,201 @@ +package robot + +import ( + "encoding/binary" + "hash/fnv" + "math" + "time" + + "scrabble/backend/internal/engine" +) + +// The robot's per-game and per-turn choices are derived deterministically from +// the game's bag seed, so the scheduler keeps no extra state and recomputes the +// same behaviour on every tick and after a restart (mirroring how the engine +// replays a game from the same seed). The mixing must be stable across process +// restarts, so it uses FNV-1a rather than hash/maphash (whose seed is process +// random). + +const ( + // playToWinPercent is the probability, in percent, that the robot decides at + // game start to play to win; the rest of the time it plays to lose, so the + // human wins about 60% of games (docs/ARCHITECTURE.md §7). + playToWinPercent = 40 + + // delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the + // right-skewed distribution (short delays frequent). With skew 3.5 the median + // is about 10 minutes and the mean about 20, with a tail out to the maximum. + delayMinMinutes = 2.0 + delayMaxMinutes = 90.0 + delaySkew = 3.5 + + // nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot + // answers a daytime nudge on its turn. + nudgeReplyMinMinutes = 2.0 + nudgeReplyMaxMinutes = 10.0 + + // sleepStartHour and sleepEndHour bound the robot's nightly sleep in its + // (opponent-anchored, drifted) local time: it makes no move and sends no nudge + // while the local hour is in [sleepStartHour, sleepEndHour). + sleepStartHour = 0 + sleepEndHour = 7 + + // sleepDriftHours is the half-width of the random drift applied to the robot's + // sleep window relative to the opponent's timezone, in hours. + sleepDriftHours = 3 + + // proactiveNudgeIdle is how long the robot waits on the human's turn before it + // proactively nudges (subject to the social once-per-hour-per-game limit). + proactiveNudgeIdle = 12 * time.Hour +) + +// defaultBand is the target resulting score margin after the robot's move: when +// playing to win it aims to lead by 1..30 points, when playing to lose it aims to +// trail by 1..30 (the band is negated). It picks the candidate closest to the +// band rather than the maximum (docs/ARCHITECTURE.md §7). +var defaultBand = marginBand{lo: 1, hi: 30} + +// marginBand is an inclusive target range for the resulting score margin +// (own score after the move minus the opponent's). +type marginBand struct{ lo, hi int } + +// decisionKind enumerates the move the robot makes on its turn. +type decisionKind int + +const ( + decidePlay decisionKind = iota + decideExchange + decidePass +) + +// decision is the robot's chosen action for a turn: a play (Move), an exchange of +// the listed tiles, or a pass. +type decision struct { + kind decisionKind + move engine.MoveRecord + exchange []string +} + +// mix folds the game seed and a salt (a label plus optional integers such as the +// move index) into a stable 64-bit value. It is deterministic across process +// restarts. +func mix(seed int64, salt string, nums ...int) uint64 { + h := fnv.New64a() + var b [8]byte + binary.LittleEndian.PutUint64(b[:], uint64(seed)) + _, _ = h.Write(b[:]) + _, _ = h.Write([]byte(salt)) + for _, n := range nums { + binary.LittleEndian.PutUint64(b[:], uint64(int64(n))) + _, _ = h.Write(b[:]) + } + return h.Sum64() +} + +// unitFloat maps a mixed value to a float in [0, 1). +func unitFloat(v uint64) float64 { + return float64(v) / (float64(math.MaxUint64) + 1) +} + +// playToWin reports the robot's once-per-game decision to play to win, derived +// from the seed so it is fixed for the whole game. +func playToWin(seed int64) bool { + return mix(seed, "win")%100 < playToWinPercent +} + +// moveDelay is the robot's think time for the move at moveCount, sampled from the +// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes). +func moveDelay(seed int64, moveCount int) time.Duration { + u := unitFloat(mix(seed, "delay", moveCount)) + mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew) + return time.Duration(mins * float64(time.Minute)) +} + +// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at +// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes). +func nudgeReplyDelay(seed int64, moveCount int) time.Duration { + u := unitFloat(mix(seed, "nudge", moveCount)) + mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u + return time.Duration(mins * float64(time.Minute)) +} + +// sleepDrift is the per-game shift of the robot's sleep window relative to the +// opponent's timezone, in [-sleepDriftHours, +sleepDriftHours] hours. +func sleepDrift(seed int64) time.Duration { + span := 2*sleepDriftHours + 1 + h := int(mix(seed, "tz")%uint64(span)) - sleepDriftHours + return time.Duration(h) * time.Hour +} + +// asleep reports whether the robot is in its nightly sleep window at now. The +// window is [sleepStartHour, sleepEndHour) in the opponent's timezone shifted by +// drift; an unknown or empty timezone falls back to UTC. +func asleep(opponentTZ string, drift time.Duration, now time.Time) bool { + local := now.In(loadLocation(opponentTZ)).Add(drift) + h := local.Hour() + return h >= sleepStartHour && h < sleepEndHour +} + +// loadLocation resolves an IANA timezone name, falling back to UTC when it is +// empty or unknown (so a bad opponent profile never breaks the driver). +func loadLocation(name string) *time.Location { + if name == "" { + return time.UTC + } + loc, err := time.LoadLocation(name) + if err != nil { + return time.UTC + } + return loc +} + +// selectMove chooses the robot's action given the ranked candidate plays, the +// current scores, the play-to-win decision and the target band. With at least one +// legal play it picks the candidate whose resulting margin (myScore + score - +// oppScore) is closest to the band, breaking ties toward the conservative edge +// (the smallest lead when winning, the smallest deficit when losing). With no +// legal play it exchanges the whole rack when the bag can refill it, else passes. +func selectMove(cands []engine.MoveRecord, myScore, oppScore int, win bool, band marginBand, rack []string, bagLen int) decision { + if len(cands) == 0 { + if len(rack) > 0 && bagLen >= len(rack) { + return decision{kind: decideExchange, exchange: append([]string(nil), rack...)} + } + return decision{kind: decidePass} + } + + lo, hi := band.lo, band.hi + if !win { + lo, hi = -band.hi, -band.lo + } + + margin := func(c engine.MoveRecord) int { return myScore + c.Score - oppScore } + best := 0 + bestDist := math.MaxInt + for i, c := range cands { + m := margin(c) + dist := distanceToBand(m, lo, hi) + switch { + case dist < bestDist: + best, bestDist = i, dist + case dist == bestDist: + // Conservative tie-break inside the band: keep the lead (win) or the + // deficit (lose) small. + if win && m < margin(cands[best]) || !win && m > margin(cands[best]) { + best = i + } + } + } + return decision{kind: decidePlay, move: cands[best]} +} + +// distanceToBand is how far m lies outside [lo, hi], or 0 when inside. +func distanceToBand(m, lo, hi int) int { + switch { + case m < lo: + return lo - m + case m > hi: + return m - hi + default: + return 0 + } +} diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go new file mode 100644 index 0000000..3161e4b --- /dev/null +++ b/backend/internal/robot/strategy_test.go @@ -0,0 +1,190 @@ +package robot + +import ( + "sort" + "testing" + "time" + + "scrabble/backend/internal/engine" +) + +// TestPlayToWinDistribution checks the once-per-game decision is fixed per seed +// and lands near the 40% target over many games. +func TestPlayToWinDistribution(t *testing.T) { + const n = 20000 + wins := 0 + for seed := int64(1); seed <= n; seed++ { + if playToWin(seed) { + wins++ + } + if playToWin(seed) != playToWin(seed) { + t.Fatalf("playToWin not deterministic for seed %d", seed) + } + } + pct := float64(wins) / float64(n) * 100 + if pct < 37 || pct > 43 { + t.Errorf("play-to-win rate = %.1f%%, want ~40%% (37-43)", pct) + } +} + +// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in +// [2min, 90min) and is reproducible for a (seed, moveCount). +func TestMoveDelayBoundsAndDeterminism(t *testing.T) { + for seed := int64(1); seed <= 200; seed++ { + for mc := 0; mc < 50; mc++ { + d := moveDelay(seed, mc) + if d < 2*time.Minute || d >= 90*time.Minute { + t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc) + } + if moveDelay(seed, mc) != d { + t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc) + } + } + } +} + +// TestMoveDelaySkew checks the distribution is right-skewed with the intended +// ~10-minute median: most delays are short, the mean sits above the median. +func TestMoveDelaySkew(t *testing.T) { + const n = 20000 + mins := make([]float64, 0, n) + var sum float64 + for mc := 0; mc < n; mc++ { + m := moveDelay(42, mc).Minutes() + mins = append(mins, m) + sum += m + } + sort.Float64s(mins) + median := mins[n/2] + mean := sum / float64(n) + if median < 7 || median > 13 { + t.Errorf("median delay = %.1f min, want ~10 (7-13)", median) + } + if mean <= median { + t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median) + } +} + +// TestSelectMovePlayToWinKeepsLeadSmall checks the winning robot prefers an +// in-band move with the smallest resulting lead. +func TestSelectMovePlayToWinKeepsLeadSmall(t *testing.T) { + cands := plays(50, 20, 5, 2) // margins 50,20,5,2 with scores even + d := selectMove(cands, 100, 100, true, marginBand{1, 30}, nil, 0) + if d.kind != decidePlay || d.move.Score != 2 { + t.Errorf("got kind=%d score=%d, want play score=2 (smallest in-band lead)", d.kind, d.move.Score) + } +} + +// TestSelectMovePlayToLoseKeepsDeficitSmall checks the losing robot prefers the +// in-band move with the smallest deficit. +func TestSelectMovePlayToLoseKeepsDeficitSmall(t *testing.T) { + cands := plays(50, 20, 15, 5) // myScore 80, opp 100 → margins 30,0,-5,-15 + d := selectMove(cands, 80, 100, false, marginBand{1, 30}, nil, 0) + if d.kind != decidePlay || d.move.Score != 15 { + t.Errorf("got kind=%d score=%d, want play score=15 (smallest deficit in band)", d.kind, d.move.Score) + } +} + +// TestSelectMoveFallbackBehind checks that when even the best play cannot reach +// the band the winning robot takes the highest-scoring move (best catch-up). +func TestSelectMoveFallbackBehind(t *testing.T) { + cands := plays(10, 5) // myScore 50, opp 100 → margins -40,-45, both below band + d := selectMove(cands, 50, 100, true, marginBand{1, 30}, nil, 0) + if d.move.Score != 10 { + t.Errorf("got score=%d, want 10 (closest to band from below)", d.move.Score) + } +} + +// TestSelectMoveFallbackOvershoot checks that when every play overshoots the band +// the winning robot takes the lowest-scoring move (keeps the lead near the cap). +func TestSelectMoveFallbackOvershoot(t *testing.T) { + cands := plays(40, 10) // myScore 100, opp 50 → margins 90,60, both above band + d := selectMove(cands, 100, 50, true, marginBand{1, 30}, nil, 0) + if d.move.Score != 10 { + t.Errorf("got score=%d, want 10 (closest to band from above)", d.move.Score) + } +} + +// TestSelectMoveNoPlay checks the exchange-or-pass fallback. +func TestSelectMoveNoPlay(t *testing.T) { + rack := []string{"A", "B", "C"} + if d := selectMove(nil, 0, 0, true, defaultBand, rack, 5); d.kind != decideExchange || len(d.exchange) != 3 { + t.Errorf("with a refillable bag want exchange of 3, got kind=%d n=%d", d.kind, len(d.exchange)) + } + if d := selectMove(nil, 0, 0, true, defaultBand, rack, 2); d.kind != decidePass { + t.Errorf("with a short bag want pass, got kind=%d", d.kind) + } + if d := selectMove(nil, 0, 0, true, defaultBand, nil, 9); d.kind != decidePass { + t.Errorf("with an empty rack want pass, got kind=%d", d.kind) + } +} + +// TestSleepDriftBounds checks the drift stays within ±3h and is deterministic. +func TestSleepDriftBounds(t *testing.T) { + for seed := int64(1); seed <= 5000; seed++ { + d := sleepDrift(seed) + if d < -3*time.Hour || d > 3*time.Hour { + t.Fatalf("drift %s out of ±3h for seed %d", d, seed) + } + if sleepDrift(seed) != d { + t.Fatalf("drift not deterministic for seed %d", seed) + } + } +} + +// TestAsleep covers the window, the drift shift, a real timezone and the +// midnight wrap. +func TestAsleep(t *testing.T) { + at := func(tz string, y int, mo time.Month, d, h int) time.Time { + loc, err := time.LoadLocation(tz) + if err != nil { + t.Fatalf("load %s: %v", tz, err) + } + return time.Date(y, mo, d, h, 0, 0, 0, loc) + } + cases := []struct { + name string + tz string + drift time.Duration + now time.Time + want bool + }{ + {"utc night", "UTC", 0, at("UTC", 2024, 1, 1, 3), true}, + {"utc day", "UTC", 0, at("UTC", 2024, 1, 1, 12), false}, + {"utc edge end", "UTC", 0, at("UTC", 2024, 1, 1, 7), false}, + {"drift+3 shifts earlier", "UTC", 3 * time.Hour, at("UTC", 2024, 1, 1, 22), true}, + {"drift+3 awake midday", "UTC", 3 * time.Hour, at("UTC", 2024, 1, 1, 5), false}, + {"drift-3 shifts later", "UTC", -3 * time.Hour, at("UTC", 2024, 1, 1, 9), true}, + {"tokyo asleep", "Asia/Tokyo", 0, at("UTC", 2024, 1, 1, 18), true}, // 03:00 JST + {"tokyo awake", "Asia/Tokyo", 0, at("UTC", 2024, 1, 1, 0), false}, // 09:00 JST + {"bad tz falls back to utc", "Nowhere/Bad", 0, at("UTC", 2024, 1, 1, 3), true}, + } + for _, c := range cases { + if got := asleep(c.tz, c.drift, c.now); got != c.want { + t.Errorf("%s: asleep = %v, want %v", c.name, got, c.want) + } + } +} + +// TestMixDeterministic checks the mixer is stable (across calls, and so across +// restarts) and salt-sensitive. +func TestMixDeterministic(t *testing.T) { + if mix(7, "win") != mix(7, "win") { + t.Error("mix not stable for the same inputs") + } + if mix(7, "win") == mix(7, "delay") { + t.Error("mix should differ by salt") + } + if mix(7, "delay", 1) == mix(7, "delay", 2) { + t.Error("mix should differ by move index") + } +} + +// plays builds candidate plays carrying only the given scores (ranked as passed). +func plays(scores ...int) []engine.MoveRecord { + out := make([]engine.MoveRecord, len(scores)) + for i, s := range scores { + out[i] = engine.MoveRecord{Action: engine.ActionPlay, Score: s} + } + return out +} diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 9fda59b..3cf767f 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -103,6 +103,13 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess return svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil) } +// LastNudgeAt returns the time of the most recent nudge senderID sent in the game +// and true, or the zero time and false when there is none. The robot opponent +// uses it to notice a human nudge on its turn and answer promptly. +func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) { + return svc.store.lastNudgeAt(ctx, gameID, senderID) +} + // Messages returns the per-game chat visible to viewerID: the viewer must be a // seated player. Messages from a sender the viewer has a block with (either // direction) are dropped, and if the viewer has disabled chat only nudges remain. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f95bebf..f30c0c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -87,7 +87,8 @@ arrive from a platform rather than completing a mandatory registration). a platform auto-provisions a durable account bound to that platform identity. Concretely, platform and email identities share one `identities` table keyed by a unique `(kind, external_id)`; email is an identity with `kind=email` and a - `confirmed` flag. The **email confirm-code flow** (Stage 4) binds an email to the + `confirmed` flag. A synthetic `kind='robot'` identity (Stage 5) backs each pooled + robot opponent (§7). The **email confirm-code flow** (Stage 4) binds an email to the authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a development log mailer when none is configured) and, once verified, attaches a @@ -191,20 +192,37 @@ Key points: ## 7. Robot opponent Substitutes for a human in 2-player auto-match when the pool yields no human -within 10 seconds. Designed to be indistinguishable from a person. +within 10 seconds (§8). It lives in `internal/robot` and plays as an ordinary +seated account through the game service, so only `internal/engine` imports the +solver. It is designed to be indistinguishable from a person. + +The robot keeps **no per-game state**: every choice is derived deterministically +from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver +(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same +behaviour on every scan and after a restart — the same philosophy as journal +replay. A pool of durable accounts — each a `kind='robot'` identity (§4), +provisioned at startup with chat and friend requests blocked — backs the +human-like name pool; those two profile toggles are all the friend/DM blocking +requires (there is no DM surface; chat is per-game). - **Balance**: at game start it decides once whether to play to win, with - `P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%). Adaptive difficulty is - post-MVP. -- **Margin targeting**: each turn it picks from `GenerateMoves` a move that - keeps the resulting lead (when playing to win) or deficit (when playing to - lose) small (≈ 1–20 points), rather than always the maximum. + `P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed. + Adaptive difficulty is post-MVP. +- **Margin targeting**: each turn it picks from the ranked candidates + (`engine.Candidates`) the move whose resulting lead (playing to win) or deficit + (playing to lose) is closest to a small band (**1–30 points**), rather than + always the maximum; with no legal play it exchanges a full rack when the bag can + refill it, else passes. - **Timing**: per-move delay sampled from a right-skewed distribution (short - delays frequent), clamped to **[2, 90] minutes**; **sleeps 00:00–07:00** in - the opponent's profile timezone (fallback UTC); on a daytime nudge after 60 - minutes idle it replies within **2–10 minutes**; it proactively nudges the - human after 12 hours idle. -- Blocks friend requests and direct messages; uses a human-like name pool. + delays frequent, median ≈ 10 min), clamped to **[2, 90] minutes**; it + **sleeps 00:00–07:00** anchored to the **opponent's** profile timezone with a + per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's + rather than running anti-phase; on a daytime nudge it replies within + **2–10 minutes**; it proactively nudges the human after **12 hours** idle + (subject to the once-per-hour chat limit). +- **Observability**: robot accounts accrue ordinary statistics (§9) — the + authoritative balance metric (target ≈ 40% robot wins) — and a + `robot_games_finished_total` OTel counter plus a per-finish log give a live view. ## 8. Lobby & social @@ -212,8 +230,10 @@ within 10 seconds. Designed to be indistinguishable from a person. fixes the board language), pairing the next two humans into a two-player auto-match with the seat order randomised for first-move fairness. The pool is lost on restart (players re-queue) and is anonymous, so it does not consult - blocks. The 10 s wait and the **robot substitution** for a missing human are - added in Stage 5. + blocks. After **10 s** with no human a background reaper substitutes a pooled + robot (§7) and starts the game. A queued player learns of a pairing or a + substitution through the matchmaker's `Poll`, the interim delivery seam until the + live match-found notification (§10). - **Friends**: a **request → accept** graph (one `friendships` table) — add by friend list or internal ID now, by platform deep-link with Stage 8. Declining or cancelling removes the pending request; blocking someone severs an existing @@ -252,7 +272,8 @@ within 10 seconds. Designed to be indistinguishable from a person. keys are application-generated **UUIDv7**. - Tables: `accounts` (durable internal accounts; Stage 3 added the away-window columns `away_start`/`away_end` and the hint wallet `hint_balance`), - `identities` (platform/email identities, unique `(kind, external_id)`), + `identities` (platform/email/robot identities, unique `(kind, external_id)`; + Stage 5's migration `00004` admits the `robot` kind), `sessions` (revoke-only opaque-token hashes), the Stage 3 game tables `games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`, `game_moves` (the move journal), `complaints` and `account_stats`, and the @@ -301,9 +322,12 @@ does not cover. Two channels: **platform-native push** (out-of-app, via the platform side-service — your-turn, nudge) and the **in-app live stream** (chat, opponent-moved, while the app is open). Backend emits notification intents; -delivery fans out to the appropriate channel. Stage 4 **persists** the -notification-worthy events (chat messages and nudges) but does not yet deliver -them: the gRPC stream to the gateway and the platform push arrive in Stage 6 / 8. +delivery fans out to the appropriate channel. A **match-found** event (a human +pairing or a robot substitution in auto-match, §8) belongs to the same fabric. +Stage 4 **persists** the notification-worthy events (chat messages and nudges) but +does not yet deliver them, and Stage 5's match-found has no live channel yet: the +gRPC stream to the gateway and the platform push arrive in Stage 6 / 8. Until then +a waiting client retrieves its started game by polling the matchmaker (`Poll`). ## 11. Observability diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 4416e2b..08fae4a 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -49,9 +49,14 @@ the bag or removed from play) is chosen when the game is created, and the leaver rack is never shown to the others. ### Robot opponent *(Stage 5)* -Indistinguishable-from-human substitute in auto-match. Decides once whether to -play to win (~40%), targets a small score margin, plays with human-like timing -and a night sleep window, and nudges/answers nudges like a person. +When auto-match finds no human within ten seconds, a robot opponent takes the empty +seat so the game starts without waiting. It is meant to feel like a person: it +decides once per game whether to play to win (about 40% of the time, so the human +wins most games), aims for a close score rather than crushing or throwing the game, +and plays at a human pace — short thinking 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 name and neither chats nor accepts friend requests. ### Social: friends, block, chat, nudge *(Stage 4)* Send a friend request and have it accepted (decline or cancel withdraws it, diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index a75b2e5..7d4ef71 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -48,9 +48,14 @@ session-токен; backend сопоставляет его с внутренн показывается остальным. ### Робот-соперник *(Stage 5)* -Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на -победу (~40%), целится в небольшой отрыв по очкам, ходит с человеческим -таймингом и ночным сном, делает и принимает nudge как человек. +Если авто-подбор не находит человека за десять секунд, свободное место занимает +робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека: +один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что +человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или +поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и +ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и +сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается +в чате и не принимает заявки в друзья. ### Социальное: друзья, блок, чат, nudge *(Stage 4)* Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление — diff --git a/docs/TESTING.md b/docs/TESTING.md index e1de2d9..44bded5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -32,21 +32,30 @@ tests or touching CI. Postgres-backed integration tests in `inttest` (full lifecycle to a natural end, **journal-replay equivalence**, the turn-timeout sweep with away-window grace, resign win/loss and statistics, the hint allowance-then-wallet policy, - word-check and complaint capture, and per-game-lock serialisation). The robot - balance/margin regression tests arrive with Stage 5. Stage 4 adds the engine's - **multi-player drop-out** cases (continue after one resign, last-survivor win, - the tile-disposition bag effect) and a domain integration test for a 3-player - **timeout that continues**. + word-check and complaint capture, and per-game-lock serialisation). Stage 4 adds + the engine's **multi-player drop-out** cases (continue after one resign, + last-survivor win, the tile-disposition bag effect) and a domain integration test + for a 3-player **timeout that continues**. The engine also gains a `Candidates` + ranked/decoded test (Stage 5). - **Social & lobby** *(Stage 4+)* — `backend/internal/social` unit-tests the chat **content filter** (links/emails/phones plus obfuscated forms) and `backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing, - cancel, per-variant pools) with a fake game creator. Postgres-backed `inttest` - covers the friend request/accept lifecycle with the block/toggle guards, the - per-user block (and its severing of friendships), chat post/list with the IP, + cancel, per-variant pools, plus the Stage 5 **robot substitution** reaper and + `Poll` delivery) with fake game-creator and robot-provider seams. Postgres-backed + `inttest` covers the friend request/accept lifecycle with the block/toggle guards, + the per-user block (and its severing of friendships), chat post/list with the IP, content and block-visibility rules, the nudge turn/rate-limit rules, the invitation flow (all-accept starts the game, decline cancels, lazy expiry, inviter-only cancel), and the email confirm-code flow (request/confirm, taken email, expiry and attempt-cap) with a fixture mailer. +- **Robot** *(Stage 5+)* — `backend/internal/robot` unit-tests the pure strategy: + the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay + (bounds, ~10-min median, determinism), the margin selection (win/lose, in-band + and out-of-band fallbacks, no-play exchange/pass), the sleep window with drift + and the midnight wrap, and mix restart-stability. Postgres-backed `inttest` + drives a robot through a full auto-match to a natural end (asserting a robot + statistics row), the matchmaker substitution end-to-end (enqueue → reap → + `[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge. ## Principles