mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-03 07:13:18 +00:00
refactor(state): replace call-bus with shared master-store for both-mode
Single-slot bus carried only the latest draw, so any state event off-bus
(player regen, master "Ván mới", reload, mode toggle, throttled tab)
silently lost history. Symptom the host hit: regenerating the player
board mid-game wiped all prior auto-crosses.
- master-store.svelte.js: lifted {called, remaining} out of MasterPanel
into shared reactive $state, persisted to loto_master, hydrated once
in +layout.svelte's onMount so panels mount with consistent state
- player-auto-cross.js: new applyMasterCalls helper using cursor-by-index
(vs the retired Date.now-based at timestamp), so callers can pass
lastHandledIndex: 0 to replay master's full history on demand
- PlayerBoard:
- Reads masterState.called directly; cursor advances strictly
- manualUnticks Set tracks user-initiated unticks of called numbers,
suppressing re-cross on replay; persisted to loto_manualUnticks
- "Tạo bảng mới" replays masterState.called onto fresh grid (in both)
- "Xoá đánh dấu" clears + immediately replays in both mode
- Master "Ván mới" detected by called length transitioning >0 → 0,
force-clears player crossed + manualUnticks (locked product call)
- Killed call-bus.svelte.js + auto-tick.js and their tests; helper
surface is fully covered by master-store.test.js (9) and
player-auto-cross.test.js (10), plus 6 new manualUnticks cases in
game-logic.test.js (134 tests passing, was 123 before this refactor)
Targets findings F1, F2, F4, F6, F7, F8, F10 from the 2026-04-30
both-mode consistency audit. F9 (voice ownership) and #20 (multi-tab)
remain out of scope — separate plans to follow.
This commit is contained in:
1 parent
8e24d8047a
commit
761086358e
14 files changed
+639
-370
No files matched your search
@@ -14,7 +14,7 @@
|
||||
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid rendered as 3 stacked 3×9 mini-cards: Tân Tân / An khang thịnh vượng / Tân Tân tốt nhất). Tall (3:4 on mobile; 3:5 on sm+) cells with condensed bold black numbers (`tan-tan-num` font stack w/ self-hosted Roboto Condensed), white number cells, purple empty cells (dark mode dims via `filter:brightness(0.85)`). Handles crossed state, animated cross-out (200 ms `cross-draw` keyframe), `active:scale-90` press, 10 ms haptic on tap. Two header actions: "Tạo bảng mới" / "Xoá đánh dấu". First-run state shows a faded preview card. Bingo popup tiers: row 1 = standard celebration; row 3+ = falling-emoji confetti rain via CSS `confetti-fall`. Toast "Chờ N" + audio. Accepts `storagePrefix` prop for multi-card isolation. |
|
||||
| `src/lib/SettingsButton.svelte` | Gear icon + modal (responsive `max-w-sm sm:max-w-md`). 6 fieldsets: Giao diện (theme pills), Chế độ (3-way mode picker w/ SVG glyphs: player/master/both), Chế độ quản trò (switch row), Tự động xổ (switch + speed slider), Âm thanh (two switches + voice picker), Màu ô trống (10 Excel swatches + custom input in bordered card w/ "Tuỳ chỉnh"/"Mẫu" sub-headers). Boolean toggles use a shared `switchRow` snippet (`role="switch"` + keyboard support). Reset-to-default button. Mounted on `/`. |
|
||||
| `src/lib/MasterEmptyState.svelte` | Empty board placeholder for first-run master (mirrors PlayerBoard's preview UX). Displays faded 11×9 grid with "Ấn để bắt đầu ván mới" hint. |
|
||||
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, large "Số vừa xổ" hero token (160 px mobile, 224 px sm+) with `aria-live="assertive"` + auto `scrollIntoView` on each new draw, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). Publishes draws to `call-bus` for player auto-tick. "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. While auto-call runs, mounts `<AutoCountdown>` above the hero (driven by `tickCount` $state, bumped on draw and on every (re-)arm of the auto-call $effect). Mounted conditionally on `/` when `settings.mode !== "player"`; the wrapping section uses `transition:slide` for smooth toggle-in. |
|
||||
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, large "Số vừa xổ" hero token (160 px mobile, 224 px sm+) with `aria-live="assertive"` + auto `scrollIntoView` on each new draw, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). Calls `drawNext()` / `startNewGame()` from `master-store`; player side reads the same store directly (no bus). "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. While auto-call runs, mounts `<AutoCountdown>` above the hero (driven by `tickCount` $state, bumped on draw and on every (re-)arm of the auto-call $effect). Mounted conditionally on `/` when `settings.mode !== "player"`; the wrapping section uses `transition:slide` for smooth toggle-in. |
|
||||
| `src/lib/AutoCountdown.svelte` | Visual countdown for auto-call. Props-driven (`running`, `duration`, `tickKey`) — parent owns the `setInterval`, this component just renders. SVG ring with `stroke-dashoffset` controlled by elapsed-time progress (rAF loop while running) plus centered seconds-remaining number. `prefers-reduced-motion` clamps `dashOffset = 0` (static full ring). `role="timer"` + `aria-live="off"` so screen readers don't announce every second. |
|
||||
| `src/lib/PageFooter.svelte` | Footer with tagline ("Made by miti99 with ❤️ SVG icon") + link. Mounted on `/`. |
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns, soft "no 3 consecutive filled cols per row" via rejection sampling), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
|
||||
| `src/lib/call-bus.svelte.js` | Pub/sub for master draws → player auto-tick. Reactive `bus.lastDrawn` slot (emits `{ num, at }`). Used in `mode: "both"` to auto-mark master-called numbers on player board. |
|
||||
| `src/lib/auto-tick.js` | Pure `processAutoTick({grid, crossed, lastDraw, lastHandledAt, mode})` extracted from PlayerBoard's bus-driven $effect. Owns the dedup-by-`at` invariant: `lastHandledAt` advances on every NEW timestamp (even no-op draws) so reactive re-runs from `crossed`/`grid` changes never re-fire a stale draw. |
|
||||
| `src/lib/master-store.svelte.js` | Shared reactive `{called, remaining}` $state for the master deck, persisted to `loto_master`. Exports `masterState`, `loadMaster`, `saveMaster`, `startNewGame`, `drawNext`, `resetMaster`. Hydrated once via `+layout.svelte`'s `onMount` so player-side reads see the full history regardless of mount order. Replaced the single-slot `call-bus` to fix history-loss bugs (regen, reload, multi-tab) — see `plans/reports/code-reviewer-260430-2024-both-mode-consistency.md`. |
|
||||
| `src/lib/player-auto-cross.js` | Pure `applyMasterCalls({grid, crossed, called, lastHandledIndex, manualUnticks, mode})`. Cursor-by-index dedup (vs the retired `at`-timestamp model), so any caller can pass `lastHandledIndex: 0` to replay master's full history (used by player regen + "Xoá đánh dấu" in both mode). Manual unticks suppress re-cross on replay. |
|
||||
| `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. |
|
||||
| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `<audio>` cache, cancel-then-play, token-based cancel ensures stale promises can't resume after a new event. Reads active voice from `settings.voice`; URLs go through `import { base } from "$app/paths"` for basePath safety. |
|
||||
| `src/lib/audio-manifest.js` | Re-exports `static/audio/manifest.json` as `VOICES` (array) + `VOICE_IDS` (Set) + `DEFAULT_VOICE`. Manifest is generated by `scripts/generate-audio.py`. |
|
||||
@@ -37,10 +37,11 @@
|
||||
### Tests
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/game-logic.test.js` | 27 unit tests: generateGrid shape (9×9, 5 per row/col, no duplicates), column ranges & ascending sort, no-3-consecutive soft constraint, row completion, waiting number detection, persistence (saveGrid/loadGrid/saveCrossedState/loadCrossedState with validators). |
|
||||
| `src/lib/game-logic.test.js` | 33 unit tests: generateGrid shape (9×9, 5 per row/col, no duplicates), column ranges & ascending sort, no-3-consecutive soft constraint, row completion, waiting number detection, persistence (saveGrid/loadGrid/saveCrossedState/loadCrossedState/saveManualUnticks/loadManualUnticks with validators). |
|
||||
| `src/lib/settings-store.test.js` | 31 unit tests: defaults (incl. voice keys), loadSettings (restore 8 keys, apply CSS vars, toggle dark class, handle empty/corrupt), saveSettings, resetSettings, theme toggle (auto → OS pref detection), master mode, auto-call + speed, color validation, voice round-trip + invalid-id fallback. |
|
||||
| `src/lib/vietnamese-number.test.js` | 40 unit tests: ones (0–9), teens (10–19 incl. mười lăm), 20–90 incl. mốt and lăm exceptions, out-of-range fall-through. |
|
||||
| `src/lib/auto-tick.test.js` | 8 unit tests for `processAutoTick`: NEW draw flips cell, dedup on same `at`, re-cross after manual untick, mode=master/player ignored (timestamp still advances), off-board number no-op, null lastDraw, null grid + empty crossed. |
|
||||
| `src/lib/master-store.test.js` | 9 unit tests for shared master state: starts empty, `startNewGame` fills 1..90 unique, `drawNext` appends called and shifts remaining (returns null when exhausted), `resetMaster` clears, save+load round-trip, rejects corrupt JSON / out-of-range / oversize payloads. |
|
||||
| `src/lib/player-auto-cross.test.js` | 10 unit tests for `applyMasterCalls`: empty called no-op, cursor-at-length no-op, mode=player advances cursor without flipping, mode=both crosses uncrossed cell, full-history replay when cursor=0, manualUnticks skipped, grid=null no-ops with cursor advance, returns same crossed reference on no-flip. |
|
||||
|
||||
### Configuration & PWA
|
||||
| File | Purpose |
|
||||
@@ -88,7 +89,7 @@ RootLayout
|
||||
├── [if settings.mode !== "master"]
|
||||
│ └── PlayerBoard (storagePrefix="loto")
|
||||
├── [if settings.mode !== "player"]
|
||||
│ └── MasterPanel (publishes to call-bus on draws)
|
||||
│ └── MasterPanel (mutates master-store; player auto-cross derives from it)
|
||||
└── PageFooter
|
||||
```
|
||||
|
||||
|
||||
+41
-84
@@ -1,6 +1,4 @@
|
||||
<script module>
|
||||
const STORAGE_KEY = "loto_master";
|
||||
|
||||
/**
|
||||
* 11x9 board, aligned by ones-digit. Row = ones digit, col = tens digit.
|
||||
* Col 0 holds 1..9, col 8 holds 80..90 (90 sits alone in row 10 col 8).
|
||||
@@ -29,87 +27,54 @@
|
||||
return board;
|
||||
}
|
||||
|
||||
/** @returns {{ called: number[], remaining: number[] }} */
|
||||
function createFreshState() {
|
||||
const all = Array.from({ length: 90 }, (_, i) => i + 1);
|
||||
for (let i = all.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[all[i], all[j]] = [all[j], all[i]];
|
||||
}
|
||||
return { called: [], remaining: all };
|
||||
}
|
||||
|
||||
/** @param {{ called: number[], remaining: number[] }} state */
|
||||
function saveState(state) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Hard cap so a poisoned origin can't stall the UI on mount.
|
||||
* 90-number called list serializes to ~500 bytes; 16 KB has 30× headroom. */
|
||||
const MAX_STORAGE_BYTES = 16_384;
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (!data || data.length > MAX_STORAGE_BYTES) return null;
|
||||
const parsed = JSON.parse(data, (k, v) =>
|
||||
k === "__proto__" || k === "constructor" ? undefined : v,
|
||||
);
|
||||
// Minimal shape check — Array.isArray on both halves is enough
|
||||
// for all callers; deeper validation isn't worth the complexity.
|
||||
if (
|
||||
!parsed ||
|
||||
!Array.isArray(parsed.called) ||
|
||||
!Array.isArray(parsed.remaining)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const BOARD = Object.freeze(buildBoard().map((row) => Object.freeze(row)));
|
||||
const BOARD_FLAT = Object.freeze(BOARD.flatMap((r) => r));
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import AutoCountdown from "$lib/AutoCountdown.svelte";
|
||||
import { broadcastDraw, resetBus } from "$lib/call-bus.svelte.js";
|
||||
import {
|
||||
drawNext,
|
||||
masterState,
|
||||
saveMaster,
|
||||
startNewGame,
|
||||
} from "$lib/master-store.svelte.js";
|
||||
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
import { cancelPlayback, playNumber } from "$lib/voice.js";
|
||||
|
||||
let state = $state(
|
||||
/** @type {{called: number[], remaining: number[]} | null} */ (null),
|
||||
);
|
||||
let lastCalled = $state(/** @type {number | null} */ (null));
|
||||
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
|
||||
let autoRunning = $state(false);
|
||||
// Bumped on each draw and on every (re-)arm of the auto-call interval —
|
||||
// signals AutoCountdown to reset its ring.
|
||||
let tickCount = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const saved = loadState();
|
||||
if (saved && saved.called.length > 0) {
|
||||
state = saved;
|
||||
lastCalled = saved.called[saved.called.length - 1];
|
||||
}
|
||||
});
|
||||
// Round is "active" once a game has been started (remaining filled or
|
||||
// called populated). `called.length === 0 && remaining.length === 0`
|
||||
// means no game in progress → show empty state.
|
||||
const hasGame = $derived(
|
||||
masterState.called.length > 0 || masterState.remaining.length > 0,
|
||||
);
|
||||
const lastCalled = $derived(
|
||||
masterState.called.length > 0
|
||||
? masterState.called[masterState.called.length - 1]
|
||||
: null,
|
||||
);
|
||||
|
||||
// Master state hydration lives in `+layout.svelte` so it runs before
|
||||
// any panel mounts — otherwise PlayerBoard would baseline its cursor
|
||||
// against an empty `masterState.called` and replay the back-history
|
||||
// when MasterPanel later loads.
|
||||
$effect(() => {
|
||||
if (state) saveState(state);
|
||||
// Subscribe to both arrays so any mutation re-saves.
|
||||
masterState.called;
|
||||
masterState.remaining;
|
||||
saveMaster();
|
||||
});
|
||||
|
||||
// Map number -> 1-based draw order for fast Kinh! verification.
|
||||
const callOrder = $derived(
|
||||
new Map((state?.called ?? []).map((n, i) => [n, i + 1])),
|
||||
new Map(masterState.called.map((n, i) => [n, i + 1])),
|
||||
);
|
||||
|
||||
// Auto-call interval. Single $effect that depends on autoRunning,
|
||||
@@ -129,7 +94,7 @@
|
||||
// edge and on `duration` change, so re-arms are covered without our help.
|
||||
const ms = settings.autoCallSpeed * 1000;
|
||||
const id = setInterval(() => {
|
||||
if (!state || state.remaining.length === 0) {
|
||||
if (masterState.remaining.length === 0) {
|
||||
autoRunning = false;
|
||||
return;
|
||||
}
|
||||
@@ -163,30 +128,22 @@
|
||||
});
|
||||
|
||||
function handleNewGame() {
|
||||
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
|
||||
if (hasGame && !confirm("Bạn có muốn tạo ván mới không?")) return;
|
||||
cancelPlayback();
|
||||
autoRunning = false;
|
||||
state = createFreshState();
|
||||
lastCalled = null;
|
||||
resetBus();
|
||||
startNewGame();
|
||||
}
|
||||
|
||||
function handleDrawNext() {
|
||||
if (!state || state.remaining.length === 0) return;
|
||||
const next = state.remaining[0];
|
||||
state = {
|
||||
called: [...state.called, next],
|
||||
remaining: state.remaining.slice(1),
|
||||
};
|
||||
lastCalled = next;
|
||||
const next = drawNext();
|
||||
if (next === null) return;
|
||||
scrollOnNextDraw = true;
|
||||
broadcastDraw(next);
|
||||
tickCount++;
|
||||
if (settings.voiceEnabledMaster) playNumber(next);
|
||||
}
|
||||
|
||||
function toggleAuto() {
|
||||
if (!state || state.remaining.length === 0) return;
|
||||
if (masterState.remaining.length === 0) return;
|
||||
autoRunning = !autoRunning;
|
||||
}
|
||||
</script>
|
||||
@@ -201,7 +158,7 @@
|
||||
>
|
||||
Ván mới
|
||||
</button>
|
||||
{#if state && state.remaining.length > 0}
|
||||
{#if hasGame && masterState.remaining.length > 0}
|
||||
{#if settings.autoCallEnabled}
|
||||
<button
|
||||
onclick={toggleAuto}
|
||||
@@ -226,7 +183,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if settings.autoCallEnabled && state && state.remaining.length > 0}
|
||||
{#if settings.autoCallEnabled && hasGame && masterState.remaining.length > 0}
|
||||
<div
|
||||
class="text-center text-sm text-slate-600 dark:text-slate-300 mb-4 tabular-nums"
|
||||
>
|
||||
@@ -234,7 +191,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if autoRunning && state && state.remaining.length > 0}
|
||||
{#if autoRunning && masterState.remaining.length > 0}
|
||||
<div class="flex justify-center mb-4">
|
||||
<AutoCountdown
|
||||
running={autoRunning}
|
||||
@@ -276,23 +233,23 @@
|
||||
{lastCalled}
|
||||
</span>
|
||||
</div>
|
||||
{#if state}
|
||||
{#if hasGame}
|
||||
<div class="mt-2.5 text-sm text-slate-600 dark:text-slate-300 tabular-nums">
|
||||
Đã xổ: <strong class="font-semibold">{state.called.length}</strong>/90
|
||||
· Còn lại: <strong class="font-semibold">{state.remaining.length}</strong>
|
||||
Đã xổ: <strong class="font-semibold">{masterState.called.length}</strong>/90
|
||||
· Còn lại: <strong class="font-semibold">{masterState.remaining.length}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Called history -->
|
||||
{#if state && state.called.length > 0}
|
||||
{#if masterState.called.length > 0}
|
||||
<div class="mb-6 px-1">
|
||||
<div class="text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||
Thứ tự đã xổ:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each state.called as num, i (i)}
|
||||
{#each masterState.called as num, i (i)}
|
||||
{@const isLow = num <= 49}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10
|
||||
@@ -310,7 +267,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- 11x9 master tracking board -->
|
||||
{#if state}
|
||||
{#if hasGame}
|
||||
<div
|
||||
aria-label="Bảng theo dõi số đã xổ"
|
||||
class="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
|
||||
|
||||
+115
-17
@@ -1,15 +1,17 @@
|
||||
<script>
|
||||
import { processAutoTick } from "$lib/auto-tick.js";
|
||||
import { bus, resetBus } from "$lib/call-bus.svelte.js";
|
||||
import {
|
||||
generateGrid,
|
||||
getWaitingNumber,
|
||||
isRowComplete,
|
||||
loadCrossedState,
|
||||
loadGrid,
|
||||
loadManualUnticks,
|
||||
saveCrossedState,
|
||||
saveGrid,
|
||||
saveManualUnticks,
|
||||
} from "$lib/game-logic.js";
|
||||
import { masterState } from "$lib/master-store.svelte.js";
|
||||
import { applyMasterCalls } from "$lib/player-auto-cross.js";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
import { cancelPlayback, playBingo, playWaiting } from "$lib/voice.js";
|
||||
|
||||
@@ -24,6 +26,10 @@
|
||||
|
||||
let grid = $state(/** @type {number[][] | null} */ (null));
|
||||
let crossed = $state(/** @type {boolean[][]} */ ([]));
|
||||
// Numbers the user explicitly unticked AFTER an auto-cross; suppresses
|
||||
// re-cross on subsequent passes (e.g. regen replay). Manual re-ticks
|
||||
// remove from the set.
|
||||
let manualUnticks = $state(/** @type {Set<number>} */ (new Set()));
|
||||
let showCongrats = $state(false);
|
||||
let congratsRow = $state(-1);
|
||||
let celebrationTier = $state(/** @type {1 | 2} */ (1));
|
||||
@@ -41,10 +47,15 @@
|
||||
let toastTimer = null;
|
||||
const celebratedRows = new Set();
|
||||
const notifiedWaitingRows = new Set();
|
||||
// Last bus draw we acted on. Compared against bus.lastDrawn.at so the
|
||||
// auto-tick effect only fires on a NEW draw — re-runs caused by
|
||||
// crossed/grid changes (manual untick, clear, regen) skip cleanly.
|
||||
let lastHandledDrawAt = 0;
|
||||
// How many entries of masterState.called we've already replayed.
|
||||
// Advances strictly even on no-op passes so a single draw never
|
||||
// re-fires (auto-cross effect dedup). Resets to 0 when the host
|
||||
// starts a new game (called → []).
|
||||
let lastHandledIndex = $state(0);
|
||||
// Tracks called.length across reactivity ticks so we can detect a
|
||||
// master "Ván mới" (length transitions from >0 → 0) and clear
|
||||
// player crossed in both mode per locked product decision.
|
||||
let prevCalledLen = $state(0);
|
||||
|
||||
// Memoized per-row completeness — avoid 81×/render isRowComplete calls
|
||||
const rowCompleteness = $derived(
|
||||
@@ -92,6 +103,7 @@
|
||||
loadCrossedState(STORAGE_PREFIX) ??
|
||||
savedGrid.map((row) => row.map(() => false));
|
||||
crossed = savedCrossed;
|
||||
manualUnticks = loadManualUnticks(STORAGE_PREFIX);
|
||||
|
||||
celebratedRows.clear();
|
||||
notifiedWaitingRows.clear();
|
||||
@@ -100,6 +112,16 @@
|
||||
if (getWaitingNumber(savedGrid, savedCrossed, i) !== null)
|
||||
notifiedWaitingRows.add(i);
|
||||
}
|
||||
// No cursor baseline here on purpose. We let `lastHandledIndex` stay
|
||||
// at its $state init (0) so the auto-cross effect re-applies the full
|
||||
// master history. Already-crossed cells are skipped by
|
||||
// `findUncrossedCell`, so reload is idempotent. This avoids racing
|
||||
// mount order between PlayerBoard and MasterPanel mode toggles.
|
||||
});
|
||||
|
||||
// Persist manualUnticks whenever it changes.
|
||||
$effect(() => {
|
||||
if (grid) saveManualUnticks(manualUnticks, STORAGE_PREFIX);
|
||||
});
|
||||
|
||||
// Persist crossed state on change
|
||||
@@ -175,35 +197,81 @@
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
// Auto-tick on master draw (only in "both" mode). Reads `bus.lastDrawn`
|
||||
// reactively; the dedup-by-`at` invariant lives in `processAutoTick` —
|
||||
// see `auto-tick.test.js` for the full case matrix.
|
||||
// Auto-cross on master draws (only in "both" mode). Reads
|
||||
// `masterState.called` directly — full history, no bus, no 1ms
|
||||
// collision risk.
|
||||
//
|
||||
// Self-write safety: this effect reads `crossed` and `lastHandledIndex`
|
||||
// and may write both. Convergence is guaranteed because (a) writes are
|
||||
// gated on `result.changed` / cursor-mismatch and (b) on re-run after
|
||||
// a write, `applyMasterCalls`'s cursor-at-length short-circuit returns
|
||||
// a no-op result. Don't unguard the writes without re-testing.
|
||||
$effect(() => {
|
||||
const result = processAutoTick({
|
||||
const result = applyMasterCalls({
|
||||
grid,
|
||||
crossed,
|
||||
lastDraw: bus.lastDrawn,
|
||||
lastHandledAt: lastHandledDrawAt,
|
||||
called: masterState.called,
|
||||
lastHandledIndex,
|
||||
manualUnticks,
|
||||
mode: settings.mode,
|
||||
});
|
||||
lastHandledDrawAt = result.lastHandledAt;
|
||||
if (result.lastHandledIndex !== lastHandledIndex) {
|
||||
lastHandledIndex = result.lastHandledIndex;
|
||||
}
|
||||
if (result.changed) crossed = result.crossed;
|
||||
});
|
||||
|
||||
// Detect master "Ván mới" — `called` length transitions from >0 → 0.
|
||||
// Per locked product decision, force-clear player crossed (and
|
||||
// manualUnticks) in both mode so a fresh round starts truly fresh.
|
||||
//
|
||||
// Self-write safety: explicit early-return when `len === prev` avoids
|
||||
// a same-value write to `prevCalledLen` that could spuriously re-run.
|
||||
$effect(() => {
|
||||
const len = masterState.called.length;
|
||||
const prev = prevCalledLen;
|
||||
if (len === prev) return;
|
||||
prevCalledLen = len;
|
||||
if (prev > 0 && len === 0 && settings.mode === "both" && grid) {
|
||||
crossed = grid.map((row) => row.map(() => false));
|
||||
manualUnticks = new Set();
|
||||
lastHandledIndex = 0;
|
||||
celebratedRows.clear();
|
||||
notifiedWaitingRows.clear();
|
||||
}
|
||||
});
|
||||
|
||||
function handleGenerate() {
|
||||
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
|
||||
cancelPlayback();
|
||||
const newGrid = generateGrid();
|
||||
const newCrossed = newGrid.map((row) => row.map(() => false));
|
||||
let newCrossed = newGrid.map((row) => row.map(() => false));
|
||||
// Replay master's called[] onto the fresh grid so the host doesn't
|
||||
// restart from zero when they regenerate mid-game (locked decision).
|
||||
if (settings.mode === "both") {
|
||||
const result = applyMasterCalls({
|
||||
grid: newGrid,
|
||||
crossed: newCrossed,
|
||||
called: masterState.called,
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set(),
|
||||
mode: "both",
|
||||
});
|
||||
newCrossed = result.crossed;
|
||||
lastHandledIndex = result.lastHandledIndex;
|
||||
} else {
|
||||
lastHandledIndex = masterState.called.length;
|
||||
}
|
||||
grid = newGrid;
|
||||
crossed = newCrossed;
|
||||
manualUnticks = new Set();
|
||||
saveGrid(newGrid, STORAGE_PREFIX);
|
||||
saveCrossedState(newCrossed, STORAGE_PREFIX);
|
||||
saveManualUnticks(manualUnticks, STORAGE_PREFIX);
|
||||
celebratedRows.clear();
|
||||
notifiedWaitingRows.clear();
|
||||
dismissToast();
|
||||
showCongrats = false;
|
||||
resetBus();
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
@@ -211,12 +279,29 @@
|
||||
const hasMarks = crossed.some((row) => row.some(Boolean));
|
||||
if (hasMarks && !confirm("Bạn có muốn xoá tất cả đánh dấu không?")) return;
|
||||
cancelPlayback();
|
||||
crossed = grid.map((row) => row.map(() => false));
|
||||
let cleared = grid.map((row) => row.map(() => false));
|
||||
manualUnticks = new Set();
|
||||
// In both mode, immediately replay master's called[] (locked
|
||||
// decision: clear → re-cross all currently-called numbers).
|
||||
if (settings.mode === "both") {
|
||||
const result = applyMasterCalls({
|
||||
grid,
|
||||
crossed: cleared,
|
||||
called: masterState.called,
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set(),
|
||||
mode: "both",
|
||||
});
|
||||
cleared = result.crossed;
|
||||
lastHandledIndex = result.lastHandledIndex;
|
||||
} else {
|
||||
lastHandledIndex = masterState.called.length;
|
||||
}
|
||||
crossed = cleared;
|
||||
celebratedRows.clear();
|
||||
notifiedWaitingRows.clear();
|
||||
dismissToast();
|
||||
showCongrats = false;
|
||||
resetBus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,6 +316,19 @@
|
||||
) {
|
||||
navigator.vibrate(10);
|
||||
}
|
||||
if (!grid) return;
|
||||
const num = grid[row][col];
|
||||
const wasCrossed = crossed[row]?.[col] === true;
|
||||
const willBeCrossed = !wasCrossed;
|
||||
// Track manual unticks of called numbers so future regen/clear
|
||||
// replays skip them. Untracking uncalled numbers would pollute the
|
||||
// set with no visible effect — auto-cross only acts on called nums.
|
||||
if (num > 0 && masterState.called.includes(num)) {
|
||||
const next = new Set(manualUnticks);
|
||||
if (wasCrossed && !willBeCrossed) next.add(num);
|
||||
else if (!wasCrossed && willBeCrossed) next.delete(num);
|
||||
if (next.size !== manualUnticks.size) manualUnticks = next;
|
||||
}
|
||||
crossed = crossed.map((r, ri) =>
|
||||
ri === row ? r.map((v, ci) => (ci === col ? !v : v)) : r
|
||||
);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Pure helper for the master→player auto-tick path. Extracted from
|
||||
* PlayerBoard.svelte so the dedup-by-`at` invariant is unit-testable
|
||||
* without mounting the component.
|
||||
* @module lib/auto-tick
|
||||
*/
|
||||
|
||||
import { findUncrossedCell } from "$lib/game-logic.js";
|
||||
|
||||
/**
|
||||
* Decide what `crossed` should become given a new bus draw.
|
||||
*
|
||||
* Always advance `lastHandledAt` to the draw's timestamp on a NEW draw,
|
||||
* even when no cell ends up flipped (mode mismatch, number off-board,
|
||||
* already crossed). This blocks reactive re-runs caused by `crossed` /
|
||||
* `grid` changes (manual untick, clear, regen) from re-firing the same
|
||||
* draw — only a fresh `at` should ever advance state.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number[][] | null} args.grid
|
||||
* @param {boolean[][]} args.crossed
|
||||
* @param {{ num: number, at: number } | null} args.lastDraw
|
||||
* @param {number} args.lastHandledAt
|
||||
* @param {"player" | "master" | "both"} args.mode
|
||||
* @returns {{ crossed: boolean[][], lastHandledAt: number, changed: boolean }}
|
||||
*/
|
||||
export function processAutoTick({
|
||||
grid,
|
||||
crossed,
|
||||
lastDraw,
|
||||
lastHandledAt,
|
||||
mode,
|
||||
}) {
|
||||
if (!lastDraw) return { crossed, lastHandledAt, changed: false };
|
||||
if (lastDraw.at === lastHandledAt) {
|
||||
return { crossed, lastHandledAt, changed: false };
|
||||
}
|
||||
// From here on, the draw is consumed: lastHandledAt advances.
|
||||
const advanced = lastDraw.at;
|
||||
if (mode !== "both") return { crossed, lastHandledAt: advanced, changed: false };
|
||||
if (!grid || crossed.length === 0) {
|
||||
return { crossed, lastHandledAt: advanced, changed: false };
|
||||
}
|
||||
const target = findUncrossedCell(grid, crossed, lastDraw.num);
|
||||
if (!target) return { crossed, lastHandledAt: advanced, changed: false };
|
||||
const updated = crossed.map((row, ri) =>
|
||||
ri === target.row
|
||||
? row.map((v, ci) => (ci === target.col ? true : v))
|
||||
: row,
|
||||
);
|
||||
return { crossed: updated, lastHandledAt: advanced, changed: true };
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { processAutoTick } from "./auto-tick.js";
|
||||
|
||||
/**
|
||||
* Build a minimal grid where row 0 col 2 = 42 and row 2 col 5 = 17.
|
||||
* Other cells are 0 (empty).
|
||||
*/
|
||||
function makeGrid() {
|
||||
const grid = Array.from({ length: 9 }, () => new Array(9).fill(0));
|
||||
grid[0][2] = 42;
|
||||
grid[2][5] = 17;
|
||||
return grid;
|
||||
}
|
||||
|
||||
function makeCrossed() {
|
||||
return Array.from({ length: 9 }, () => new Array(9).fill(false));
|
||||
}
|
||||
|
||||
describe("processAutoTick", () => {
|
||||
it("crosses the cell on a NEW draw when mode=both", () => {
|
||||
const grid = makeGrid();
|
||||
const crossed = makeCrossed();
|
||||
const result = processAutoTick({
|
||||
grid,
|
||||
crossed,
|
||||
lastDraw: { num: 42, at: 1000 },
|
||||
lastHandledAt: 0,
|
||||
mode: "both",
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.lastHandledAt).toBe(1000);
|
||||
expect(result.crossed[0][2]).toBe(true);
|
||||
// Original input not mutated (immutable update).
|
||||
expect(crossed[0][2]).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a re-fire with the same `at` (dedup guard)", () => {
|
||||
const crossed = makeCrossed();
|
||||
const result = processAutoTick({
|
||||
grid: makeGrid(),
|
||||
crossed,
|
||||
lastDraw: { num: 42, at: 5000 },
|
||||
lastHandledAt: 5000,
|
||||
mode: "both",
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.lastHandledAt).toBe(5000);
|
||||
expect(result.crossed).toBe(crossed);
|
||||
});
|
||||
|
||||
it("re-crosses on a NEW `at` after a manual untick", () => {
|
||||
// Step 1: auto-tick fires.
|
||||
const grid = makeGrid();
|
||||
const first = processAutoTick({
|
||||
grid,
|
||||
crossed: makeCrossed(),
|
||||
lastDraw: { num: 42, at: 1000 },
|
||||
lastHandledAt: 0,
|
||||
mode: "both",
|
||||
});
|
||||
expect(first.crossed[0][2]).toBe(true);
|
||||
|
||||
// Step 2: user manually unticks (simulated by editing).
|
||||
const manualUntick = first.crossed.map((row) => row.slice());
|
||||
manualUntick[0][2] = false;
|
||||
|
||||
// Step 3: same number arrives with NEW timestamp → re-crosses.
|
||||
const second = processAutoTick({
|
||||
grid,
|
||||
crossed: manualUntick,
|
||||
lastDraw: { num: 42, at: 2000 },
|
||||
lastHandledAt: first.lastHandledAt,
|
||||
mode: "both",
|
||||
});
|
||||
expect(second.changed).toBe(true);
|
||||
expect(second.lastHandledAt).toBe(2000);
|
||||
expect(second.crossed[0][2]).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores draws when mode=master but still consumes the timestamp", () => {
|
||||
const crossed = makeCrossed();
|
||||
const result = processAutoTick({
|
||||
grid: makeGrid(),
|
||||
crossed,
|
||||
lastDraw: { num: 42, at: 4000 },
|
||||
lastHandledAt: 0,
|
||||
mode: "master",
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.crossed).toBe(crossed);
|
||||
expect(result.lastHandledAt).toBe(4000);
|
||||
});
|
||||
|
||||
it("ignores draws when mode=player but still consumes the timestamp", () => {
|
||||
const crossed = makeCrossed();
|
||||
const result = processAutoTick({
|
||||
grid: makeGrid(),
|
||||
crossed,
|
||||
lastDraw: { num: 42, at: 9000 },
|
||||
lastHandledAt: 0,
|
||||
mode: "player",
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.crossed).toBe(crossed);
|
||||
// lastHandledAt still advances — solo player switching to "both"
|
||||
// mid-game shouldn't replay a stale draw.
|
||||
expect(result.lastHandledAt).toBe(9000);
|
||||
});
|
||||
|
||||
it("no-ops when the number is not on the grid", () => {
|
||||
const crossed = makeCrossed();
|
||||
const result = processAutoTick({
|
||||
grid: makeGrid(),
|
||||
crossed,
|
||||
lastDraw: { num: 88, at: 3000 },
|
||||
lastHandledAt: 0,
|
||||
mode: "both",
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.crossed).toBe(crossed);
|
||||
expect(result.lastHandledAt).toBe(3000);
|
||||
});
|
||||
|
||||
it("returns unchanged state when lastDraw is null", () => {
|
||||
const crossed = makeCrossed();
|
||||
const result = processAutoTick({
|
||||
grid: makeGrid(),
|
||||
crossed,
|
||||
lastDraw: null,
|
||||
lastHandledAt: 1234,
|
||||
mode: "both",
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.crossed).toBe(crossed);
|
||||
expect(result.lastHandledAt).toBe(1234);
|
||||
});
|
||||
|
||||
it("no-ops when grid is null or crossed is empty", () => {
|
||||
const result = processAutoTick({
|
||||
grid: null,
|
||||
crossed: [],
|
||||
lastDraw: { num: 42, at: 7000 },
|
||||
lastHandledAt: 0,
|
||||
mode: "both",
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.lastHandledAt).toBe(7000);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Tiny one-slot bus to coordinate master draws → player auto-tick.
|
||||
* Each draw publishes a fresh object so even repeat numbers fire a
|
||||
* fresh reactive change. Consumers read `bus.lastDrawn?.num` in an
|
||||
* effect.
|
||||
*
|
||||
* @module lib/call-bus
|
||||
*/
|
||||
|
||||
export const bus = $state({
|
||||
/** @type {{ num: number, at: number } | null} */
|
||||
lastDrawn: null,
|
||||
});
|
||||
|
||||
/** @param {number} num */
|
||||
export function broadcastDraw(num) {
|
||||
bus.lastDrawn = { num, at: Date.now() };
|
||||
}
|
||||
|
||||
export function resetBus() {
|
||||
bus.lastDrawn = null;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { broadcastDraw, bus, resetBus } from "./call-bus.svelte.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetBus();
|
||||
});
|
||||
|
||||
describe("call-bus", () => {
|
||||
it("starts with lastDrawn=null", () => {
|
||||
expect(bus.lastDrawn).toBeNull();
|
||||
});
|
||||
|
||||
it("broadcastDraw stores the number", () => {
|
||||
broadcastDraw(42);
|
||||
expect(bus.lastDrawn?.num).toBe(42);
|
||||
expect(typeof bus.lastDrawn?.at).toBe("number");
|
||||
});
|
||||
|
||||
it("broadcasting the same number twice creates a fresh object", () => {
|
||||
broadcastDraw(7);
|
||||
const first = bus.lastDrawn;
|
||||
broadcastDraw(7);
|
||||
const second = bus.lastDrawn;
|
||||
expect(second?.num).toBe(7);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("resetBus clears lastDrawn", () => {
|
||||
broadcastDraw(15);
|
||||
expect(bus.lastDrawn).not.toBeNull();
|
||||
resetBus();
|
||||
expect(bus.lastDrawn).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -264,6 +264,45 @@ export function loadCrossedState(prefix = "loto") {
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {any} v */
|
||||
function isUnticksArray(v) {
|
||||
return (
|
||||
Array.isArray(v) &&
|
||||
v.every((n) => Number.isInteger(n) && n >= 1 && n <= 90)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<number>} unticks
|
||||
* @param {string} [prefix]
|
||||
*/
|
||||
export function saveManualUnticks(unticks, prefix = "loto") {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${prefix}_manualUnticks`,
|
||||
JSON.stringify([...unticks].sort((a, b) => a - b)),
|
||||
);
|
||||
} catch {
|
||||
// see saveGrid
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [prefix]
|
||||
* @returns {Set<number>}
|
||||
*/
|
||||
export function loadManualUnticks(prefix = "loto") {
|
||||
try {
|
||||
const arr = safeParse(
|
||||
localStorage.getItem(`${prefix}_manualUnticks`),
|
||||
isUnticksArray,
|
||||
);
|
||||
return new Set(arr ?? []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the first non-crossed cell holding `num` on the grid.
|
||||
* Returns `null` when the number is absent or already crossed everywhere.
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
isRowComplete,
|
||||
loadCrossedState,
|
||||
loadGrid,
|
||||
loadManualUnticks,
|
||||
saveCrossedState,
|
||||
saveGrid,
|
||||
saveManualUnticks,
|
||||
} from "./game-logic.js";
|
||||
|
||||
const NUM_ROWS = 9;
|
||||
@@ -294,3 +296,45 @@ describe("findUncrossedCell", () => {
|
||||
.toEqual({ row: 0, col: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveManualUnticks / loadManualUnticks roundtrip", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("round-trips a Set of numbers", () => {
|
||||
const set = new Set([7, 42, 88]);
|
||||
saveManualUnticks(set, "loto");
|
||||
expect(loadManualUnticks("loto")).toEqual(set);
|
||||
});
|
||||
|
||||
it("returns empty Set when nothing stored", () => {
|
||||
expect(loadManualUnticks("loto")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty Set on corrupt JSON", () => {
|
||||
localStorage.setItem("loto_manualUnticks", "{not valid");
|
||||
expect(loadManualUnticks("loto")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("rejects entries outside [1,90]", () => {
|
||||
localStorage.setItem(
|
||||
"loto_manualUnticks",
|
||||
JSON.stringify([0, 1, 91, 5]),
|
||||
);
|
||||
// Whole payload is rejected when any entry fails validation,
|
||||
// mirroring the safeParse semantics for grids and crossed state.
|
||||
expect(loadManualUnticks("loto")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("storage value is sorted ascending for deterministic diffs", () => {
|
||||
saveManualUnticks(new Set([88, 7, 42]), "loto");
|
||||
expect(localStorage.getItem("loto_manualUnticks")).toBe("[7,42,88]");
|
||||
});
|
||||
|
||||
it("respects the prefix arg", () => {
|
||||
saveManualUnticks(new Set([5]), "alt");
|
||||
expect(loadManualUnticks("alt")).toEqual(new Set([5]));
|
||||
expect(loadManualUnticks("loto")).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Shared reactive state for the master draw deck. Lifted out of
|
||||
* MasterPanel.svelte so the player side can read `called[]` directly
|
||||
* (instead of relying on a single-slot bus that loses history).
|
||||
*
|
||||
* Persisted to localStorage `loto_master`. Mutations replace whole
|
||||
* arrays so Svelte's proxy fires reactively for every consumer.
|
||||
*
|
||||
* @module lib/master-store
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "loto_master";
|
||||
/** Hard cap so a poisoned origin can't stall the UI on mount.
|
||||
* 90-number called list serializes to ~500 bytes; 16 KB has 30× headroom. */
|
||||
const MAX_STORAGE_BYTES = 16_384;
|
||||
|
||||
export const masterState = $state({
|
||||
/** @type {number[]} */
|
||||
called: [],
|
||||
/** @type {number[]} */
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
function shuffled1to90() {
|
||||
const all = Array.from({ length: 90 }, (_, i) => i + 1);
|
||||
for (let i = all.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[all[i], all[j]] = [all[j], all[i]];
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** @param {unknown} v */
|
||||
function isValidNumberArray(v) {
|
||||
return (
|
||||
Array.isArray(v) &&
|
||||
v.every((n) => Number.isInteger(n) && n >= 1 && n <= 90)
|
||||
);
|
||||
}
|
||||
|
||||
export function loadMaster() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw || raw.length > MAX_STORAGE_BYTES) return;
|
||||
const parsed = JSON.parse(raw, (k, v) =>
|
||||
k === "__proto__" || k === "constructor" ? undefined : v,
|
||||
);
|
||||
if (
|
||||
!parsed ||
|
||||
!isValidNumberArray(parsed.called) ||
|
||||
!isValidNumberArray(parsed.remaining)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
masterState.called = parsed.called;
|
||||
masterState.remaining = parsed.remaining;
|
||||
} catch {
|
||||
/* private mode / corrupt JSON — leave defaults */
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMaster() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
called: masterState.called,
|
||||
remaining: masterState.remaining,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* see loadMaster */
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a fresh round: empty called, full shuffled remaining. */
|
||||
export function startNewGame() {
|
||||
masterState.called = [];
|
||||
masterState.remaining = shuffled1to90();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the next number from `remaining` into `called`.
|
||||
* @returns {number | null} the drawn number, or null if exhausted
|
||||
*/
|
||||
export function drawNext() {
|
||||
if (masterState.remaining.length === 0) return null;
|
||||
const next = masterState.remaining[0];
|
||||
masterState.called = [...masterState.called, next];
|
||||
masterState.remaining = masterState.remaining.slice(1);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Wipe everything — used by tests and when leaving a game permanently. */
|
||||
export function resetMaster() {
|
||||
masterState.called = [];
|
||||
masterState.remaining = [];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
drawNext,
|
||||
loadMaster,
|
||||
masterState,
|
||||
resetMaster,
|
||||
saveMaster,
|
||||
startNewGame,
|
||||
} from "./master-store.svelte.js";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetMaster();
|
||||
});
|
||||
|
||||
describe("master-store", () => {
|
||||
it("starts empty", () => {
|
||||
expect(masterState.called).toEqual([]);
|
||||
expect(masterState.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it("startNewGame fills remaining with 90 unique 1..90", () => {
|
||||
startNewGame();
|
||||
expect(masterState.called).toEqual([]);
|
||||
expect(masterState.remaining).toHaveLength(90);
|
||||
const set = new Set(masterState.remaining);
|
||||
expect(set.size).toBe(90);
|
||||
for (let n = 1; n <= 90; n++) expect(set.has(n)).toBe(true);
|
||||
});
|
||||
|
||||
it("drawNext appends called and shifts remaining; returns drawn", () => {
|
||||
startNewGame();
|
||||
const first = masterState.remaining[0];
|
||||
const drawn = drawNext();
|
||||
expect(drawn).toBe(first);
|
||||
expect(masterState.called).toEqual([first]);
|
||||
expect(masterState.remaining).toHaveLength(89);
|
||||
expect(masterState.remaining.includes(first)).toBe(false);
|
||||
});
|
||||
|
||||
it("drawNext returns null when exhausted", () => {
|
||||
startNewGame();
|
||||
for (let i = 0; i < 90; i++) drawNext();
|
||||
expect(drawNext()).toBeNull();
|
||||
expect(masterState.called).toHaveLength(90);
|
||||
expect(masterState.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it("resetMaster clears both arrays", () => {
|
||||
startNewGame();
|
||||
drawNext();
|
||||
resetMaster();
|
||||
expect(masterState.called).toEqual([]);
|
||||
expect(masterState.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it("save + load round-trip preserves state", () => {
|
||||
startNewGame();
|
||||
drawNext();
|
||||
drawNext();
|
||||
const calledBefore = [...masterState.called];
|
||||
const remainingBefore = [...masterState.remaining];
|
||||
saveMaster();
|
||||
resetMaster();
|
||||
expect(masterState.called).toEqual([]);
|
||||
loadMaster();
|
||||
expect(masterState.called).toEqual(calledBefore);
|
||||
expect(masterState.remaining).toEqual(remainingBefore);
|
||||
});
|
||||
|
||||
it("loadMaster ignores corrupt JSON", () => {
|
||||
localStorage.setItem("loto_master", "{not valid");
|
||||
loadMaster();
|
||||
expect(masterState.called).toEqual([]);
|
||||
expect(masterState.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it("loadMaster ignores non-int range entries", () => {
|
||||
localStorage.setItem(
|
||||
"loto_master",
|
||||
JSON.stringify({ called: [1, 91], remaining: [] }),
|
||||
);
|
||||
loadMaster();
|
||||
expect(masterState.called).toEqual([]);
|
||||
});
|
||||
|
||||
it("loadMaster rejects payloads exceeding the size cap", () => {
|
||||
const big = "x".repeat(20_000);
|
||||
localStorage.setItem("loto_master", big);
|
||||
loadMaster();
|
||||
expect(masterState.called).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Pure helper for the master→player auto-cross path. Reads the master's
|
||||
* full `called[]` history (not a single-slot bus) so player regen,
|
||||
* "Xoá đánh dấu", and reload can replay missed draws by passing
|
||||
* `lastHandledIndex: 0`.
|
||||
*
|
||||
* Cursor advances strictly even when no cell flips (mode mismatch,
|
||||
* manual untick, off-board) so the same draw never re-fires.
|
||||
*
|
||||
* @module lib/player-auto-cross
|
||||
*/
|
||||
|
||||
import { findUncrossedCell } from "$lib/game-logic.js";
|
||||
|
||||
/**
|
||||
* @param {object} args
|
||||
* @param {number[][] | null} args.grid
|
||||
* @param {boolean[][]} args.crossed
|
||||
* @param {number[]} args.called - master's full history
|
||||
* @param {number} args.lastHandledIndex - index already consumed
|
||||
* @param {Set<number>} args.manualUnticks - numbers user explicitly unticked
|
||||
* @param {"player" | "master" | "both"} args.mode
|
||||
* @returns {{ crossed: boolean[][], lastHandledIndex: number, changed: boolean }}
|
||||
*/
|
||||
export function applyMasterCalls({
|
||||
grid,
|
||||
crossed,
|
||||
called,
|
||||
lastHandledIndex,
|
||||
manualUnticks,
|
||||
mode,
|
||||
}) {
|
||||
if (lastHandledIndex >= called.length) {
|
||||
return { crossed, lastHandledIndex, changed: false };
|
||||
}
|
||||
// In non-both modes the player isn't auto-following the master, but we
|
||||
// still advance the cursor — otherwise toggling player→both would dump
|
||||
// the entire back-history at once. Only future draws should auto-cross.
|
||||
if (mode !== "both" || !grid || crossed.length === 0) {
|
||||
return { crossed, lastHandledIndex: called.length, changed: false };
|
||||
}
|
||||
let next = crossed;
|
||||
let changed = false;
|
||||
for (let i = lastHandledIndex; i < called.length; i++) {
|
||||
const num = called[i];
|
||||
if (manualUnticks.has(num)) continue;
|
||||
const target = findUncrossedCell(grid, next, num);
|
||||
if (!target) continue;
|
||||
next = next.map((row, ri) =>
|
||||
ri === target.row
|
||||
? row.map((v, ci) => (ci === target.col ? true : v))
|
||||
: row,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
return { crossed: next, lastHandledIndex: called.length, changed };
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMasterCalls } from "./player-auto-cross.js";
|
||||
|
||||
/** @param {number} num */
|
||||
function gridWith(num) {
|
||||
// 9x9 grid with `num` placed at (0,0) and zeros elsewhere — sufficient
|
||||
// for unit testing applyMasterCalls's flip path.
|
||||
const g = Array.from({ length: 9 }, () => new Array(9).fill(0));
|
||||
g[0][0] = num;
|
||||
return g;
|
||||
}
|
||||
|
||||
function emptyCrossed() {
|
||||
return Array.from({ length: 9 }, () => new Array(9).fill(false));
|
||||
}
|
||||
|
||||
describe("applyMasterCalls", () => {
|
||||
it("no-op when called[] is empty", () => {
|
||||
const grid = gridWith(7);
|
||||
const r = applyMasterCalls({
|
||||
grid,
|
||||
crossed: emptyCrossed(),
|
||||
called: [],
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set(),
|
||||
mode: "both",
|
||||
});
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.lastHandledIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("no-op when cursor at length", () => {
|
||||
const r = applyMasterCalls({
|
||||
grid: gridWith(7),
|
||||
crossed: emptyCrossed(),
|
||||
called: [7],
|
||||
lastHandledIndex: 1,
|
||||
manualUnticks: new Set(),
|
||||
mode: "both",
|
||||
});
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.lastHandledIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("mode=player advances cursor without flipping", () => {
|
||||
const r = applyMasterCalls({
|
||||
grid: gridWith(7),
|
||||
crossed: emptyCrossed(),
|
||||
called: [7, 8, 9],
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set(),
|
||||
mode: "player",
|
||||
});
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.lastHandledIndex).toBe(3);
|
||||
expect(r.crossed[0][0]).toBe(false);
|
||||
});
|
||||
|
||||
it("mode=both crosses uncrossed cell on match", () => {
|
||||
const r = applyMasterCalls({
|
||||
grid: gridWith(7),
|
||||
crossed: emptyCrossed(),
|
||||
called: [7],
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set(),
|
||||
mode: "both",
|
||||
});
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.lastHandledIndex).toBe(1);
|
||||
expect(r.crossed[0][0]).toBe(true);
|
||||
});
|
||||
|
||||
it("mode=both replays full back-history when cursor=0", () => {
|
||||
const grid = Array.from({ length: 9 }, () => new Array(9).fill(0));
|
||||
grid[0][0] = 5;
|
||||
grid[1][1] = 12;
|
||||
grid[2][2] = 88;
|
||||
const r = applyMasterCalls({
|
||||
grid,
|
||||
crossed: emptyCrossed(),
|
||||
called: [5, 12, 88, 99 /* off-board */],
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set(),
|
||||
mode: "both",
|
||||
});
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.lastHandledIndex).toBe(4);
|
||||
expect(r.crossed[0][0]).toBe(true);
|
||||
expect(r.crossed[1][1]).toBe(true);
|
||||
expect(r.crossed[2][2]).toBe(true);
|
||||
});
|
||||
|
||||
it("manualUnticks numbers are skipped", () => {
|
||||
const r = applyMasterCalls({
|
||||
grid: gridWith(7),
|
||||
crossed: emptyCrossed(),
|
||||
called: [7],
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set([7]),
|
||||
mode: "both",
|
||||
});
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.lastHandledIndex).toBe(1);
|
||||
expect(r.crossed[0][0]).toBe(false);
|
||||
});
|
||||
|
||||
it("grid=null no-ops in both mode (still advances cursor)", () => {
|
||||
const r = applyMasterCalls({
|
||||
grid: null,
|
||||
crossed: [],
|
||||
called: [7],
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set(),
|
||||
mode: "both",
|
||||
});
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.lastHandledIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("returns same crossed reference when no flip happens", () => {
|
||||
const crossed = emptyCrossed();
|
||||
const r = applyMasterCalls({
|
||||
grid: gridWith(7),
|
||||
crossed,
|
||||
called: [99 /* not on grid */],
|
||||
lastHandledIndex: 0,
|
||||
manualUnticks: new Set(),
|
||||
mode: "both",
|
||||
});
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.crossed).toBe(crossed);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,18 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import "../app.css";
|
||||
import { loadMaster } from "$lib/master-store.svelte.js";
|
||||
import { loadSettings } from "$lib/settings-store.svelte.js";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Hydrate settings once so the empty-cell color is applied before
|
||||
// either page renders its grid. onMount (not $effect) makes the
|
||||
// single-shot intent explicit.
|
||||
onMount(loadSettings);
|
||||
// Hydrate global stores once so children mount with consistent state.
|
||||
// Master state must hydrate before PlayerBoard reads `masterState.called`
|
||||
// length — see the mode-mount race fix in the consistency refactor.
|
||||
onMount(() => {
|
||||
loadSettings();
|
||||
loadMaster();
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
Reference in new issue
Block a user