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
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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 часа):
|
||||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Словарь',
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user