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
+1
View File
@@ -51,6 +51,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount,
EndReason: g.EndReason,
Seats: seats,
+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) {
+81 -17
View File
@@ -41,6 +41,8 @@ type gameInsert struct {
dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn bool
// vsAI marks an honest-AI game (games.vs_ai).
vsAI bool
// status is the lifecycle state to create the game in: StatusActive for a normal
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
// empty string defaults to StatusActive.
@@ -130,9 +132,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn)
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
@@ -916,7 +918,7 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
}
stmt := postgres.SELECT(
table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt,
table.Games.MoveCount, table.Games.Seed,
table.Games.MoveCount, table.Games.Seed, table.Games.VsAi,
table.GamePlayers.Seat, table.GamePlayers.AccountID,
).FROM(
table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)),
@@ -934,24 +936,85 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
}
out := make([]RobotTurn, 0, len(rows))
for _, r := range rows {
// The filter matches only the robot's (non-null) seat, so AccountID is set.
robotID := uuid.Nil
if r.GamePlayers.AccountID != nil {
robotID = *r.GamePlayers.AccountID
}
out = append(out, RobotTurn{
GameID: r.Games.GameID,
RobotID: robotID,
RobotSeat: int(r.GamePlayers.Seat),
ToMove: int(r.Games.ToMove),
TurnStartedAt: r.Games.TurnStartedAt,
MoveCount: int(r.Games.MoveCount),
Seed: r.Games.Seed,
})
out = append(out, robotTurnFrom(r.Games, r.GamePlayers))
}
return out, nil
}
// RobotTurnByGame returns the robot turn for a single active game — the seat held by
// one of ids (the robot pool) — and true, or false when the game is not active, holds
// no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which
// drives one game at once rather than scanning the whole pool (RobotTurns).
func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) {
if len(ids) == 0 {
return RobotTurn{}, false, nil
}
exprs := make([]postgres.Expression, len(ids))
for i, id := range ids {
exprs[i] = postgres.UUID(id)
}
stmt := postgres.SELECT(
table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt,
table.Games.MoveCount, table.Games.Seed, table.Games.VsAi,
table.GamePlayers.Seat, table.GamePlayers.AccountID,
).FROM(
table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)),
).WHERE(
table.Games.GameID.EQ(postgres.UUID(gameID)).
AND(table.Games.Status.EQ(postgres.String(StatusActive))).
AND(table.GamePlayers.AccountID.IN(exprs...)),
).LIMIT(1)
var rows []struct {
model.Games
model.GamePlayers
}
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return RobotTurn{}, false, fmt.Errorf("game: robot turn by game: %w", err)
}
if len(rows) == 0 {
return RobotTurn{}, false, nil
}
return robotTurnFrom(rows[0].Games, rows[0].GamePlayers), true, nil
}
// robotTurnFrom projects a games row joined with the robot's seat into a RobotTurn.
// The query matches only the robot's (non-null) seat, so AccountID is set.
func robotTurnFrom(g model.Games, p model.GamePlayers) RobotTurn {
robotID := uuid.Nil
if p.AccountID != nil {
robotID = *p.AccountID
}
return RobotTurn{
GameID: g.GameID,
RobotID: robotID,
RobotSeat: int(p.Seat),
ToMove: int(g.ToMove),
TurnStartedAt: g.TurnStartedAt,
MoveCount: int(g.MoveCount),
Seed: g.Seed,
VsAI: g.VsAi,
}
}
// GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap
// single-column read for the social chat/nudge gate, which must reject both in an
// AI game even though it reports status 'active'. ErrNotFound when the game is gone.
func (s *Store) GameVsAI(ctx context.Context, id uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Games.VsAi).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return false, ErrNotFound
}
return false, fmt.Errorf("game: get vs_ai %s: %w", id, err)
}
return row.VsAi, nil
}
// GameSeed returns the bag seed a game was dealt from, used to replay it. The
// seed is server-only state and never travels in the public Game view.
func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
@@ -1046,6 +1109,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
UpdatedAt: g.UpdatedAt,
}
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.VsAI = g.VsAi
if g.EndReason != nil {
out.EndReason = *g.EndReason
}
+23
View File
@@ -79,6 +79,19 @@ var AllowedTurnTimeouts = []time.Duration{
// zero (the owner's default: a full day).
const DefaultTurnTimeout = 24 * time.Hour
// AIInactivityTimeout is the move clock for an honest-AI game (CreateParams.VsAI).
// These games have no short per-move timeout; instead the player loses a game they
// abandon after seven days of inactivity. Because the robot moves immediately, only
// the human is ever on the clock, so the per-turn timeout doubles as the abandon
// rule (the sweeper resigns the overdue seat). It is set programmatically and is not
// one of AllowedTurnTimeouts (never offered in the creation UI).
const AIInactivityTimeout = 7 * 24 * time.Hour
// aiOpponentName is the display marker shown for the robot in an honest-AI game's
// out-of-app pushes, so the player who chose an AI game never sees the robot's
// human-like pool name. The in-app UI applies the same 🤖 from the game's vs_ai flag.
const aiOpponentName = "🤖"
// CreateParams describes a new game. Seats lists the seated accounts in turn
// order (seat 0 moves first); lobby/matchmaking assembles it in a later stage.
type CreateParams struct {
@@ -92,6 +105,10 @@ type CreateParams struct {
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule —
// only the main word is validated and scored. Russian games default to false.
MultipleWordsPerTurn bool
// VsAI creates an honest-AI game against a pooled robot (see games.vs_ai): the
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
VsAI bool
}
// Game is the persisted state of a match: the games row joined with its seats.
@@ -115,6 +132,9 @@ type Game struct {
FinishedAt *time.Time
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
VsAI bool
}
// Seat is one player's standing in a game.
@@ -220,6 +240,9 @@ type RobotTurn struct {
TurnStartedAt time.Time
MoveCount int
Seed int64
// VsAI is true when the game is an honest-AI game: the driver then makes the
// robot move immediately, with no sleep window and no proactive nudge.
VsAI bool
}
// Complaint is a word-check complaint in the admin review queue. It is filed