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:
@@ -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