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
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user