Merge pull request 'feat(robot): shrink endgame think time when both sides pass' (#86) from feature/robot-endgame-shrink into development
This commit was merged in pull request #86.
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
+11
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -164,7 +164,9 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
поддавки, время от времени делает один ход вопреки этому плану — неожиданный отрыв или
|
||||
слабый ход, — но к концу партии (когда мешок пуст) держится плана, и ходит с
|
||||
человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза,
|
||||
подстроенная под день игрока. На nudge отвечает за несколько минут и
|
||||
подстроенная под день игрока; когда партия уже явно решена и обе стороны только пасуют,
|
||||
он перестаёт её затягивать — отвечает на вынужденные пасы примерно в темпе самого игрока,
|
||||
а не после долгого раздумья. На nudge отвечает за несколько минут и
|
||||
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
|
||||
языку партии — каждую партию новое, из широкого международного пула реальных имён и
|
||||
никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном
|
||||
|
||||
Reference in New Issue
Block a user