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

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:
Ilia Denisov
2026-06-19 12:48:39 +02:00
parent 0eb1e0ef47
commit f3768d20f2
10 changed files with 342 additions and 7 deletions
+79 -1
View File
@@ -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.