feat(robot): shrink endgame think time when both sides pass
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
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.
This commit is contained in:
@@ -1020,13 +1020,18 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
|
||||
for _, r := range rows {
|
||||
out = append(out, robotTurnFrom(r.Games, r.GamePlayers))
|
||||
}
|
||||
if err := s.fillEndgamePass(ctx, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RobotTurnByGame returns the robot turn for a single active game — the seat held by
|
||||
// one of ids (the robot pool) — and true, or false when the game is not active, holds
|
||||
// no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which
|
||||
// drives one game at once rather than scanning the whole pool (RobotTurns).
|
||||
// drives one game at once rather than scanning the whole pool (RobotTurns). It leaves
|
||||
// EndgamePass false: honest-AI games move at once, so the endgame think-time shrink is a
|
||||
// human-mimicry concern computed only on the RobotTurns scan.
|
||||
func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) {
|
||||
if len(ids) == 0 {
|
||||
return RobotTurn{}, false, nil
|
||||
@@ -1079,6 +1084,79 @@ func robotTurnFrom(g model.Games, p model.GamePlayers) RobotTurn {
|
||||
}
|
||||
}
|
||||
|
||||
// fillEndgamePass marks the turns whose game is a dead-drawn endgame — its two most
|
||||
// recent committed moves are both passes, so the board and racks are frozen and the
|
||||
// seated robot is bound to pass again — setting EndgamePass and OppLastMove from the
|
||||
// move journal so the driver can shorten the robot's think time. Turns whose game is
|
||||
// not in that state are left unchanged. A nil or empty slice is a no-op. It runs one
|
||||
// batched journal query for the whole scan, so it adds no per-game round trip.
|
||||
func (s *Store) fillEndgamePass(ctx context.Context, turns []RobotTurn) error {
|
||||
if len(turns) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uuid.UUID, len(turns))
|
||||
for i := range turns {
|
||||
ids[i] = turns[i].GameID
|
||||
}
|
||||
info, err := s.endgamePassInfo(ctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range turns {
|
||||
if d, ok := info[turns[i].GameID]; ok {
|
||||
turns[i].EndgamePass = true
|
||||
turns[i].OppLastMove = d
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// endgamePassInfo returns, for each of ids whose two most recent committed moves are
|
||||
// both passes, the human's think time on the most recent of them (the gap between the
|
||||
// last two journal entries' created_at). Games with fewer than two moves, or whose last
|
||||
// two are not both passes, are absent from the map. It reads the move journal only — no
|
||||
// schema change — mirroring the analytics.go duration reports. A negative gap (clock
|
||||
// skew) is floored to zero.
|
||||
func (s *Store) endgamePassInfo(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]time.Duration, error) {
|
||||
if len(ids) == 0 {
|
||||
return map[uuid.UUID]time.Duration{}, nil
|
||||
}
|
||||
const q = `
|
||||
SELECT q.game_id, q.secs FROM (
|
||||
SELECT t.game_id,
|
||||
bool_and(t.action = 'pass') AS both_pass,
|
||||
COUNT(*) AS n,
|
||||
EXTRACT(EPOCH FROM (MAX(t.created_at) - MIN(t.created_at))) AS secs
|
||||
FROM (
|
||||
SELECT m.game_id, m.action, m.created_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq DESC) AS rn
|
||||
FROM backend.game_moves m
|
||||
WHERE m.game_id = ANY($1::uuid[])
|
||||
) t
|
||||
WHERE t.rn <= 2
|
||||
GROUP BY t.game_id
|
||||
) q
|
||||
WHERE q.n = 2 AND q.both_pass`
|
||||
rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(ids))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game: endgame pass info: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[uuid.UUID]time.Duration, len(ids))
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var secs float64
|
||||
if err := rows.Scan(&id, &secs); err != nil {
|
||||
return nil, fmt.Errorf("game: scan endgame pass info: %w", err)
|
||||
}
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
out[id] = time.Duration(secs * float64(time.Second))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap
|
||||
// single-column read for the social chat/nudge gate, which must reject both in an
|
||||
// AI game even though it reports status 'active'. ErrNotFound when the game is gone.
|
||||
|
||||
@@ -268,6 +268,15 @@ type RobotTurn struct {
|
||||
// VsAI is true when the game is an honest-AI game: the driver then makes the
|
||||
// robot move immediately, with no sleep window and no proactive nudge.
|
||||
VsAI bool
|
||||
// EndgamePass is true when the two most recent committed moves are both passes, so
|
||||
// the board and racks are frozen and the robot is bound to pass again. The driver
|
||||
// then shortens the robot's think time (see robot.endgamePassDelay) so a decided
|
||||
// game is not dragged out. It is false until at least two moves exist.
|
||||
EndgamePass bool
|
||||
// OppLastMove is the human's think time on the most recent move — the gap between
|
||||
// the last two journal entries — used to scale the shortened endgame think time. It
|
||||
// is meaningful only when EndgamePass is true (zero otherwise).
|
||||
OppLastMove time.Duration
|
||||
}
|
||||
|
||||
// Complaint is a word-check complaint in the admin review queue. It is filed
|
||||
|
||||
@@ -249,3 +249,124 @@ func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, hum
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,16 @@ func (s *Service) TriggerMove(gameID uuid.UUID) {
|
||||
// the opponent during the current turn pulls the move in to the short reply
|
||||
// window; otherwise the robot waits out its sampled delay.
|
||||
func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.UUID, now time.Time) error {
|
||||
if now.Before(rt.TurnStartedAt.Add(moveDelay(rt.Seed, rt.MoveCount))) {
|
||||
delay := moveDelay(rt.Seed, rt.MoveCount)
|
||||
if rt.EndgamePass {
|
||||
// A dead-drawn endgame (the last two moves are both passes) means the robot is
|
||||
// bound to pass again: answer on the shortened schedule scaled to the human's
|
||||
// last move, taken as a min so it is never slower than the normal think time.
|
||||
if d := endgamePassDelay(rt.Seed, rt.MoveCount, rt.OppLastMove); d < delay {
|
||||
delay = d
|
||||
}
|
||||
}
|
||||
if now.Before(rt.TurnStartedAt.Add(delay)) {
|
||||
last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, oppID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -49,6 +49,21 @@ const (
|
||||
delayHardMinMinutes = 1.0
|
||||
delayHardMaxMinutes = 90.0
|
||||
|
||||
// In a dead-drawn endgame — the two most recent committed moves are both passes,
|
||||
// so the board and the robot's rack are frozen and the robot is bound to pass
|
||||
// again — the robot drops the long late-game think time and answers on a shortened
|
||||
// schedule scaled to the human's own last-move (pass) think time: a uniform sample
|
||||
// from [endgameLoFactor, endgameHiFactor] of it, clamped to [endgameFloorSeconds,
|
||||
// endgameCapMinutes]. A slow human collapses to the cap (the robot never drags out
|
||||
// a decided game), a fast human is tracked, and the floor keeps the robot from
|
||||
// passing suspiciously instantly. The shrink only ever lowers the delay (it is
|
||||
// taken as a min with the normal schedule), so it never makes the robot slower, and
|
||||
// it composes with the sleep window, which is still honoured before any move.
|
||||
endgameLoFactor = 0.8
|
||||
endgameHiFactor = 1.5
|
||||
endgameFloorSeconds = 30.0
|
||||
endgameCapMinutes = 8.0
|
||||
|
||||
// nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's
|
||||
// lower band (delayBand's lo), within which the robot answers a daytime nudge on
|
||||
// its turn — so a nudged robot replies near the floor of its think time.
|
||||
@@ -171,7 +186,9 @@ func deviates(seed int64, moveCount, bagLen int) bool {
|
||||
// robot's sleep window). It is the sampled think-time delay, deferred to the end of the
|
||||
// sleep window when it would otherwise land while the robot is asleep. The driver acts on
|
||||
// a scan tick, so the real move lands at the first scan at or after this instant. It is
|
||||
// meaningful only on the robot's own turn; the admin console surfaces it as an ETA.
|
||||
// meaningful only on the robot's own turn; the admin console surfaces it as an ETA. In a
|
||||
// dead-drawn endgame the robot may pass sooner than this (see endgamePassDelay); NextMoveAt
|
||||
// remains the normal-schedule upper bound.
|
||||
func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time {
|
||||
t := turnStartedAt.Add(moveDelay(seed, moveCount))
|
||||
drift := sleepDrift(seed)
|
||||
@@ -215,6 +232,25 @@ func moveDelay(seed int64, moveCount int) time.Duration {
|
||||
return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew))
|
||||
}
|
||||
|
||||
// endgamePassDelay is the robot's shortened think time for a guaranteed endgame pass
|
||||
// (the two most recent moves are both passes), given the human's last-move think time
|
||||
// oppLast: a uniform sample from [endgameLoFactor, endgameHiFactor] of oppLast, clamped
|
||||
// to [endgameFloorSeconds, endgameCapMinutes]. It is deterministic per (seed, moveCount)
|
||||
// like moveDelay, and oppLast is read from the persisted move journal, so the schedule is
|
||||
// reproducible across restarts. The caller takes it as a min with moveDelay, so it never
|
||||
// slows the robot down. A non-positive oppLast (clock skew) clamps up to the floor.
|
||||
func endgamePassDelay(seed int64, moveCount int, oppLast time.Duration) time.Duration {
|
||||
floor := time.Duration(endgameFloorSeconds * float64(time.Second))
|
||||
ceil := time.Duration(endgameCapMinutes * float64(time.Minute))
|
||||
lo := clampDur(time.Duration(float64(oppLast)*endgameLoFactor), floor, ceil)
|
||||
hi := clampDur(time.Duration(float64(oppLast)*endgameHiFactor), floor, ceil)
|
||||
if hi < lo {
|
||||
hi = lo
|
||||
}
|
||||
u := unitFloat(mix(seed, "endgame", moveCount))
|
||||
return lo + time.Duration(float64(hi-lo)*u)
|
||||
}
|
||||
|
||||
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
|
||||
// moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes],
|
||||
// where lo is the move's lower band — so a nudge pulls the move in near the floor of
|
||||
@@ -254,6 +290,18 @@ func clampMinutes(mins float64) time.Duration {
|
||||
return time.Duration(mins * float64(time.Minute))
|
||||
}
|
||||
|
||||
// clampDur returns d confined to the inclusive range [lo, hi].
|
||||
func clampDur(d, lo, hi time.Duration) time.Duration {
|
||||
switch {
|
||||
case d < lo:
|
||||
return lo
|
||||
case d > hi:
|
||||
return hi
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
// sleepDrift is the per-game shift of the robot's sleep window relative to the
|
||||
// opponent's timezone, in [-sleepDriftHours, +sleepDriftHours] hours.
|
||||
func sleepDrift(seed int64) time.Duration {
|
||||
|
||||
@@ -342,6 +342,59 @@ func TestProactiveNudgeGap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndgamePassDelayBoundsAndAnchor checks the shortened endgame think time: it always
|
||||
// lands in [30s, 8min], collapses a slow human to the cap, floors a fast human, tracks a
|
||||
// mid human inside [0.8,1.5]*oppLast, floors a clock-skew negative gap, and is
|
||||
// deterministic per (seed, moveCount).
|
||||
func TestEndgamePassDelayBoundsAndAnchor(t *testing.T) {
|
||||
const floor = 30 * time.Second
|
||||
const ceil = 8 * time.Minute
|
||||
cases := []struct {
|
||||
name string
|
||||
oppLast time.Duration
|
||||
lo, hi time.Duration // expected inclusive output range
|
||||
}{
|
||||
{"clock-skew negative floors", -time.Hour, floor, floor},
|
||||
{"zero floors", 0, floor, floor},
|
||||
{"very fast floors", 3 * time.Second, floor, floor}, // [2.4s,4.5s] → floor
|
||||
{"fast tracks above floor", 30 * time.Second, floor, 45 * time.Second}, // [24s,45s] → [30s,45s]
|
||||
{"mid tracks in band", 2 * time.Minute, 96 * time.Second, 3 * time.Minute}, // [1.6m,3m]
|
||||
{"at cap boundary", 8 * time.Minute, 384 * time.Second, ceil}, // [6.4m,12m] → [6.4m,8m]
|
||||
{"slow caps", 3 * time.Hour, ceil, ceil}, // [2.4h,4.5h] → cap
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for seed := int64(1); seed <= 2000; seed++ {
|
||||
d := endgamePassDelay(seed, 30, c.oppLast)
|
||||
if d < floor || d > ceil {
|
||||
t.Fatalf("oppLast=%s seed=%d: delay %s out of hard [%s,%s]", c.oppLast, seed, d, floor, ceil)
|
||||
}
|
||||
if d < c.lo || d > c.hi {
|
||||
t.Fatalf("oppLast=%s seed=%d: delay %s out of expected [%s,%s]", c.oppLast, seed, d, c.lo, c.hi)
|
||||
}
|
||||
if endgamePassDelay(seed, 30, c.oppLast) != d {
|
||||
t.Fatalf("oppLast=%s seed=%d: not deterministic", c.oppLast, seed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndgamePassDelayShrinksLateGame checks the endgame think time is always shorter than
|
||||
// the normal late-game schedule (band floor 10min vs the 8min cap), so taking the min in the
|
||||
// driver actually speeds the robot up rather than ever slowing it down.
|
||||
func TestEndgamePassDelayShrinksLateGame(t *testing.T) {
|
||||
for seed := int64(1); seed <= 1000; seed++ {
|
||||
for mc := 28; mc <= 40; mc++ {
|
||||
eg := endgamePassDelay(seed, mc, 3*time.Hour) // worst case: a slow human, caps at 8min
|
||||
if nd := moveDelay(seed, mc); eg >= nd {
|
||||
t.Fatalf("seed=%d mc=%d: endgame %s not shorter than normal %s", seed, mc, eg, nd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// plays builds candidate plays carrying only the given scores (ranked as passed).
|
||||
func plays(scores ...int) []engine.MoveRecord {
|
||||
out := make([]engine.MoveRecord, len(scores))
|
||||
|
||||
Reference in New Issue
Block a user