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
+5 -1
View File
@@ -1252,7 +1252,11 @@ an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lo
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
creates one through the in-browser engine — the same game screen then drives it, the robot replying creates one through the in-browser engine — the same game screen then drives it, the robot replying
locally; online-only affordances (the Stats tab, the friend/random options in New Game) are disabled locally; online-only affordances (the Stats tab, the friend/random options in New Game) are disabled
or hidden. To have data ready before the switch, the **Profile advertises the current dictionary or hidden. A local `vs_ai` hint is unlimited and wallet-free but idle-gated (unlocked ~30 min into a
stuck turn); the gate is enforced **client-side against a monotonic clock** (`performance.now()`,
`lib/hints`), never a wall-clock timestamp — a device clock the player controls must not be able to
open or freeze it, so the wait is session-scoped (a reload restarts it). An online `vs_ai` game will
gate the same way but from the server's clock (a follow-up). To have data ready before the switch, the **Profile advertises the current dictionary
version per variant** (`dict_versions`, version per variant** (`dict_versions`,
filled from the registry on the existing cold-start profile request — no extra round-trip), and an filled from the registry on the existing cold-start profile request — no extra round-trip), and an
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
+8 -1
View File
@@ -215,7 +215,14 @@ unlimited and offers a complaint on any result; for a word it finds, it also lin
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
English) to look it up. Hints are governed per game — English) to look it up. Hints are governed per game —
whether they are allowed and how many each player starts with — and draw on a whether they are allowed and how many each player starts with — and draw on a
personal hint wallet once the per-game allowance is spent. The game ends when the personal hint wallet once the per-game allowance is spent. Against the robot
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
on a turn** (timed from the robot's last move; the very first move, before the robot
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
tap shows how long remains; the lock lifts live at the mark. The wait uses a steady
in-app timer, not the device clock — changing the clock cannot open or freeze it
(though a reload restarts the wait). The game ends when the
bag empties and a player clears their rack, after 6 consecutive scoreless turns, bag empties and a player clears their rack, after 6 consecutive scoreless turns,
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
24 hours): a missed turn auto-resigns, except while the player is inside their 24 hours): a missed turn auto-resigns, except while the player is inside their
+8 -1
View File
@@ -222,7 +222,14 @@ e-mail) либо ввод фразы. Активные игры форфейтя
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками чтобы его посмотреть. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
замок снимается вживую в нужный момент. Отсчёт идёт по ровному внутриигровому таймеру, а не
по системным часам — их перевод не откроет и не заморозит гейт (но перезагрузка отсчёт
начинает заново). Партия
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа): ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
+7
View File
@@ -60,6 +60,9 @@ test.describe('offline mode', () => {
await page.locator('button.invite').click(); await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
// The human's first move is not idle-gated: the hint is available at once (no 🔒 lock badge).
await expect(page.locator('.lock')).toHaveCount(0);
// The human plays WAY horizontally across the centre (7,5)-(7,7). // The human plays WAY horizontally across the centre (7,5)-(7,7).
await placeTile(page, 'W', 7, 5); await placeTile(page, 'W', 7, 5);
await placeTile(page, 'A', 7, 6); await placeTile(page, 'A', 7, 6);
@@ -74,6 +77,10 @@ test.describe('offline mode', () => {
}).toPass({ timeout: 15000 }); }).toPass({ timeout: 15000 });
const filled = await page.locator('[data-cell].filled').count(); const filled = await page.locator('[data-cell].filled').count();
// Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint
// button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here).
await expect(page.locator('.lock')).toBeVisible();
// Reload: the hash router restores the /game/<id> route and the local game replays from // Reload: the hash router restores the /game/<id> route and the local game replays from
// IndexedDB with every committed tile intact. // IndexedDB with every committed tile intact.
await page.reload(); await page.reload();
+7 -6
View File
@@ -20,12 +20,13 @@ import { join } from 'node:path';
const DIST = 'dist'; const DIST = 'dist';
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview // 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 // 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 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 // the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
// dict loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped // countdown toast live in the always-loaded game screen (Game.svelte). The heavy parts — the dict
// CSS lands in the CSS chunk, not this JS budget. // loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped CSS
const BUDGET = { app: 114, shared: 30, landing: 5 }; // 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 // 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. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+71 -10
View File
@@ -23,7 +23,7 @@
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; 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 { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk'; import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; 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 // 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). // hint was spent in another game (see lib/hints).
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0)); 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 // 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 // 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). // exchange regardless (engine rejects when bag.Len() < rules.RackSize).
@@ -854,6 +886,16 @@
} }
} }
async function doHint() { 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 { try {
const h = await source.hint(id); const h = await source.hint(id);
if (h.move.tiles.length && view) { if (h.move.tiles.length && view) {
@@ -1496,15 +1538,25 @@
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}> <button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}>
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span> <span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
</button> </button>
<TapConfirm {#if view?.game.vsAi}
triggerClass="tab" <!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
label={t('game.hint')} idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
disabled={busy || !isMyTurn || !netReady || hintCount <= 0} lifts live at the unlock moment. -->
onconfirm={doHint} <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="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span> <span class="lbl">{t('game.hint')}</span>
<span class="lbl">{t('game.hint')}</span> </button>
</TapConfirm> {: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} {#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}> <button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span> <span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
@@ -2052,4 +2104,13 @@
.scoreboard.flat { .scoreboard.flat {
cursor: default; 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> </style>
+30 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { hintsLeft } from './hints'; import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
// view carries only the two fields hintsLeft reads. // view carries only the two fields hintsLeft reads.
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance }); const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
@@ -30,3 +30,32 @@ describe('hintsLeft', () => {
expect(hintsLeft(view(1, 0), -5)).toBe(1); 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); const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
return allowance + Math.max(0, 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.draw': 'Exchange/Pass',
'game.shuffle': 'Shuffle', 'game.shuffle': 'Shuffle',
'game.hint': 'Hint', 'game.hint': 'Hint',
'game.hintLockedIn': 'Available in {n} min.',
'game.chat': 'Chat', 'game.chat': 'Chat',
'game.checkWord': 'Check word', 'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary', 'game.dictionary': 'Dictionary',
+1
View File
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
'game.draw': 'Обмен/Пас', 'game.draw': 'Обмен/Пас',
'game.shuffle': 'Перемешать', 'game.shuffle': 'Перемешать',
'game.hint': 'Подсказка', 'game.hint': 'Подсказка',
'game.hintLockedIn': 'Будет доступно через {n} мин.',
'game.chat': 'Чат', 'game.chat': 'Чат',
'game.checkWord': 'Проверить слово', 'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь', '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 }); 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', () => { describe('local game serialize + replay', () => {
it('reconstructs a mid-game position by replay', () => { it('reconstructs a mid-game position by replay', () => {
-4
View File
@@ -38,8 +38,6 @@ export interface LocalGameRecord {
status: 'active' | 'finished'; status: 'active' | 'finished';
createdAtUnix: number; createdAtUnix: number;
updatedAtUnix: 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). */ /** The record fields the caller owns (the engine supplies the rest). */
@@ -48,7 +46,6 @@ export interface RecordMeta {
seats: Seat[]; seats: Seat[];
createdAtUnix: number; createdAtUnix: number;
updatedAtUnix: number; updatedAtUnix: number;
robotLastMoveAtUnix: number;
} }
/** /**
@@ -70,7 +67,6 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
status: game.isOver ? 'finished' : 'active', status: game.isOver ? 'finished' : 'active',
createdAtUnix: meta.createdAtUnix, createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix, 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); 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); 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 () => { 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 }; 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 * 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. * 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 })), seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })),
createdAtUnix: now, createdAtUnix: now,
updatedAtUnix: now, updatedAtUnix: now,
robotLastMoveAtUnix: now,
}); });
const entry: Live = { game, record }; const entry: Live = { game, record };
this.live.set(opts.id, entry); this.live.set(opts.id, entry);
@@ -191,10 +187,10 @@ export class LocalSource implements GameLoopSource {
} }
async hint(gameId: string): Promise<HintResult> { 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); 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); const top = this.topMove(entry.game);
if (!top) throw new GatewayError('no_hint'); if (!top) throw new GatewayError('no_hint');
return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 }; 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)); 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 if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange));
else out.push(g.pass()); else out.push(g.pass());
entry.record.robotLastMoveAtUnix = nowUnix();
} }
return out; return out;
} }
@@ -306,7 +301,6 @@ export class LocalSource implements GameLoopSource {
seats: entry.record.seats, seats: entry.record.seats,
createdAtUnix: entry.record.createdAtUnix, createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(), updatedAtUnix: nowUnix(),
robotLastMoveAtUnix: entry.record.robotLastMoveAtUnix,
}); });
await saveLocalGame(entry.record); await saveLocalGame(entry.record);
} }