Merge pull request 'feat(robot): shrink endgame think time when both sides pass' (#86) from feature/robot-endgame-shrink into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s

This commit was merged in pull request #86.
This commit is contained in:
2026-06-19 14:00:59 +00:00
10 changed files with 342 additions and 7 deletions
+4 -1
View File
@@ -68,7 +68,10 @@ win (≈ 40%), targets a small score margin — with an occasional off-strategy
none as the bag empties — and times its moves with a move-number-aware
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra
state. A background **reaper** seats a pooled robot (matching the game's language) in any open
state. In a dead-drawn endgame — the last two journal moves are both passes, so the robot is bound
to pass again — it shortens that delay to a `[0.8, 1.5]×` band around the human's last-move think
time (the gap between the last two moves), clamped to `[30 s, 8 min]` and `min`-ed with the normal
delay, so a decided game is not dragged out while the robot never moves slower than usual. A background **reaper** seats a pooled robot (matching the game's language) in any open
game whose wait window — a fixed **90 s** plus a random **090 s** (so **90180 s**) — has
elapsed, and the waiting starter is told an opponent took the seat by an in-app
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
+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.
+9
View File
@@ -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
+121
View File
@@ -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)
}
}
+10 -1
View File
@@ -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 -1
View File
@@ -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 {
+53
View File
@@ -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))
+11 -1
View File
@@ -449,11 +449,21 @@ disguised robot stays indistinguishable from a person.
band; it proactively nudges the idle human on a **lengthening, randomized schedule** — the
first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long
wait gets a handful of increasingly-spaced nudges rather than an hourly stream.
- **Dead-endgame timing**: once the **two most recent moves are both passes**, the board and the
robot's rack are frozen and it is bound to pass again, so the robot drops the long late-game
think time and answers on a **shortened schedule scaled to the human's own last (pass) think
time** — a uniform sample in **[0.8, 1.5]×** of it, clamped to **[30 s, 8 min]** and taken as a
**min** with the normal delay, so it never slows down. A slow human collapses to the 8-min cap (a
decided game is not dragged out); a fast human is tracked, with the floor keeping the robot from
passing suspiciously instantly. The anchor (the gap between the last two journal entries) reads the
move journal only — no schema change — stays deterministic from the seed, and still defers to the
sleep window.
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
authoritative balance metric (target ≈ 40% robot wins) — and a
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
the seed) and, on the robot's turn, its deterministic **next-move ETA**.
the seed) and, on the robot's turn, its deterministic **next-move ETA** (the normal-schedule
upper bound — a dead-endgame pass may land sooner).
## 8. Lobby & social
+3 -1
View File
@@ -158,7 +158,9 @@ wins most games), aims for a close score rather than crushing or throwing the ga
now and then plays a single move against that plan — a surprise lead or a slack move —
yet holds to the plan once the bag empties, and plays at a human pace — short thinking
times for most moves, the occasional long one, and a night-time pause that tracks the
player's own day. It answers a nudge
player's own day; once a game is clearly decided and both sides are only passing, it stops
dragging it out, answering its forced passes at roughly the player's own pace rather than
after a long deliberation. It answers a nudge
within a few minutes and nudges back when the player has been away a long time. It
carries a human-like, language-appropriate name — a fresh one each game, drawn from a wide
international pool of real names and handles, so the arena feels populated by many different
+3 -1
View File
@@ -164,7 +164,9 @@ nudge) приходят от бота **этой партии** — по язы
поддавки, время от времени делает один ход вопреки этому плану — неожиданный отрыв или
слабый ход, — но к концу партии (когда мешок пуст) держится плана, и ходит с
человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза,
подстроенная под день игрока. На nudge отвечает за несколько минут и
подстроенная под день игрока; когда партия уже явно решена и обе стороны только пасуют,
он перестаёт её затягивать — отвечает на вынужденные пасы примерно в темпе самого игрока,
а не после долгого раздумья. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
языку партии — каждую партию новое, из широкого международного пула реальных имён и
никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном