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:
tiennm99 committed 2026-04-30 21:10:51 +07:00
1 parent 8e24d8047a
commit 761086358e
14 files changed
+639 -370

No files matched your search

+7 -6
View File
@@ -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
View File
@@ -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
&middot; Còn lại: <strong class="font-semibold">{state.remaining.length}</strong>
Đã xổ: <strong class="font-semibold">{masterState.called.length}</strong>/90
&middot; 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
View File
@@ -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
);
-52
View File
@@ -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 };
}
-150
View File
@@ -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);
});
});
-22
View File
@@ -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;
}
-35
View File
@@ -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();
});
});
+39
View File
@@ -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.
+44
View File
@@ -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());
});
});
+98
View File
@@ -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 = [];
}
+95
View File
@@ -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([]);
});
});
+57
View File
@@ -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 };
}
+135
View File
@@ -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);
});
});
+8 -4
View File
@@ -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()}