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
+37
View File
@@ -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) {