Merge pull request 'feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4)' (#207) from feature/offline-hint-gate into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s

This commit was merged in pull request #207.
This commit is contained in:
2026-07-06 21:46:18 +00:00
16 changed files with 225 additions and 42 deletions
+7 -1
View File
@@ -1252,7 +1252,13 @@ 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 a **persisted wall-clock unlock instant** (`hintUnlockAtMs` on the record +
the game view, stamped from the robot's reply, carried on the move delta), so the wait survives a
relaunch — but it is **sanitised on read** (capped at `now + window`, `lib/hints` + `source.ts`) so a
device clock the player sets **back** cannot push the unlock away and freeze the gate; a clock set
**forward** merely opens the hint early, harmless for a solo game. 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
+10 -1
View File
@@ -215,7 +215,16 @@ 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 **persists across
leaving and reopening the app**, so a stuck turn is not forgotten. It stays robust to a
device clock change: the remaining is capped at the window, so a clock set back cannot
freeze it, and a clock set forward merely opens the hint early — harmless in a solo game.
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
+9 -1
View File
@@ -222,7 +222,15 @@ e-mail) либо ввод фразы. Активные игры форфейтя
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками чтобы его посмотреть. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
замок снимается вживую в нужный момент. Отсчёт **переживает выход и повторный вход в
приложение**, так что застрявший ход не забывается. И он устойчив к переводу часов: остаток
ограничен окном, поэтому перевод назад не заморозит гейт, а перевод вперёд просто откроет
подсказку раньше — безвредно в соло-игре. Партия
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа): ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
+10
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,10 +77,17 @@ 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();
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('[data-cell].filled')).toHaveCount(filled); await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload —
// the wait was not reset by relaunching.
await expect(page.locator('.lock')).toBeVisible();
}); });
}); });
+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.
+55 -11
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,22 @@
// 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 unlock is a persisted
// wall-clock instant carried on the game view (`hintUnlockAtMs`, stamped by the source off the
// robot's reply and kept fresh by the move delta), so the wait survives leaving and reopening the
// app. It is sanitised on read (source + lib/hints cap it at now + the window) so a device clock
// set BACK cannot freeze the gate; a clock set forward just opens the hint early, harmless for a
// solo game. `now` ticks so the 🔒 lifts live at the mark. 0 = open (the human's first move).
let now = $state(Date.now());
const hintUnlockAt = $derived(view?.game.vsAi ? (view.hintUnlockAtMs ?? 0) : 0);
const hintRemaining = $derived(hintGateRemainingMs(hintUnlockAt, now));
const hintGated = $derived(hintRemaining > 0);
$effect(() => {
if (!view?.game.vsAi) return;
const iv = setInterval(() => (now = Date.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).
@@ -329,7 +345,7 @@
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
// the move as a delta with no fetch. // the move as a delta with no fetch.
if (placement.pending.length > 0) void load(); if (placement.pending.length > 0) void load();
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen })); else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen, hintUnlockAtMs: e.hintUnlockAtMs }));
} else if (e.kind === 'your_turn' && e.gameId === id) { } else if (e.kind === 'your_turn' && e.gameId === id) {
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch // The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
// only if we missed the move (our cached count trails the event's). // only if we missed the move (our cached count trails the event's).
@@ -854,6 +870,15 @@
} }
} }
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 at tap time.
if (view?.game.vsAi) {
const remaining = hintGateRemainingMs(hintUnlockAt, Date.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 +1521,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 +2087,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>
+10 -2
View File
@@ -11,6 +11,9 @@ export interface MoveDelta {
move?: MoveRecord; move?: MoveRecord;
game?: GameView; game?: GameView;
bagLen: number; bagLen: number;
/** For a vs_ai game, the refreshed idle-hint unlock instant (the robot's reply re-arms the gate),
* so the cached view stays current without a refetch; absent leaves the prior value. */
hintUnlockAtMs?: number;
} }
/** /**
@@ -50,7 +53,12 @@ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): De
if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed
// The actor's own move changed their rack (a draw), which opponent_moved does not carry. // The actor's own move changed their rack (a draw), which opponent_moved does not carry.
if (d.move.player === cached.view.seat) return { refetch: true }; if (d.move.player === cached.view.seat) return { refetch: true };
const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen }; const view: StateView = {
...cached.view,
game: d.game,
bagLen: d.bagLen,
hintUnlockAtMs: d.hintUnlockAtMs ?? cached.view.hintUnlockAtMs,
};
return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false }; return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false };
} }
@@ -79,7 +87,7 @@ export function applyGameOver(cached: CachedGame | undefined, game: GameView | u
*/ */
export function advanceCached(cached: CachedGame | undefined, e: PushEvent): CachedGame | undefined { export function advanceCached(cached: CachedGame | undefined, e: PushEvent): CachedGame | undefined {
if (e.kind === 'opponent_moved') { if (e.kind === 'opponent_moved') {
return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache; return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen, hintUnlockAtMs: e.hintUnlockAtMs }).cache;
} }
if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache; if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache;
if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state); if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state);
+36 -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,38 @@ 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)', () => {
it('is 0 when the gate is open — no unlock time (the human first move, or a non-gated game)', () => {
expect(hintGateRemainingMs(undefined, 5000, 1000)).toBe(0);
expect(hintGateRemainingMs(0, 5000, 1000)).toBe(0);
});
it('is 0 once the unlock instant has passed (the gate is open)', () => {
expect(hintGateRemainingMs(1000, 1000, 1000)).toBe(0);
expect(hintGateRemainingMs(1000, 1500, 1000)).toBe(0);
});
it('is the wall-clock time remaining while still gated', () => {
expect(hintGateRemainingMs(1000, 400, 1000)).toBe(600);
});
it('clamps to the window, so a clock set back cannot freeze the gate above the window', () => {
// now shoved far into the past (device clock moved back): the raw remaining would exceed the
// window, but the cap keeps it at the window so the wait stays bounded and self-heals.
expect(hintGateRemainingMs(1000, -100_000, 1000)).toBe(1000);
});
});
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);
});
});
+30
View File
@@ -24,3 +24,33 @@ 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. hintUnlockAtMs is the wall-clock instant the hint opens (the robot's last
* move plus the idle window), persisted so the wait survives leaving and reopening the app; 0 or
* undefined means the gate is open (the human's first move, or a non-gated game).
*
* The result is CLAMPED to the window. A wall-clock timestamp is the only way to carry idle time
* across an app relaunch, but a device clock the player changes (or an auto-sync) could push the
* unlock far into the future and freeze the gate; capping the remaining at the window means the wait
* is never longer than intended and self-heals (the caller re-reads a sanitised value on load). A
* clock moved forward simply opens the hint early — harmless for this solo anti-frustration aid.
*/
export function hintGateRemainingMs(
hintUnlockAtMs: number | undefined,
nowMs: number,
gateMs: number = HINT_GATE_MS,
): number {
if (!hintUnlockAtMs) return 0;
return Math.min(gateMs, Math.max(0, hintUnlockAtMs - nowMs));
}
/** 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, hintUnlockAtMs: 0 };
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', () => {
+7 -4
View File
@@ -38,8 +38,11 @@ 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. */ /** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0
robotLastMoveAtUnix: number; * while open (no robot move yet). Persisted so the wait survives leaving and reopening the app;
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it
* see lib/hints. */
hintUnlockAtMs: 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 +51,7 @@ export interface RecordMeta {
seats: Seat[]; seats: Seat[];
createdAtUnix: number; createdAtUnix: number;
updatedAtUnix: number; updatedAtUnix: number;
robotLastMoveAtUnix: number; hintUnlockAtMs: number;
} }
/** /**
@@ -70,7 +73,7 @@ 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, hintUnlockAtMs: meta.hintUnlockAtMs,
}; };
} }
+8 -2
View File
@@ -72,9 +72,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 () => {
+28 -11
View File
@@ -14,6 +14,7 @@ import { getLocalGame, saveLocalGame, listLocalGames, deleteLocalGame } from './
import { RULESETS } from './ruleset'; import { RULESETS } from './ruleset';
import { getDawg } from '../dict'; import { getDawg } from '../dict';
import { setAlphabet, hasAlphabet } from '../alphabet'; import { setAlphabet, hasAlphabet } from '../alphabet';
import { HINT_GATE_MS } from '../hints';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy'; import { decide } from '../robot/strategy';
import { GatewayError, type PlacedTile } from '../client'; import { GatewayError, type PlacedTile } from '../client';
@@ -35,9 +36,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.
@@ -124,7 +122,7 @@ 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, hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once
}); });
const entry: Live = { game, record }; const entry: Live = { game, record };
this.live.set(opts.id, entry); this.live.set(opts.id, entry);
@@ -192,10 +190,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 };
@@ -281,17 +279,21 @@ 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();
} }
// Arm the idle hint gate from the robot's reply: a wall-clock unlock time, persisted so the wait
// survives a relaunch. Read back through a sanitiser (lib/hints / stateView) so a clock set back
// cannot freeze it.
if (out.length > 0) entry.record.hintUnlockAtMs = Date.now() + HINT_GATE_MS;
return out; return out;
} }
private emitRobot(entry: Live, moves: LocalMove[]): void { private emitRobot(entry: Live, moves: LocalMove[]): void {
const set = this.listeners.get(entry.record.id); const set = this.listeners.get(entry.record.id);
if (!set) return; if (!set) return;
const hintUnlockAtMs = this.hintUnlock(entry);
for (const m of moves) { for (const m of moves) {
const game = this.gameView(entry); const game = this.gameView(entry);
const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength }; const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength, hintUnlockAtMs };
for (const cb of set) cb(ev); for (const cb of set) cb(ev);
} }
if (entry.game.isOver) { if (entry.game.isOver) {
@@ -307,7 +309,7 @@ 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, hintUnlockAtMs: entry.record.hintUnlockAtMs,
}); });
await saveLocalGame(entry.record); await saveLocalGame(entry.record);
} }
@@ -340,10 +342,25 @@ export class LocalSource implements GameLoopSource {
// --- shape builders -------------------------------------------------------- // --- shape builders --------------------------------------------------------
// The vs_ai idle-hint unlock instant, sanitised on read: capped at now + the window so a device
// clock set back cannot push it far ahead and freeze the gate (the client counts down from here).
// 0 while open (a human-first opening, no robot move yet).
private hintUnlock(entry: Live): number {
return entry.record.hintUnlockAtMs ? Math.min(entry.record.hintUnlockAtMs, Date.now() + HINT_GATE_MS) : 0;
}
private stateView(entry: Live): StateView { private stateView(entry: Live): StateView {
const seat = this.humanSeat(entry); const seat = this.humanSeat(entry);
const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
return { game: this.gameView(entry), seat, rack, bagLen: entry.game.bagLength, hintsRemaining: 1, walletBalance: 0 }; return {
game: this.gameView(entry),
seat,
rack,
bagLen: entry.game.bagLength,
hintsRemaining: 1,
walletBalance: 0,
hintUnlockAtMs: this.hintUnlock(entry),
};
} }
// Offline there is no server to send the per-variant alphabet table (letters + tile values) the // Offline there is no server to send the per-variant alphabet table (letters + tile values) the
+5 -1
View File
@@ -82,6 +82,10 @@ export interface StateView {
bagLen: number; bagLen: number;
hintsRemaining: number; hintsRemaining: number;
walletBalance: number; walletBalance: number;
/** For a vs_ai game, the wall-clock ms at which the idle hint unlocks persisted so the wait
* survives a relaunch, and sanitised on read so a device clock set back cannot freeze it or
* 0/undefined while open (the human's first move, or a non-vs_ai game). See lib/hints. */
hintUnlockAtMs?: number;
} }
export interface MoveResult { export interface MoveResult {
@@ -421,7 +425,7 @@ export interface GameList {
*/ */
export type PushEvent = export type PushEvent =
| { kind: 'your_turn'; gameId: string; deadlineUnix: number; opponentName: string; moveCount: number } | { kind: 'your_turn'; gameId: string; deadlineUnix: number; opponentName: string; moveCount: number }
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number } | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number; hintUnlockAtMs?: number }
| { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView } | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
| { kind: 'chat_message'; message: ChatMessage } | { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string; senderName: string } | { kind: 'nudge'; gameId: string; fromUserId: string; senderName: string }