feat: honest AI opponent in quick game
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

New Game's quick game gains an explicit opponent selector — 🤖 AI (default)
or 👤 Random player. AI starts a game seated with a pooled robot that joins
and moves at once: no per-move timeout (a 7-day inactivity loss reusing the
turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown
as 🤖 everywhere. The random path (disguised robot) is unchanged.

Driven by one game flag (games.vs_ai), set only on AI-started games so the
disguised path is never revealed; Matchmaker.StartVsAI seats the robot
directly (no open pool); the robot replies event-driven via the game service's
after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView.
This commit is contained in:
Ilia Denisov
2026-06-15 20:14:24 +02:00
parent 91d5c341ef
commit aa765a0c06
56 changed files with 901 additions and 86 deletions
+46
View File
@@ -63,6 +63,18 @@ func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time)
if !ok {
return nil
}
// Honest-AI game: the robot moves the instant it is its turn — no sleep window and
// no proactive nudge (chat and nudge are disabled in these games, and the player
// chose an opponent that "moves at once"). It still plays to the same per-game
// strength (playToWin) and margin band as the human-mimicry path.
if rt.VsAI {
if rt.ToMove == rt.RobotSeat {
return s.act(ctx, rt, now)
}
return nil
}
opp, err := s.accounts.GetByID(ctx, oppID)
if err != nil {
return err
@@ -77,6 +89,40 @@ func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time)
return s.maybeNudge(ctx, rt, now)
}
// DriveGame handles a single active game seating a pool robot, immediately. It backs
// the honest-AI fast-move trigger fired by the game service after a vs_ai game is
// created or advanced; it mirrors Drive's per-game step but for one game (false from
// the focused read means the game is gone, finished, or holds no pooled robot).
func (s *Service) DriveGame(ctx context.Context, gameID uuid.UUID, now time.Time) error {
rt, ok, err := s.games.RobotTurn(ctx, gameID, s.poolIDs())
if err != nil {
return err
}
if !ok {
return nil
}
return s.handle(ctx, rt, now)
}
// driveTimeout bounds a triggered, off-request honest-AI move so a stuck call cannot
// leak a goroutine. A robot move is a quick in-process computation, so it is generous.
const driveTimeout = 30 * time.Second
// TriggerMove asynchronously drives the robot's move in an honest-AI game, used by the
// game service's after-commit/after-create hook so the robot replies at once instead of
// waiting for the next driver scan. It returns immediately and runs on a background
// context (the originating request's context may already be cancelled); errors are
// logged. The periodic Drive scan remains the fallback if a trigger is missed.
func (s *Service) TriggerMove(gameID uuid.UUID) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), driveTimeout)
defer cancel()
if err := s.DriveGame(ctx, gameID, s.clock()); err != nil {
s.log.Warn("robot trigger failed", zap.String("game", gameID.String()), zap.Error(err))
}
}()
}
// 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.