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
+78 -17
View File
@@ -39,8 +39,14 @@ type Service struct {
clock func() time.Time
rng func() int64
pub notify.Publisher
metrics *gameMetrics
log *zap.Logger
// aiTrigger, when set, is called after an honest-AI game is created or advanced and
// is still on a robot's potential turn, so the robot replies at once instead of
// waiting for the next driver scan. It is fire-and-forget (the robot package wires
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
// these games). Kept as a func so the game package never imports the robot package.
aiTrigger func(gameID uuid.UUID)
metrics *gameMetrics
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
@@ -75,6 +81,23 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
}
}
// SetAITrigger installs the honest-AI fast-move hook called when a vs_ai game is
// created or advanced and a robot may now be on the clock. It must be called during
// startup wiring; the default (nil) leaves only the periodic driver scan. The robot
// package wires its asynchronous TriggerMove here.
func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
svc.aiTrigger = trigger
}
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
// installed, so callers can invoke it unconditionally after a create or commit.
func (svc *Service) triggerAI(g Game) {
if svc.aiTrigger != nil && g.VsAI && g.Status == StatusActive {
svc.aiTrigger(g.ID)
}
}
// activeVersion returns the dictionary version new games currently pin.
func (svc *Service) activeVersion() string {
svc.verMu.RLock()
@@ -143,11 +166,17 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
}
timeout := params.TurnTimeout
if timeout == 0 {
timeout = DefaultTurnTimeout
}
if !allowedTimeout(timeout) {
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
if params.VsAI {
// Honest-AI games use the fixed 7-day inactivity clock, not a user-chosen value
// from AllowedTurnTimeouts (it is never offered in the creation UI).
timeout = AIInactivityTimeout
} else {
if timeout == 0 {
timeout = DefaultTurnTimeout
}
if !allowedTimeout(timeout) {
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
}
}
seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range params.Seats {
@@ -200,13 +229,21 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI,
}
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err
}
svc.cache.put(id, g, params.Variant.String())
svc.metrics.recordStarted(ctx, params.Variant)
return svc.store.GetGame(ctx, id)
created, err := svc.store.GetGame(ctx, id)
if err != nil {
return Game{}, err
}
// Honest-AI game seated with a robot: if the robot moves first, reply at once
// (the periodic driver is the fallback). No-op for every human-only game.
svc.triggerAI(created)
return created, nil
}
// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
@@ -368,7 +405,7 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
if err != nil {
return MoveResult{}, err
}
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats)
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats, pre.VsAI)
if err != nil {
return MoveResult{}, err
}
@@ -516,7 +553,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if err != nil {
return MoveResult{}, err
}
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats)
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats, pre.VsAI)
if err != nil {
return MoveResult{}, err
}
@@ -546,7 +583,7 @@ func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uui
// cursor and scores, and on a game-ending move the finish stamp and statistics.
// On a persistence failure it evicts the now-divergent live game so the next
// access rebuilds from the journal.
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat) (Game, error) {
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat, vsAI bool) (Game, error) {
now := svc.clock()
logLen := len(g.Log())
scores := make([]int, g.Players())
@@ -577,12 +614,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
c.endReason = "timeout"
}
c.winner = g.Result().Winner
statSeats, err := svc.nonGuestSeats(ctx, seats)
if err != nil {
svc.cache.remove(gameID)
return Game{}, err
// Honest-AI games are practice and never touch player statistics (like guest
// games); a human game records them for its non-guest seats.
if !vsAI {
statSeats, err := svc.nonGuestSeats(ctx, seats)
if err != nil {
svc.cache.remove(gameID)
return Game{}, err
}
c.stats = buildStats(g, statSeats)
}
c.stats = buildStats(g, statSeats)
}
if err := svc.store.CommitMove(ctx, c); err != nil {
svc.cache.remove(gameID)
@@ -596,6 +637,9 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
return Game{}, err
}
svc.emitMove(ctx, post, rec, g.BagLen())
// Honest-AI game still going: nudge the robot to take its turn at once (the
// periodic driver is the fallback). No-op for human games and finished ones.
svc.triggerAI(post)
return post, nil
}
@@ -630,6 +674,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
word = rec.Words[0]
}
opponent := svc.displayName(ctx, post.Seats, rec.Player)
if post.VsAI {
opponent = aiOpponentName // the player chose an AI game; show the robot as 🤖, not its pool name
}
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
yourTurn.Language = lang
intents = append(intents, yourTurn)
@@ -759,7 +806,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
if err != nil {
return false, err
}
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats, cur.VsAI); err != nil {
return false, err
}
svc.metrics.recordAbandoned(ctx, cur.Variant)
@@ -996,6 +1043,20 @@ func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]Rob
return svc.store.RobotTurns(ctx, robotIDs)
}
// RobotTurn returns the robot driver's view of a single active game seating one of
// robotIDs, and true, or false when the game holds no pooled robot or is no longer
// active. It backs the honest-AI fast-move trigger, which drives just the one game.
func (svc *Service) RobotTurn(ctx context.Context, gameID uuid.UUID, robotIDs []uuid.UUID) (RobotTurn, bool, error) {
return svc.store.RobotTurnByGame(ctx, gameID, robotIDs)
}
// VsAI reports whether a game is an honest-AI game (games.vs_ai). The social
// service uses it to reject chat and nudge in AI games (which otherwise report
// status 'active').
func (svc *Service) VsAI(ctx context.Context, gameID uuid.UUID) (bool, error) {
return svc.store.GameVsAI(ctx, gameID)
}
// 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) {