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
+26
View File
@@ -24,3 +24,29 @@ export function hintsLeft(
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
return allowance + Math.max(0, walletBalance);
}
/** HINT_GATE_MS is the idle time a vs_ai player must be stuck on a turn before a hint unlocks. */
export const HINT_GATE_MS = 30 * 60 * 1000;
/**
* hintGateRemainingMs returns how long (in milliseconds) until a vs_ai game's idle hint unlocks — 0
* once it is available. It is measured from a MONOTONIC clock (performance.now()), never a wall-clock
* timestamp, so a device clock change — by the user or an auto-sync — can neither open nor freeze the
* gate. gateStartMono is the performance.now() captured when the current turn's wait began; null
* means the gate is open (the human's first move, or not a gated game). The vs_ai hint is unlimited
* and wallet-free; this idle gate is an anti-frustration aid that unlocks only after a stuck turn.
*/
export function hintGateRemainingMs(
gateStartMono: number | null,
monoNow: number,
gateMs: number = HINT_GATE_MS,
): number {
if (gateStartMono === null) return 0;
return Math.max(0, gateMs - (monoNow - gateStartMono));
}
/** hintLockMinutes rounds a remaining-milliseconds gap up to whole minutes for the "available in N
* min." toast, so a gate that is still closed never reads 0. */
export function hintLockMinutes(remainingMs: number): number {
return Math.ceil(remainingMs / 60000);
}