From f3768d20f2d7c67d7c521e711b2e66a374538b74 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 12:48:39 +0200 Subject: [PATCH] feat(robot): shrink endgame think time when both sides pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/README.md | 5 +- backend/internal/game/store.go | 80 +++++++++++++++- backend/internal/game/types.go | 9 ++ backend/internal/inttest/robot_test.go | 121 ++++++++++++++++++++++++ backend/internal/robot/driver.go | 11 ++- backend/internal/robot/strategy.go | 50 +++++++++- backend/internal/robot/strategy_test.go | 53 +++++++++++ docs/ARCHITECTURE.md | 12 ++- docs/FUNCTIONAL.md | 4 +- docs/FUNCTIONAL_ru.md | 4 +- 10 files changed, 342 insertions(+), 7 deletions(-) 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, когда игрок надолго пропал. Носит человекоподобное имя, подходящее языку партии — каждую партию новое, из широкого международного пула реальных имён и никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном