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
+194
View File
@@ -0,0 +1,194 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
)
// The AI-game suite covers the honest-AI quick game: a game created already seated with a
// pooled robot (vs_ai), in which the robot moves immediately, the move clock is the 7-day
// inactivity rule, chat and nudge are disabled and no statistics are recorded.
// startAIGame creates an active vs_ai game seating a fresh human and a pooled robot, the human
// at seat 0 when humanFirst (otherwise the robot moves first), and returns it with the two ids.
func startAIGame(t *testing.T, svc *game.Service, robots *robot.Service, humanFirst bool, seed int64) (game.Game, uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
human := provisionAccount(t)
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick robot: %v", err)
}
seats := []uuid.UUID{human, robotID}
if !humanFirst {
seats = []uuid.UUID{robotID, human}
}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, VsAI: true, Seed: seed,
HintsAllowed: true, HintsPerPlayer: 1, MultipleWordsPerTurn: true,
})
if err != nil {
t.Fatalf("create AI game: %v", err)
}
return g, human, robotID
}
// TestAIGameStartSeatsRobotActive checks the matchmaker's AI path creates an active game already
// seated with a robot (no open/wait phase), flagged vs_ai and on the 7-day inactivity clock.
func TestAIGameStartSeatsRobotActive(t *testing.T) {
ctx := context.Background()
robots := newRobotService(t, newGameService())
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
mm := newMatchmaker(t, robots, time.Minute, 0)
human := provisionAccount(t)
res, err := mm.StartVsAI(ctx, human, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("start vs AI: %v", err)
}
g := res.Game
if !res.Matched || g.Status != game.StatusActive {
t.Fatalf("AI game = (matched %v, status %q), want (true, active)", res.Matched, g.Status)
}
if !g.VsAI {
t.Error("an AI game must be flagged vs_ai")
}
if g.TurnTimeout != game.AIInactivityTimeout {
t.Errorf("AI game move clock = %s, want %s", g.TurnTimeout, game.AIInactivityTimeout)
}
var hasHuman, hasRobot bool
for _, s := range g.Seats {
switch {
case s.AccountID == human:
hasHuman = true
case s.AccountID != uuid.Nil:
hasRobot = true
}
}
if len(g.Seats) != 2 || !hasHuman || !hasRobot {
t.Errorf("AI game seats = %+v, want exactly the human and a robot (no empty seat)", g.Seats)
}
}
// TestAIRobotMovesImmediately checks the robot in a vs_ai game moves the instant it is its turn,
// with no sampled delay and no sleep window (the honest-AI fast path the trigger drives).
func TestAIRobotMovesImmediately(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, _, _ := startAIGame(t, svc, robots, false, openingSeed(t)) // robot at seat 0, to move
if err := robots.DriveGame(ctx, g.ID, time.Now()); err != nil {
t.Fatalf("drive AI game: %v", err)
}
after, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if after.MoveCount != 1 {
t.Errorf("after one drive the robot's first move must be in (move count %d, want 1)", after.MoveCount)
}
if after.ToMove == 0 {
t.Error("after the robot's move it must be the human's turn")
}
}
// TestAIGameStatsSkipped checks a vs_ai game records no statistics: a human resign (a loss in a
// normal game) leaves the human with no stats row.
func TestAIGameStatsSkipped(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, human, _ := startAIGame(t, svc, robots, true, openingSeed(t))
if _, err := svc.Resign(ctx, g.ID, human); err != nil {
t.Fatalf("resign: %v", err)
}
if _, _, _, _, _, found := readStats(t, human); found {
t.Error("an AI game must not record statistics for the human")
}
}
// TestAIGameSevenDayTimeout checks the reused turn-timeout sweeper enforces the 7-day inactivity
// rule: nothing fires within seven days, and past it the human on the clock is resigned and loses.
func TestAIGameSevenDayTimeout(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, human, robotID := startAIGame(t, svc, robots, true, openingSeed(t)) // human at seat 0, to move
// Six days idle: well under the 7-day clock, so the sweeper leaves it active.
setTurnStarted(t, g.ID, time.Now().Add(-6*24*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
if mid, _ := svc.GameByID(ctx, g.ID); mid.Status != game.StatusActive {
t.Fatalf("at six days idle the AI game must still be active, got %q", mid.Status)
}
// Eight days idle: past the clock, so the human (on the clock) is timed out and loses.
setTurnStarted(t, g.ID, time.Now().Add(-8*24*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
t.Fatalf("sweep: %v", err)
}
done, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if done.Status != game.StatusFinished || done.EndReason != "timeout" {
t.Fatalf("after seven days idle: (status %q, reason %q), want (finished, timeout)", done.Status, done.EndReason)
}
for _, s := range done.Seats {
if s.AccountID == robotID && !s.IsWinner {
t.Error("the robot must win when the human abandons the game")
}
if s.AccountID == human && s.IsWinner {
t.Error("the abandoning human must not win")
}
}
if _, _, _, _, _, found := readStats(t, human); found {
t.Error("a timed-out AI game must not record statistics")
}
}
// TestAIGameChatAndNudgeRejected checks chat and nudge are both refused in a vs_ai game (the
// opponent is a robot), even though the game reports status 'active'.
func TestAIGameChatAndNudgeRejected(t *testing.T) {
ctx := context.Background()
svc := newGameService()
soc := newSocialService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
g, human, _ := startAIGame(t, svc, robots, true, openingSeed(t))
if _, err := soc.PostMessage(ctx, g.ID, human, "hi robot", ""); !errors.Is(err, social.ErrGameVsAI) {
t.Errorf("chat in an AI game = %v, want ErrGameVsAI", err)
}
if _, err := soc.Nudge(ctx, g.ID, human); !errors.Is(err, social.ErrGameVsAI) {
t.Errorf("nudge in an AI game = %v, want ErrGameVsAI", err)
}
}
+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,
}
}
+54
View File
@@ -3,6 +3,7 @@ package lobby
import (
"context"
"errors"
"slices"
"testing"
"time"
@@ -31,6 +32,11 @@ type stubMatcher struct {
attached bool
attachErr error
attachedGames []uuid.UUID
createGame game.Game
createErr error
createCalls int
createParams game.CreateParams
}
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
@@ -57,6 +63,12 @@ func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.Pl
return notify.PlayerState{}, nil
}
func (s *stubMatcher) Create(_ context.Context, params game.CreateParams) (game.Game, error) {
s.createCalls++
s.createParams = params
return s.createGame, s.createErr
}
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an
// empty pool. It records the variant of the last substitution request.
type fakeRobots struct {
@@ -135,6 +147,48 @@ func TestEnqueueJoinEmitsOpponentJoinedToStarter(t *testing.T) {
}
}
func TestStartVsAISeatsRobotAndFlagsGame(t *testing.T) {
human, robotID := uuid.New(), uuid.New()
m := &stubMatcher{createGame: twoSeatGame(human, robotID)}
robots := &fakeRobots{id: robotID}
mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop())
res, err := mm.StartVsAI(context.Background(), human, engine.VariantRussianScrabble, false)
if err != nil {
t.Fatalf("start vs AI: %v", err)
}
if !res.Matched {
t.Error("an AI game starts already matched (Matched=true)")
}
// The AI path creates a seated active game and never enters the open pool.
if m.createCalls != 1 || m.openCalls != 0 {
t.Errorf("calls: create=%d open=%d, want create=1 open=0", m.createCalls, m.openCalls)
}
if robots.lastVariant != engine.VariantRussianScrabble {
t.Errorf("robot picked for variant %v, want RussianScrabble", robots.lastVariant)
}
if !m.createParams.VsAI {
t.Error("AI game create params must set VsAI")
}
if len(m.createParams.Seats) != 2 ||
!slices.Contains(m.createParams.Seats, human) || !slices.Contains(m.createParams.Seats, robotID) {
t.Errorf("AI game seats %v, want exactly the human %s and the robot %s", m.createParams.Seats, human, robotID)
}
}
func TestStartVsAINoRobotLeavesNoGame(t *testing.T) {
poolErr := errors.New("pool empty")
m := &stubMatcher{}
mm := NewMatchmaker(m, &fakeRobots{err: poolErr}, time.Minute, time.Minute, zap.NewNop())
if _, err := mm.StartVsAI(context.Background(), uuid.New(), engine.VariantEnglish, true); !errors.Is(err, poolErr) {
t.Fatalf("start vs AI with empty pool = %v, want poolErr", err)
}
if m.createCalls != 0 {
t.Errorf("no robot available must not create a game; create calls = %d", m.createCalls)
}
}
func TestEnqueueDeadlineWithinWindow(t *testing.T) {
base := time.Now()
m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)}
+1
View File
@@ -36,6 +36,7 @@ func toWireGame(g GameSummary) wire.GameView {
ToMove: g.ToMove,
TurnTimeoutSecs: g.TurnTimeoutSecs,
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount,
EndReason: g.EndReason,
Seats: seats,
+6 -4
View File
@@ -29,10 +29,12 @@ type GameSummary struct {
ToMove int
TurnTimeoutSecs int
MultipleWordsPerTurn bool
MoveCount int
EndReason string
Seats []SeatStanding
LastActivityUnix int64
// VsAI marks an honest-AI game (the opponent is shown as 🤖, chat/nudge/add-friend off).
VsAI bool
MoveCount int
EndReason string
Seats []SeatStanding
LastActivityUnix int64
}
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
@@ -32,4 +32,5 @@ type Games struct {
OpenDeadlineAt *time.Time
DropoutTiles string
MultipleWordsPerTurn bool
VsAi bool
}
@@ -36,6 +36,7 @@ type gamesTable struct {
OpenDeadlineAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
VsAi postgres.ColumnBool
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -96,9 +97,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
OpenDeadlineAtColumn = postgres.TimestampzColumn("open_deadline_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
VsAiColumn = postgres.BoolColumn("vs_ai")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
)
return gamesTable{
@@ -124,6 +126,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
OpenDeadlineAt: OpenDeadlineAtColumn,
DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
VsAi: VsAiColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -102,6 +102,13 @@ CREATE TABLE games (
open_deadline_at timestamptz,
dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true,
-- vs_ai marks an "honest AI" game: the player knowingly plays a robot that joins
-- and moves at once (no disguise). It drives the 🤖 display, the disabled chat/nudge,
-- the skipped statistics and the robot's immediate, sleepless, nudge-free behaviour.
-- The robot-filled auto-match game (the "random player" path) keeps vs_ai=false, so
-- the disguised-robot opponent is never revealed. turn_timeout_secs holds the 7-day
-- inactivity clock for these games (the per-move timeout is reused as the abandon rule).
vs_ai boolean NOT NULL DEFAULT false,
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished', 'open')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
+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.
+1
View File
@@ -39,6 +39,7 @@ var ErrNoRobotAvailable = errors.New("robot: no robot available in the pool")
// player. game.Service satisfies it.
type GameDriver interface {
RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]game.RobotTurn, error)
RobotTurn(ctx context.Context, gameID uuid.UUID, robotIDs []uuid.UUID) (game.RobotTurn, bool, error)
Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error)
Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error)
GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error)
+6 -2
View File
@@ -94,8 +94,11 @@ type gameDTO struct {
ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"`
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
// are suppressed in the client.
VsAI bool `json:"vs_ai"`
MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"`
// LastActivityUnix is the lobby sort key: the current turn's start for an active
// game, the finish time once finished.
LastActivityUnix int64 `json:"last_activity_unix"`
@@ -220,6 +223,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount,
EndReason: g.EndReason,
LastActivityUnix: last.Unix(),
+2
View File
@@ -168,6 +168,8 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "nudge_own_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished"
case errors.Is(err, social.ErrGameVsAI):
return http.StatusConflict, "ai_game"
case errors.Is(err, game.ErrNoOpponentYet):
return http.StatusConflict, "no_opponent_yet"
case errors.Is(err, game.ErrGameActive):
+12 -5
View File
@@ -133,15 +133,18 @@ func (s *Server) handleGameState(c *gin.Context) {
c.JSON(http.StatusOK, dto)
}
// enqueueRequest enters per-variant auto-match under a per-turn word rule.
// enqueueRequest enters per-variant auto-match under a per-turn word rule. VsAI true
// starts an honest-AI game against a robot instead of the open/wait matchmaking path.
type enqueueRequest struct {
Variant string `json:"variant"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
VsAI bool `json:"vs_ai"`
}
// handleEnqueue enters the caller into auto-match for a variant and returns the game
// they land in immediately: a freshly opened game awaiting an opponent, or another
// player's open game they just joined. The client navigates straight into the game.
// handleEnqueue enters the caller into a quick game and returns the game they land in
// immediately. With vs_ai it starts an honest-AI game already seated with a robot;
// otherwise it enters auto-match — a freshly opened game awaiting an opponent, or
// another player's open game they just joined. The client navigates straight in.
func (s *Server) handleEnqueue(c *gin.Context) {
uid, ok := userID(c)
if !ok {
@@ -158,7 +161,11 @@ func (s *Server) handleEnqueue(c *gin.Context) {
abortBadRequest(c, "unknown variant")
return
}
res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
enter := s.matchmaker.Enqueue
if req.VsAI {
enter = s.matchmaker.StartVsAI
}
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
if err != nil {
s.abortErr(c, err)
return
+12
View File
@@ -62,6 +62,12 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if status != statusActive {
return Message{}, ErrGameNotActive
}
// Chat is disabled in honest-AI games (the opponent is a robot).
if vsAI, err := svc.games.VsAI(ctx, gameID); err != nil {
return Message{}, err
} else if vsAI {
return Message{}, ErrGameVsAI
}
if idx != toMove {
return Message{}, ErrChatNotYourTurn
}
@@ -118,6 +124,12 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
if idx < 0 {
return Message{}, ErrNotParticipant
}
// Nudge is disabled in honest-AI games (the opponent is a robot).
if vsAI, err := svc.games.VsAI(ctx, gameID); err != nil {
return Message{}, err
} else if vsAI {
return Message{}, ErrGameVsAI
}
if idx == toMove {
return Message{}, ErrNudgeOnOwnTurn
}
+6
View File
@@ -38,6 +38,9 @@ type GameReader interface {
// GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes
// to the game's bot rather than the recipient's last-login bot.
GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error)
// VsAI reports whether the game is an honest-AI game, in which chat and nudge are
// both disabled (the game still reports status 'active').
VsAI(ctx context.Context, gameID uuid.UUID) (bool, error)
}
// Sentinel errors returned by the service.
@@ -77,6 +80,9 @@ var (
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
// ErrGameNotActive is returned when a nudge is attempted on a finished game.
ErrGameNotActive = errors.New("social: game is not active")
// ErrGameVsAI is returned when chat or nudge is attempted in an honest-AI game,
// where both are disabled (the UI also disables the controls).
ErrGameVsAI = errors.New("social: chat and nudge are disabled in AI games")
// ErrChatNotYourTurn is returned when a chat message is sent while it is not the
// sender's turn — chat is allowed only on your own turn (the opponent's-turn control
// is the nudge).