f3768d20f2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
In a dead-drawn endgame — the two most recent journal moves are both passes, so the board and the robot's rack are frozen and the robot is bound to pass again — the robot still waited out its long late-game think time (up to 90 min) before passing, needlessly dragging out a decided game. Shorten that delay to a [0.8, 1.5]x band around the human's last-move think time (the gap between the last two journal entries), clamped to [30s, 8min] and taken as a min with the normal schedule, so the robot never moves slower. A slow human collapses to the 8-min cap; a fast human is tracked, with the floor keeping the robot from passing suspiciously instantly. The anchor reads the move journal only (no schema change), stays deterministic from the seed, and still defers to the sleep window. RobotTurns now carries EndgamePass + OppLastMove, filled by one batched journal query on the scan; the honest-AI single-game trigger keeps the normal path (it moves at once). NextMoveAt (admin ETA) is left as the normal-schedule upper bound.
373 lines
13 KiB
Go
373 lines
13 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
)
|
|
|
|
// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
|
|
// idle) at a chosen instant, independent of wall time.
|
|
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
|
|
t.Helper()
|
|
if _, err := testDB.ExecContext(context.Background(),
|
|
`UPDATE backend.games SET turn_started_at = $2 WHERE game_id = $1`, id, at); err != nil {
|
|
t.Fatalf("set turn_started_at: %v", err)
|
|
}
|
|
}
|
|
|
|
// isRobotAccount reports whether the account carries a robot identity.
|
|
func isRobotAccount(t *testing.T, id uuid.UUID) bool {
|
|
t.Helper()
|
|
var n int
|
|
if err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT count(*) FROM backend.identities WHERE account_id = $1 AND kind = 'robot'`, id).Scan(&n); err != nil {
|
|
t.Fatalf("count robot identity: %v", err)
|
|
}
|
|
return n > 0
|
|
}
|
|
|
|
// countNudges counts the nudges senderID has sent in a game.
|
|
func countNudges(t *testing.T, gameID, senderID uuid.UUID) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT count(*) FROM backend.chat_messages WHERE game_id = $1 AND sender_id = $2 AND kind = 'nudge'`,
|
|
gameID, senderID).Scan(&n); err != nil {
|
|
t.Fatalf("count nudges: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// daytime is a fixed instant whose hour is awake for every sleep drift (the
|
|
// always-awake band is [10,21) local), used to drive robot moves deterministically.
|
|
var daytime = time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC)
|
|
|
|
// TestRobotPoolProvisionsRobotAccounts checks EnsurePool creates durable,
|
|
// chat/friend-blocked robot accounts (exercising the kind='robot' migration) and
|
|
// is idempotent.
|
|
func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
|
ctx := context.Background()
|
|
r := newRobotService(t, newGameService())
|
|
if err := r.EnsurePool(ctx); err != nil {
|
|
t.Fatalf("ensure pool: %v", err)
|
|
}
|
|
if err := r.EnsurePool(ctx); err != nil {
|
|
t.Fatalf("ensure pool (idempotent): %v", err)
|
|
}
|
|
id, err := r.Pick(engine.VariantEnglish)
|
|
if err != nil {
|
|
t.Fatalf("pick: %v", err)
|
|
}
|
|
if !isRobotAccount(t, id) {
|
|
t.Errorf("picked account %s is not a robot identity", id)
|
|
}
|
|
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
|
|
t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err)
|
|
}
|
|
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("get robot account: %v", err)
|
|
}
|
|
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
|
|
// expires, mirroring a human who ignores it.
|
|
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
|
|
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
|
|
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
|
}
|
|
}
|
|
|
|
// TestRobotPlaysAutoMatchToEnd drives a robot through a full two-player game (the
|
|
// human plays greedily) and checks it finishes with a robot statistics row. The
|
|
// robot is forced due each turn by resetting the turn clock and driving at a fixed
|
|
// daytime instant, so the game does not depend on wall time.
|
|
func TestRobotPlaysAutoMatchToEnd(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)
|
|
}
|
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
|
if err != nil {
|
|
t.Fatalf("pick: %v", err)
|
|
}
|
|
human := provisionAccount(t)
|
|
seed := openingSeed(t)
|
|
|
|
g, err := svc.Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
|
|
TurnTimeout: 24 * time.Hour, Seed: seed,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
robotSeat := 1 // seats = [human, robot]
|
|
|
|
finished := false
|
|
for i := 0; i < 400 && !finished; i++ {
|
|
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
|
if err != nil {
|
|
t.Fatalf("participants: %v", err)
|
|
}
|
|
if status != game.StatusActive {
|
|
finished = true
|
|
break
|
|
}
|
|
if toMove == robotSeat {
|
|
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour)) // well past any sampled delay
|
|
robots.Drive(ctx, daytime)
|
|
continue
|
|
}
|
|
playHuman(t, ctx, svc, g.ID, human)
|
|
}
|
|
if !finished {
|
|
t.Fatal("robot game did not finish within the move budget")
|
|
}
|
|
|
|
if _, _, _, mg, _, ok := readStats(t, robotID); !ok || mg < 0 {
|
|
t.Errorf("robot must have a statistics row after a finished game (found=%v, maxGame=%d)", ok, mg)
|
|
}
|
|
}
|
|
|
|
// TestMatchmakerSubstitutesRobotEndToEnd checks the reaper fills an open game's empty
|
|
// seat with a real robot account once its wait window has elapsed.
|
|
func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
|
|
ctx := context.Background()
|
|
clearOpenGames(t)
|
|
robots := newRobotService(t, newGameService())
|
|
if err := robots.EnsurePool(ctx); err != nil {
|
|
t.Fatalf("ensure pool: %v", err)
|
|
}
|
|
// Zero wait and jitter so the opened game is immediately due for a robot.
|
|
mm := newMatchmaker(t, robots, 0, 0)
|
|
human := provisionAccount(t)
|
|
|
|
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
|
|
if err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
if r.Matched {
|
|
t.Fatal("first enqueue must open a game awaiting an opponent")
|
|
}
|
|
|
|
mm.Reap(ctx, time.Now().Add(time.Second)) // past the (zero) wait window
|
|
seats, _, status, err := newGameService().Participants(ctx, r.Game.ID)
|
|
if err != nil {
|
|
t.Fatalf("participants: %v", err)
|
|
}
|
|
if status != game.StatusActive || len(seats) != 2 {
|
|
t.Fatalf("substituted game: status %q seats %v", status, seats)
|
|
}
|
|
var human0, robot0 bool
|
|
for _, s := range seats {
|
|
switch {
|
|
case s == human:
|
|
human0 = true
|
|
case isRobotAccount(t, s):
|
|
robot0 = true
|
|
}
|
|
}
|
|
if !human0 || !robot0 {
|
|
t.Errorf("substituted seats must be the human and a robot, got %v", seats)
|
|
}
|
|
}
|
|
|
|
// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the
|
|
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
|
|
func TestRobotProactiveNudge(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)
|
|
}
|
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
|
if err != nil {
|
|
t.Fatalf("pick: %v", err)
|
|
}
|
|
human := provisionAccount(t)
|
|
seed := openingSeed(t)
|
|
|
|
// Seat the human first so it is the human's turn and the robot is the awaiter.
|
|
g, err := svc.Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
|
|
TurnTimeout: 24 * time.Hour, Seed: seed,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
|
|
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
|
|
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
|
setTurnStarted(t, g.ID, start)
|
|
|
|
robots.Drive(ctx, start.Add(30*time.Minute))
|
|
if n := countNudges(t, g.ID, robotID); n != 0 {
|
|
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
|
|
}
|
|
robots.Drive(ctx, start.Add(2*time.Hour))
|
|
if n := countNudges(t, g.ID, robotID); n != 1 {
|
|
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n)
|
|
}
|
|
}
|
|
|
|
// playHuman makes a greedy human move: the top candidate, else an exchange, else a
|
|
// pass.
|
|
func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, human uuid.UUID) {
|
|
t.Helper()
|
|
cands, err := svc.Candidates(ctx, gameID, human)
|
|
if err != nil {
|
|
t.Fatalf("human candidates: %v", err)
|
|
}
|
|
if len(cands) > 0 {
|
|
if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Tiles); err != nil {
|
|
t.Fatalf("human play: %v", err)
|
|
}
|
|
return
|
|
}
|
|
st, err := svc.GameState(ctx, gameID, human)
|
|
if err != nil {
|
|
t.Fatalf("human state: %v", err)
|
|
}
|
|
if len(st.Rack) > 0 && st.BagLen >= len(st.Rack) {
|
|
if _, err := svc.Exchange(ctx, gameID, human, st.Rack); err != nil {
|
|
t.Fatalf("human exchange: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if _, err := svc.Pass(ctx, gameID, human); err != nil {
|
|
t.Fatalf("human pass: %v", err)
|
|
}
|
|
}
|
|
|
|
// countMoves returns the number of committed moves in a game's journal.
|
|
func countMoves(t *testing.T, gameID uuid.UUID) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT count(*) FROM backend.game_moves WHERE game_id = $1`, gameID).Scan(&n); err != nil {
|
|
t.Fatalf("count moves: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// setLastTwoMoveTimes rewrites the created_at of a game's two most recent journal entries
|
|
// so the endgame think-time anchor (their gap, the human's last-move think time) is a known
|
|
// value, independent of the millisecond spacing of the manufactured passes.
|
|
func setLastTwoMoveTimes(t *testing.T, gameID uuid.UUID, prevAt, lastAt time.Time) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`UPDATE backend.game_moves SET created_at = $2
|
|
WHERE game_id = $1 AND seq = (SELECT max(seq) FROM backend.game_moves WHERE game_id = $1)`,
|
|
gameID, lastAt); err != nil {
|
|
t.Fatalf("set last move time: %v", err)
|
|
}
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`UPDATE backend.game_moves SET created_at = $2
|
|
WHERE game_id = $1 AND seq = (SELECT max(seq) - 1 FROM backend.game_moves WHERE game_id = $1)`,
|
|
gameID, prevAt); err != nil {
|
|
t.Fatalf("set prev move time: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRobotEndgamePassShrinksThinkTime checks the dead-drawn-endgame shrink end to end: when
|
|
// the two most recent moves are both passes, RobotTurns reports EndgamePass with the human's
|
|
// last-move think time (OppLastMove), and the driver answers on the shortened schedule — well
|
|
// before the normal late-game delay — rather than dragging out the decided game. The journal
|
|
// state is manufactured with direct passes so the test isolates the timing mechanism (the
|
|
// SQL anchor and the driver gate) from a full game to an empty bag.
|
|
func TestRobotEndgamePassShrinksThinkTime(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)
|
|
}
|
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
|
if err != nil {
|
|
t.Fatalf("pick: %v", err)
|
|
}
|
|
human := provisionAccount(t)
|
|
seed := openingSeed(t)
|
|
|
|
g, err := svc.Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
|
|
TurnTimeout: 24 * time.Hour, Seed: seed,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
const robotSeat = 1
|
|
store := game.NewStore(testDB)
|
|
|
|
turnOf := func(id uuid.UUID) game.RobotTurn {
|
|
t.Helper()
|
|
turns, err := store.RobotTurns(ctx, []uuid.UUID{robotID})
|
|
if err != nil {
|
|
t.Fatalf("robot turns: %v", err)
|
|
}
|
|
for _, rt := range turns {
|
|
if rt.GameID == id {
|
|
return rt
|
|
}
|
|
}
|
|
t.Fatalf("no robot turn for game %s", id)
|
|
return game.RobotTurn{}
|
|
}
|
|
|
|
// One pass so far (the human): not yet a double-pass endgame.
|
|
if _, err := svc.Pass(ctx, g.ID, human); err != nil {
|
|
t.Fatalf("human pass 1: %v", err)
|
|
}
|
|
if rt := turnOf(g.ID); rt.EndgamePass {
|
|
t.Fatalf("EndgamePass after a single pass = true, want false")
|
|
}
|
|
|
|
// Robot then human pass: the two most recent moves are now both passes and it is the
|
|
// robot's turn — the guaranteed-pass endgame state.
|
|
if _, err := svc.Pass(ctx, g.ID, robotID); err != nil {
|
|
t.Fatalf("robot pass: %v", err)
|
|
}
|
|
if _, err := svc.Pass(ctx, g.ID, human); err != nil {
|
|
t.Fatalf("human pass 2: %v", err)
|
|
}
|
|
if _, toMove, _, err := svc.Participants(ctx, g.ID); err != nil || toMove != robotSeat {
|
|
t.Fatalf("after two passes: toMove %d err %v, want robot seat %d", toMove, err, robotSeat)
|
|
}
|
|
|
|
// Anchor the human's last-move think time to 60s and start the robot's turn at daytime.
|
|
setLastTwoMoveTimes(t, g.ID, daytime.Add(-60*time.Second), daytime)
|
|
setTurnStarted(t, g.ID, daytime)
|
|
|
|
rt := turnOf(g.ID)
|
|
if !rt.EndgamePass {
|
|
t.Fatalf("EndgamePass after a double pass = false, want true")
|
|
}
|
|
if d := rt.OppLastMove; d < 59*time.Second || d > 61*time.Second {
|
|
t.Fatalf("OppLastMove = %s, want ~60s", d)
|
|
}
|
|
|
|
// The normal schedule for this move is at least the early band floor (~3.75 min); the
|
|
// 60s-anchored endgame delay is at most 90s for every seed. Driving at +150s is past the
|
|
// shrunk delay but well before the normal one, so the robot acts only because of the shrink.
|
|
before := countMoves(t, g.ID)
|
|
robots.Drive(ctx, daytime.Add(150*time.Second))
|
|
if after := countMoves(t, g.ID); after != before+1 {
|
|
t.Fatalf("robot did not act on the shrunk endgame schedule: moves %d → %d (want +1)", before, after)
|
|
}
|
|
if _, toMove, _, err := svc.Participants(ctx, g.ID); err != nil || toMove == robotSeat {
|
|
t.Fatalf("after the shrunk move: still the robot's turn (toMove %d, err %v)", toMove, err)
|
|
}
|
|
}
|