From 42a63082617eac3ac3b7b7606e529cbffa1a5ffb Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 22:03:27 +0200 Subject: [PATCH] feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4) 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). --- docs/ARCHITECTURE.md | 6 +- docs/FUNCTIONAL.md | 9 ++- docs/FUNCTIONAL_ru.md | 9 ++- ui/e2e/offline.spec.ts | 7 +++ ui/scripts/bundle-size.mjs | 13 +++-- ui/src/game/Game.svelte | 81 ++++++++++++++++++++++---- ui/src/lib/hints.test.ts | 31 +++++++++- ui/src/lib/hints.ts | 26 +++++++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/localgame/serialize.test.ts | 2 +- ui/src/lib/localgame/serialize.ts | 4 -- ui/src/lib/localgame/source.test.ts | 10 +++- ui/src/lib/localgame/source.ts | 12 +--- 14 files changed, 176 insertions(+), 36 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 02193d0..5991ed6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 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 -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`, 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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 2f830a5..3f655d8 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 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 -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, 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index afb1222..0e96755 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -222,7 +222,14 @@ e-mail) либо ввод фразы. Активные игры форфейтя внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), чтобы его посмотреть. Подсказки управляются настройками партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют -личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия +личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота +(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как +анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут** +(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт +закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось; +замок снимается вживую в нужный момент. Отсчёт идёт по ровному внутриигровому таймеру, а не +по системным часам — их перевод не откроет и не заморозит гейт (но перезагрузка отсчёт +начинает заново). Партия завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа): пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего diff --git a/ui/e2e/offline.spec.ts b/ui/e2e/offline.spec.ts index cc42f6e..7b4793a 100644 --- a/ui/e2e/offline.spec.ts +++ b/ui/e2e/offline.spec.ts @@ -60,6 +60,9 @@ test.describe('offline mode', () => { await page.locator('button.invite').click(); 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). await placeTile(page, 'W', 7, 5); await placeTile(page, 'A', 7, 6); @@ -74,6 +77,10 @@ test.describe('offline mode', () => { }).toPass({ timeout: 15000 }); 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/ route and the local game replays from // IndexedDB with every committed tile intact. await page.reload(); diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 75f7f9e..43c4ba9 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -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. diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index e5cc082..a2d964f 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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(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 @@ - - 🛟{#if hintCount > 0}{hintCount}{/if} - {t('game.hint')} - + {#if view?.game.vsAi} + + + {:else} + + 🛟{#if hintCount > 0}{hintCount}{/if} + {t('game.hint')} + + {/if} {#if placement.pending.length > 0}