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.
|
||||
|
||||
Reference in New Issue
Block a user