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
|
||||
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
|
||||
|
||||
+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
|
||||
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
|
||||
|
||||
@@ -222,7 +222,14 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||
чтобы его посмотреть. Подсказки управляются настройками
|
||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
|
||||
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
|
||||
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
|
||||
замок снимается вживую в нужный момент. Отсчёт идёт по ровному внутриигровому таймеру, а не
|
||||
по системным часам — их перевод не откроет и не заморозит гейт (но перезагрузка отсчёт
|
||||
начинает заново). Партия
|
||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||
|
||||
@@ -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/<id> route and the local game replays from
|
||||
// IndexedDB with every committed tile intact.
|
||||
await page.reload();
|
||||
|
||||
@@ -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.
|
||||
|
||||
+71
-10
@@ -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<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
|
||||
// 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 @@
|
||||
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}>
|
||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
<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 view?.game.vsAi}
|
||||
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
|
||||
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
|
||||
lifts live at the unlock moment. -->
|
||||
<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="lbl">{t('game.hint')}</span>
|
||||
</button>
|
||||
{: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}
|
||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||
@@ -2052,4 +2104,13 @@
|
||||
.scoreboard.flat {
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hintsLeft } from './hints';
|
||||
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
|
||||
|
||||
// view carries only the two fields hintsLeft reads.
|
||||
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
|
||||
@@ -30,3 +30,32 @@ describe('hintsLeft', () => {
|
||||
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);
|
||||
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.shuffle': 'Shuffle',
|
||||
'game.hint': 'Hint',
|
||||
'game.hintLockedIn': 'Available in {n} min.',
|
||||
'game.chat': 'Chat',
|
||||
'game.checkWord': 'Check word',
|
||||
'game.dictionary': 'Dictionary',
|
||||
|
||||
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.draw': 'Обмен/Пас',
|
||||
'game.shuffle': 'Перемешать',
|
||||
'game.hint': 'Подсказка',
|
||||
'game.hintLockedIn': 'Будет доступно через {n} мин.',
|
||||
'game.chat': 'Чат',
|
||||
'game.checkWord': 'Проверить слово',
|
||||
'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 });
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('reconstructs a mid-game position by replay', () => {
|
||||
|
||||
@@ -38,8 +38,6 @@ export interface LocalGameRecord {
|
||||
status: 'active' | 'finished';
|
||||
createdAtUnix: 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). */
|
||||
@@ -48,7 +46,6 @@ export interface RecordMeta {
|
||||
seats: Seat[];
|
||||
createdAtUnix: number;
|
||||
updatedAtUnix: number;
|
||||
robotLastMoveAtUnix: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +67,6 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
|
||||
status: game.isOver ? 'finished' : 'active',
|
||||
createdAtUnix: meta.createdAtUnix,
|
||||
updatedAtUnix: meta.updatedAtUnix,
|
||||
robotLastMoveAtUnix: meta.robotLastMoveAtUnix,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +59,15 @@ describe('LocalSource', () => {
|
||||
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);
|
||||
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 () => {
|
||||
|
||||
@@ -34,9 +34,6 @@ import type { Move } from '../dict/validate';
|
||||
|
||||
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
|
||||
* 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 })),
|
||||
createdAtUnix: now,
|
||||
updatedAtUnix: now,
|
||||
robotLastMoveAtUnix: now,
|
||||
});
|
||||
const entry: Live = { game, record };
|
||||
this.live.set(opts.id, entry);
|
||||
@@ -191,10 +187,10 @@ export class LocalSource implements GameLoopSource {
|
||||
}
|
||||
|
||||
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);
|
||||
if (nowUnix() * 1000 - entry.record.robotLastMoveAtUnix * 1000 < HINT_GATE_MS) {
|
||||
throw new GatewayError('hint_not_ready');
|
||||
}
|
||||
const top = this.topMove(entry.game);
|
||||
if (!top) throw new GatewayError('no_hint');
|
||||
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));
|
||||
else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange));
|
||||
else out.push(g.pass());
|
||||
entry.record.robotLastMoveAtUnix = nowUnix();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -306,7 +301,6 @@ export class LocalSource implements GameLoopSource {
|
||||
seats: entry.record.seats,
|
||||
createdAtUnix: entry.record.createdAtUnix,
|
||||
updatedAtUnix: nowUnix(),
|
||||
robotLastMoveAtUnix: entry.record.robotLastMoveAtUnix,
|
||||
});
|
||||
await saveLocalGame(entry.record);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user