feat: honest AI opponent in quick game
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

New Game's quick game gains an explicit opponent selector — 🤖 AI (default)
or 👤 Random player. AI starts a game seated with a pooled robot that joins
and moves at once: no per-move timeout (a 7-day inactivity loss reusing the
turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown
as 🤖 everywhere. The random path (disguised robot) is unchanged.

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