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
+15
View File
@@ -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.