diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 02193d0..e7f2cb9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 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 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`, 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..cae0761 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 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 **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, 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..fd53471 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -222,7 +222,15 @@ 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..4b30d47 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,10 +77,17 @@ 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(); await expect(page.locator('[data-cell]').first()).toBeVisible(); 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(); }); }); 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..2025966 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,22 @@ // 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 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 // 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). @@ -329,7 +345,7 @@ // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply // the move as a delta with no fetch. 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) { // 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). @@ -854,6 +870,15 @@ } } 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 { const h = await source.hint(id); if (h.move.tiles.length && view) { @@ -1496,15 +1521,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}