feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s

A vs_ai hint is now unlimited and wallet-free, but idle-gated as an anti-frustration
aid: it unlocks only after the player has been stuck ~30 min on a turn (timed from the
robot's last move; the human's first move, before the robot has played, is exempt).
While gated the hint button carries a small lock badge and a tap shows the remaining
minutes ('Available in N min.'); the lock lifts live at the mark.

The gate is enforced CLIENT-SIDE against a MONOTONIC clock (performance.now()), never a
wall-clock timestamp: a device clock the player controls (or an auto-sync) must not be
able to open or freeze it. The wait is therefore session-scoped -- a reload restarts it
(there is no tamper-proof way to carry idle time across a relaunch without a wall clock).
An online vs_ai game will gate the same way but from the server's clock (a follow-up).

- lib/hints.ts: HINT_GATE_MS + pure hintGateRemainingMs (monotonic) + hintLockMinutes;
  removed the wall-clock hintRemainingMs. Unit-tested red->green.
- Game.svelte: the monotonic gate (hintGateStart/monoNow, armed on the turn change), a
  vs_ai hint button (plain: no confirm, no count; lock badge + gated tap -> toast), a
  live 10s tick.
- localgame: removed the wall-clock hint gate and the now-dead robotLastMoveAtUnix field
  from source.ts/serialize.ts (the client is authoritative); hint() just serves the top
  move.
- i18n game.hintLockedIn.
- offline.spec.ts: assert the first move is un-gated and the lock arms after the robot moves.
- Docs FUNCTIONAL(+_ru) + ARCHITECTURE; bundle budget 114->115 (game-screen feature).
This commit is contained in:
Ilia Denisov
2026-07-06 22:03:27 +02:00
parent 08792dad3d
commit 42a6308261
14 changed files with 176 additions and 36 deletions
+7 -6
View File
@@ -20,12 +20,13 @@ import { join } from 'node:path';
const DIST = 'dist';
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, then to 114 for
// the offline auto-detect: the cold-start reachability check and the "no connection" dialog live in
// the boot path (app.svelte.ts / the App shell), which cannot be lazy-loaded. The heavy parts — the
// dict loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped
// CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 114, shared: 30, landing: 5 };
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
// countdown toast live in the always-loaded game screen (Game.svelte). The heavy parts — the dict
// loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped CSS
// lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 115, shared: 30, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.