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