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
+71 -10
View File
@@ -23,7 +23,7 @@
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints';
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from '../lib/hints';
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
@@ -167,6 +167,38 @@
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
// hint was spent in another game (see lib/hints).
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
// A vs_ai game's hint is unlimited and wallet-free, but idle-gated (an anti-frustration aid: it
// unlocks only after the player has been stuck ~30 min on a turn). The wait is measured with a
// MONOTONIC clock (performance.now()), never a wall-clock timestamp — a device clock change (by the
// player or an auto-sync) must not open or freeze the gate. hintGateStart is that clock when the
// current turn's wait began (null = open: the human's first move, or the robot's turn); monoNow
// ticks so the 🔒 lifts live at the mark. A reload restarts the wait — the monotonic clock is
// session-scoped, and there is no tamper-proof way to carry idle time across a relaunch.
let hintGateStart = $state<number | null>(null);
let hintArmedFor = -1; // the moveCount the current wait was armed for (plain: not a reactive dep)
let monoNow = $state(performance.now());
const hintRemaining = $derived(view?.game.vsAi ? hintGateRemainingMs(hintGateStart, monoNow) : 0);
const hintGated = $derived(hintRemaining > 0);
// Arm the wait when it becomes the human's turn after the robot has moved; a human-first opening
// (no move yet, moveCount 0) is open at once. Re-arms only when the turn actually changes.
$effect(() => {
const mc = view?.game.moveCount ?? 0;
if (!view?.game.vsAi || !isMyTurn) {
hintGateStart = null;
hintArmedFor = -1;
return;
}
if (hintArmedFor !== mc) {
hintArmedFor = mc;
hintGateStart = mc === 0 ? null : performance.now();
}
});
// Tick the monotonic clock while a vs_ai game is open, so the gate re-evaluates and unlocks live.
$effect(() => {
if (!view?.game.vsAi) return;
const iv = setInterval(() => (monoNow = performance.now()), 10_000);
return () => clearInterval(iv);
});
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
@@ -854,6 +886,16 @@
}
}
async function doHint() {
// vs_ai: the idle gate replaces the wallet. While it is still closed, a tap only explains when the
// hint unlocks (no wallet is ever spent) — matching the 🔒 badge. Measured fresh off the monotonic
// clock at tap time.
if (view?.game.vsAi) {
const remaining = hintGateRemainingMs(hintGateStart, performance.now());
if (remaining > 0) {
showToast(t('game.hintLockedIn', { n: hintLockMinutes(remaining) }), 'info');
return;
}
}
try {
const h = await source.hint(id);
if (h.move.tiles.length && view) {
@@ -1496,15 +1538,25 @@
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}>
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{#if view?.game.vsAi}
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
lifts live at the unlock moment. -->
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</button>
{:else}
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{/if}
{#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
@@ -2052,4 +2104,13 @@
.scoreboard.flat {
cursor: default;
}
/* The vs_ai hint's idle lock: a small 🔒 at the icon-square corner (the .sq is positioned by the
shared TabBar rule). No count pill — the hint is unlimited; the lock alone marks the gate. */
.sq .lock {
position: absolute;
top: -4px;
right: -4px;
font-size: 0.62rem;
line-height: 1;
}
</style>
+30 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { hintsLeft } from './hints';
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
// view carries only the two fields hintsLeft reads.
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
@@ -30,3 +30,32 @@ describe('hintsLeft', () => {
expect(hintsLeft(view(1, 0), -5)).toBe(1);
});
});
describe('hintGateRemainingMs (the vs_ai idle-hint gate, monotonic)', () => {
it('is 0 when the gate is open — a null start (the human first move, or a non-gated game)', () => {
expect(hintGateRemainingMs(null, 5000, 1000)).toBe(0);
});
it('is 0 once the idle window has fully elapsed', () => {
expect(hintGateRemainingMs(0, 1000, 1000)).toBe(0);
expect(hintGateRemainingMs(0, 1500, 1000)).toBe(0);
});
it('counts down the monotonic time remaining while still gated (only the delta matters)', () => {
expect(hintGateRemainingMs(0, 400, 1000)).toBe(600);
expect(hintGateRemainingMs(1000, 1400, 1000)).toBe(600);
});
});
describe('hintLockMinutes (the toast N, rounded up)', () => {
it('rounds up to whole minutes, so a still-closed gate never reads 0', () => {
expect(hintLockMinutes(1)).toBe(1);
expect(hintLockMinutes(60_000)).toBe(1);
expect(hintLockMinutes(61_000)).toBe(2);
expect(hintLockMinutes(29 * 60_000)).toBe(29);
});
it('is 0 when nothing remains', () => {
expect(hintLockMinutes(0)).toBe(0);
});
});
+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);
}
+1
View File
@@ -99,6 +99,7 @@ export const en = {
'game.draw': 'Exchange/Pass',
'game.shuffle': 'Shuffle',
'game.hint': 'Hint',
'game.hintLockedIn': 'Available in {n} min.',
'game.chat': 'Chat',
'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary',
+1
View File
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
'game.draw': 'Обмен/Пас',
'game.shuffle': 'Перемешать',
'game.hint': 'Подсказка',
'game.hintLockedIn': 'Будет доступно через {n} мин.',
'game.chat': 'Чат',
'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь',
+1 -1
View File
@@ -46,7 +46,7 @@ function makeGame(seed: bigint): LocalGame {
return new LocalGame({ variant: 'scrabble_en', version: 'sample', seed, players: 2, dawg, multipleWords: true });
}
const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2, robotLastMoveAtUnix: 3 };
const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2 };
describe('local game serialize + replay', () => {
it('reconstructs a mid-game position by replay', () => {
-4
View File
@@ -38,8 +38,6 @@ export interface LocalGameRecord {
status: 'active' | 'finished';
createdAtUnix: number;
updatedAtUnix: number;
/** Unix seconds of the robot's most recent move — the local hint gate (>30 min) reads this. */
robotLastMoveAtUnix: number;
}
/** The record fields the caller owns (the engine supplies the rest). */
@@ -48,7 +46,6 @@ export interface RecordMeta {
seats: Seat[];
createdAtUnix: number;
updatedAtUnix: number;
robotLastMoveAtUnix: number;
}
/**
@@ -70,7 +67,6 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
status: game.isOver ? 'finished' : 'active',
createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix,
robotLastMoveAtUnix: meta.robotLastMoveAtUnix,
};
}
+8 -2
View File
@@ -59,9 +59,15 @@ describe('LocalSource', () => {
expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true);
});
it('gates the hint until 30 minutes since the robot last moved', async () => {
it('does not wall-clock-gate the hint (the idle gate is client-side and monotonic)', async () => {
const src = await newSource('local:g3', 7n);
await expect(src.hint('local:g3')).rejects.toMatchObject({ code: 'hint_not_ready' });
// hint() serves the engine's top move (or no_hint when the tiny sample dictionary has none for
// this rack), but never the old timestamp gate 'hint_not_ready' — that moved to the client
// (lib/hints, measured with performance.now()) so a device clock change cannot defeat it.
await src.hint('local:g3').then(
(h) => expect(h.move).toBeDefined(),
(e) => expect((e as { code: string }).code).toBe('no_hint'),
);
});
it('records decoded moves in the history', async () => {
+3 -9
View File
@@ -34,9 +34,6 @@ import type { Move } from '../dict/validate';
export { LOCAL_ID_PREFIX, isLocalGameId };
/** HINT_GATE_MS is the idle time since the robot's last move before an offline hint unlocks. */
export const HINT_GATE_MS = 30 * 60 * 1000;
/**
* GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real
* gateway satisfies it structurally; the local source implements it for offline play.
@@ -123,7 +120,6 @@ export class LocalSource implements GameLoopSource {
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })),
createdAtUnix: now,
updatedAtUnix: now,
robotLastMoveAtUnix: now,
});
const entry: Live = { game, record };
this.live.set(opts.id, entry);
@@ -191,10 +187,10 @@ export class LocalSource implements GameLoopSource {
}
async hint(gameId: string): Promise<HintResult> {
// The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints):
// a wall-clock timestamp here could be defeated by changing the device clock. This just serves
// the engine's top-ranked move; the client only calls it once the gate is open.
const entry = await this.load(gameId);
if (nowUnix() * 1000 - entry.record.robotLastMoveAtUnix * 1000 < HINT_GATE_MS) {
throw new GatewayError('hint_not_ready');
}
const top = this.topMove(entry.game);
if (!top) throw new GatewayError('no_hint');
return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 };
@@ -280,7 +276,6 @@ export class LocalSource implements GameLoopSource {
if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles));
else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange));
else out.push(g.pass());
entry.record.robotLastMoveAtUnix = nowUnix();
}
return out;
}
@@ -306,7 +301,6 @@ export class LocalSource implements GameLoopSource {
seats: entry.record.seats,
createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(),
robotLastMoveAtUnix: entry.record.robotLastMoveAtUnix,
});
await saveLocalGame(entry.record);
}