Stage 5: robot opponent (pool, seed-derived strategy, move driver, matchmaker substitution)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 10s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 10s

- internal/robot: durable kind='robot' account pool (migration 00004); every
  per-game and per-turn choice derived deterministically from the game seed
  (restart-stable FNV mix); a background move driver; margin targeting (band
  1-30, closest-to-band); right-skewed [2,90]min delays (median ~10m);
  opponent-anchored sleep with +/-3h drift; daytime nudge reply + proactive
  12h nudge; friend/chat blocked via profile toggles.
- engine.Candidates (decoded ranked plays); game.Candidates + RobotTurns;
  social.LastNudgeAt.
- matchmaker: 10s wait then robot substitution (reaper) + Poll delivery seam.
- config (BACKEND_ROBOT_DRIVE_INTERVAL, BACKEND_LOBBY_ROBOT_WAIT,
  BACKEND_LOBBY_REAPER_INTERVAL); main wiring + boot-time pool provisioning.
- metrics: robot account_stats (authoritative balance) + robot_games_finished_total
  OTel counter + per-finish log.
- docs: PLAN, ARCHITECTURE, FUNCTIONAL(+ru), TESTING, README; account.go comment.
- tests: robot strategy units, matchmaker reaper/Poll, engine.Candidates; inttest
  robot full-game / substitution / proactive-nudge.
This commit is contained in:
Ilia Denisov
2026-06-02 21:02:20 +02:00
parent 12fc6e498e
commit 85baabe4ba
26 changed files with 1700 additions and 85 deletions
+45 -1
View File
@@ -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 14: **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 210 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:0007: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+moveopp)
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,