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
+40
View File
@@ -22,6 +22,9 @@ type GameMatcher interface {
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error)
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
// Create seats the given accounts in an active game at once; the AI-match path uses
// it to start a game already seated with a robot (no open/wait phase).
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
}
// Matchmaker turns an auto-match enqueue into a real game the player enters at once:
@@ -96,6 +99,29 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
return EnqueueResult{Matched: joined, Game: g}, nil
}
// StartVsAI creates an honest-AI game for accountID against a pooled robot, returning
// the game the player enters at once. Unlike Enqueue it never opens or waits: it picks
// a robot for the variant, seats the human and the robot in a random order (seat 0
// moves first), and creates an active game flagged vs_ai. The flag makes the robot
// reply immediately and suppresses chat, nudge and statistics; the robot's first move
// (when it is seated first) follows on the game service's fast-move trigger.
func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
robotID, err := m.robots.Pick(variant)
if err != nil {
return EnqueueResult{}, err
}
params := aiMatchParams(variant, multipleWords)
params.Seats = []uuid.UUID{accountID, robotID}
if rand.IntN(2) == 1 {
params.Seats = []uuid.UUID{robotID, accountID}
}
g, err := m.games.Create(ctx, params)
if err != nil {
return EnqueueResult{}, err
}
return EnqueueResult{Matched: true, Game: g}, nil
}
// RunReaper substitutes a robot for any open game past its wait window, scanning every
// interval until ctx is cancelled. It is started once from main.
func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
@@ -191,3 +217,17 @@ func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreatePara
MultipleWordsPerTurn: multipleWords,
}
}
// aiMatchParams builds the create parameters for an honest-AI two-player game: the
// same casual auto-match defaults plus the vs_ai flag. The 7-day inactivity clock is
// applied by the game service for vs_ai games, so TurnTimeout is left zero; the caller
// fills Seats with the human and the picked robot.
func aiMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
return game.CreateParams{
Variant: variant,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
MultipleWordsPerTurn: multipleWords,
VsAI: true,
}
}