Stage 5: robot opponent (pool, seed-derived strategy, move driver, matchmaker substitution)
- internal/robot: durable kind='robot' account pool (migration 00004); every per-game and per-turn choice derived deterministically from the game seed (restart-stable FNV mix); a background move driver; margin targeting (band 1-30, closest-to-band); right-skewed [2,90]min delays (median ~10m); opponent-anchored sleep with +/-3h drift; daytime nudge reply + proactive 12h nudge; friend/chat blocked via profile toggles. - engine.Candidates (decoded ranked plays); game.Candidates + RobotTurns; social.LastNudgeAt. - matchmaker: 10s wait then robot substitution (reaper) + Poll delivery seam. - config (BACKEND_ROBOT_DRIVE_INTERVAL, BACKEND_LOBBY_ROBOT_WAIT, BACKEND_LOBBY_REAPER_INTERVAL); main wiring + boot-time pool provisioning. - metrics: robot account_stats (authoritative balance) + robot_games_finished_total OTel counter + per-finish log. - docs: PLAN, ARCHITECTURE, FUNCTIONAL(+ru), TESTING, README; account.go comment. - tests: robot strategy units, matchmaker reaper/Poll, engine.Candidates; inttest robot full-game / substitution / proactive-nudge.
This commit is contained in:
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
@@ -25,6 +27,28 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
|
||||
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
||||
}
|
||||
|
||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
|
||||
// an empty pool.
|
||||
type fakeRobots struct {
|
||||
id uuid.UUID
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRobots) Pick() (uuid.UUID, error) {
|
||||
if f.err != nil {
|
||||
return uuid.Nil, f.err
|
||||
}
|
||||
return f.id, nil
|
||||
}
|
||||
|
||||
// testWaitDelay is long enough that the reaper never fires in the pairing tests
|
||||
// (which do not run it); the substitution tests drive reap directly.
|
||||
const testWaitDelay = 10 * time.Second
|
||||
|
||||
func newTestMatchmaker(creator GameCreator, robotID uuid.UUID) *Matchmaker {
|
||||
return NewMatchmaker(creator, &fakeRobots{id: robotID}, testWaitDelay, zap.NewNop())
|
||||
}
|
||||
|
||||
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
|
||||
for _, w := range want {
|
||||
found := false
|
||||
@@ -43,7 +67,7 @@ func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
|
||||
|
||||
func TestMatchmakerPairsTwoHumans(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator)
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
a, b := uuid.New(), uuid.New()
|
||||
|
||||
@@ -78,10 +102,22 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) {
|
||||
if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer {
|
||||
t.Errorf("auto-match defaults not applied: %+v", p)
|
||||
}
|
||||
|
||||
// The waiting opponent learns of the match through Poll, exactly once.
|
||||
got, err := mm.Poll(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("poll a: %v", err)
|
||||
}
|
||||
if !got.Matched || got.Game.ID != r2.Game.ID {
|
||||
t.Errorf("poll a = %+v, want the matched game %s", got, r2.Game.ID)
|
||||
}
|
||||
if again, _ := mm.Poll(ctx, a); again.Matched {
|
||||
t.Error("poll result must drain after the first read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerAlreadyQueued(t *testing.T) {
|
||||
mm := NewMatchmaker(&fakeCreator{})
|
||||
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
@@ -93,7 +129,7 @@ func TestMatchmakerAlreadyQueued(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMatchmakerCancel(t *testing.T) {
|
||||
mm := NewMatchmaker(&fakeCreator{})
|
||||
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
@@ -112,7 +148,7 @@ func TestMatchmakerCancel(t *testing.T) {
|
||||
|
||||
func TestMatchmakerVariantsAreSeparate(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator)
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue en: %v", err)
|
||||
@@ -130,7 +166,7 @@ func TestMatchmakerVariantsAreSeparate(t *testing.T) {
|
||||
|
||||
func TestMatchmakerFIFO(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator)
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
||||
for _, id := range []uuid.UUID{a, b, c} {
|
||||
@@ -149,3 +185,75 @@ func TestMatchmakerFIFO(t *testing.T) {
|
||||
t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperSubstitutesRobot(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
robotID := uuid.New()
|
||||
mm := newTestMatchmaker(creator, robotID)
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
|
||||
mm.Reap(ctx, base.Add(5*time.Second)) // before the wait window
|
||||
if len(creator.created) != 0 || mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Fatalf("must not substitute before the wait: created=%d queued=%d", len(creator.created), mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // past the wait window
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1 after substitution", len(creator.created))
|
||||
}
|
||||
if !seatsContain(creator.created[0].Seats, a, robotID) {
|
||||
t.Errorf("substituted game seats = %v, want human %s and robot %s", creator.created[0].Seats, a, robotID)
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Errorf("waiter should be dequeued after substitution")
|
||||
}
|
||||
got, err := mm.Poll(ctx, a)
|
||||
if err != nil || !got.Matched {
|
||||
t.Errorf("poll after substitution = %+v err=%v, want matched", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
mm.Cancel(ctx, a)
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
||||
if len(creator.created) != 0 {
|
||||
t.Errorf("a cancelled waiter must not be substituted; created %d", len(creator.created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
||||
if len(creator.created) != 0 {
|
||||
t.Errorf("no robot available: must not create a game; created %d", len(creator.created))
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user