diff --git a/backend/README.md b/backend/README.md index 400c56f..ed6fc53 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 **0–90 s** (so **90–180 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 diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index bcdec97..90ab3a2 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -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. diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 4e7f733..75b472f 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -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 diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index c653674..5e04982 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -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) + } +} diff --git a/backend/internal/robot/driver.go b/backend/internal/robot/driver.go index edac356..aa2b33b 100644 --- a/backend/internal/robot/driver.go +++ b/backend/internal/robot/driver.go @@ -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 diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index a6c9736..07afa93 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -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 { diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go index 84ff7fd..f3f5f85 100644 --- a/backend/internal/robot/strategy_test.go +++ b/backend/internal/robot/strategy_test.go @@ -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)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6a0fc3a..9595419 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0a5130c..43d9591 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index cc7ae87..d15ee77 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -164,7 +164,9 @@ nudge) приходят от бота **этой партии** — по язы поддавки, время от времени делает один ход вопреки этому плану — неожиданный отрыв или слабый ход, — но к концу партии (когда мешок пуст) держится плана, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза, -подстроенная под день игрока. На nudge отвечает за несколько минут и +подстроенная под день игрока; когда партия уже явно решена и обе стороны только пасуют, +он перестаёт её затягивать — отвечает на вынужденные пасы примерно в темпе самого игрока, +а не после долгого раздумья. На nudge отвечает за несколько минут и сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее языку партии — каждую партию новое, из широкого международного пула реальных имён и никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном