mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 16:15:17 +00:00
feat: three-mode display (player/master/both) with master auto-tick
- Add `mode` setting to replace legacy `masterMode` with migration path - Implement 3-button display mode picker (player-only, master-only, both) - Auto-increment master when card drawn via call-bus event system - Add voice hint on settings button, gate auto-call on mode changes - Broadcast draw events from MasterPanel, reset call-bus on new game - Broadened announce condition to cover both modes - New call-bus.svelte.js module for event coordination - Update settings-store tests to cover mode migration - Update codebase docs for mode setting and call-bus architecture
This commit is contained in:
1 parent
7d1bb5e68a
commit
d08f25bd96
17 files changed
+937
-101
No files matched your search
@@ -6,24 +6,25 @@
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/routes/+layout.svelte` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. |
|
||||
| `src/routes/+page.svelte` | Player page (`/`) — the ONLY page. Header + SettingsButton, PlayerBoard, conditional MasterPanel (when `settings.masterMode`), PageFooter. Indigo→purple gradient branding. |
|
||||
| `src/routes/+page.svelte` | Single page (`/`). Header + SettingsButton, renders player/master/both via `settings.mode`. Indigo→purple gradient branding. |
|
||||
|
||||
### Shared Components
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `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), 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`). 5 fieldsets: Giao diện (theme pills), Chế độ quản trò (switch row), Tự động xổ (switch + speed slider), Âm thanh (two switches + voice picker), Màu ô trống (10 Excel color swatches + custom picker). Boolean toggles use a shared `switchRow` snippet (`role="switch"` + keyboard support). Reset-to-default button. Mounted on `/`. |
|
||||
| `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). No host's own player card (the player already has one above). "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.masterMode === true`; 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). Publishes draws to `call-bus` for player auto-tick. "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.mode !== "player"`; the wrapping section uses `transition:slide` for smooth toggle-in. |
|
||||
| `src/lib/PageFooter.svelte` | Footer with tagline ("Made by miti99 with ❤️ SVG icon") + link. Mounted on `/`. |
|
||||
|
||||
### Game Logic
|
||||
### Game Logic & Coordination
|
||||
| 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/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`. |
|
||||
| `src/lib/settings-store.svelte.js` | Reactive global UI settings via Svelte 5 runes. Stores 8 keys: `theme` (enum: "auto" / "light" / "dark"), `masterMode` (bool), `autoCallEnabled` (bool), `autoCallSpeed` (1–10), `emptyCellColor` (hex), `voiceEnabledMaster` (bool), `voiceEnabledPlayer` (bool), `voice` (string id matching audio manifest). Persisted to localStorage `loto_settings`. Pushes values to CSS vars and `<html class="dark">` on `:root`. Per-key validators preserve old data. |
|
||||
| `src/lib/settings-store.svelte.js` | Reactive global UI settings via Svelte 5 runes. Stores 9 keys: `theme` (enum: "auto" / "light" / "dark"), `mode` (enum: "player" / "master" / "both"; replaces legacy `masterMode`), `autoCallEnabled` (bool), `autoCallSpeed` (1–10), `emptyCellColor` (hex), `voiceEnabledMaster` (bool), `voiceEnabledPlayer` (bool), `voiceWaitingNumber` (bool), `voice` (string id matching audio manifest). Persisted to localStorage `loto_settings`. Pushes values to CSS vars and `<html class="dark">` on `:root`. Per-key validators preserve old data. One-shot migration: `masterMode: true` → `mode: "both"`. |
|
||||
|
||||
### Styling
|
||||
| File | Purpose |
|
||||
@@ -65,18 +66,19 @@
|
||||
| `loto_grid` | Player's card numbers. |
|
||||
| `loto_crossed` | Player's marked cells. |
|
||||
| `loto_master` | Host's drawn/remaining numbers. |
|
||||
| `loto_settings` | Global UI settings: `{ theme, masterMode, autoCallEnabled, autoCallSpeed, emptyCellColor }`. |
|
||||
| `loto_settings` | Global UI settings: `{ theme, mode, autoCallEnabled, autoCallSpeed, emptyCellColor, voiceEnabledMaster, voiceEnabledPlayer, voiceWaitingNumber, voice }`. |
|
||||
|
||||
`loto_master_card_*` keys are no longer written (host's own player card removed) but old saved data is left untouched.
|
||||
Old `masterMode` bool is migrated on load: `masterMode: true` → `mode: "both"`. `loto_master_card_*` keys no longer written but old saved data untouched.
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
RootLayout
|
||||
└── HomePage (/) ← single page; any other URL redirects to /
|
||||
├── PlayerBoard (storagePrefix="loto")
|
||||
├── [if settings.masterMode]
|
||||
│ └── MasterPanel (controls, history, 11×9 tracking grid)
|
||||
├── [if settings.mode !== "master"]
|
||||
│ └── PlayerBoard (storagePrefix="loto")
|
||||
├── [if settings.mode !== "player"]
|
||||
│ └── MasterPanel (publishes to call-bus on draws)
|
||||
└── PageFooter
|
||||
```
|
||||
|
||||
|
||||
@@ -62,6 +62,16 @@ All colors have corresponding `dark:` variants. Tailwind's `prefers-color-scheme
|
||||
- **Max width**: `max-w-lg` (player), `max-w-2xl` (host) — centered with `mx-auto`.
|
||||
- **Flex wrap**: Use `flex flex-wrap gap-1.5` for number history chips.
|
||||
|
||||
## Settings & UI Toggles
|
||||
|
||||
### Mode Picker (SettingsButton)
|
||||
Three-way toggle in settings modal:
|
||||
- **Player mode** (`mode: "player"`) — PlayerBoard only, default UX for players
|
||||
- **Master mode** (`mode: "master"`) — MasterPanel only, host-only view (e.g., projector)
|
||||
- **Both mode** (`mode: "both"`) — PlayerBoard + MasterPanel stacked inline, master auto-ticks player board on draw
|
||||
|
||||
Mode selection is persisted to localStorage and applied immediately.
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Button Styles
|
||||
|
||||
@@ -4,6 +4,14 @@ This document tracks **future work only**. Completed features live in
|
||||
git commit history and `plans/`, not here. For "what's in the app right
|
||||
now", see `docs/project-overview-pdr.md` and `docs/codebase-summary.md`.
|
||||
|
||||
## Shipped
|
||||
|
||||
### Three-Mode Rendering + Master Auto-Tick (2026-04-27)
|
||||
- `mode: "player" | "master" | "both"` replaces `masterMode: boolean`
|
||||
- New `call-bus.svelte.js` coordinates master draws → player auto-tick in "both" mode
|
||||
- "Quản trò đọc số" voice now speaks Chờ/Kinh on master in "both" mode
|
||||
- One-shot migration: `masterMode: true` → `mode: "both"`
|
||||
|
||||
## Idea Phase
|
||||
|
||||
### Undo Last Cell
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
|
||||
```
|
||||
Entry (+layout.svelte)
|
||||
├─ onMount: loadSettings() — restore all 5 settings keys from loto_settings,
|
||||
├─ onMount: loadSettings() — restore all 9 settings keys from loto_settings,
|
||||
│ apply CSS vars, toggle <html class="dark"> on theme/OS pref, setup auto-call effect
|
||||
│
|
||||
└─ / (single page)
|
||||
├─ Load loto_grid, loto_crossed from localStorage
|
||||
├─ Display 9×9 PlayerBoard (empty cells use --empty-cell-bg from settings)
|
||||
├─ Generate new grid on button click
|
||||
├─ Mark/unmark cells on click
|
||||
├─ Show bingo popup + "Chờ X" toasts
|
||||
├─ [if settings.masterMode === true]
|
||||
│ └─ Mount MasterPanel (host controls + draw history)
|
||||
├─ [if settings.mode !== "master"]
|
||||
│ ├─ Load loto_grid, loto_crossed from localStorage
|
||||
│ ├─ Display 9×9 PlayerBoard (empty cells use --empty-cell-bg from settings)
|
||||
│ ├─ Listen to call-bus for auto-tick when master draws
|
||||
│ ├─ Generate new grid on button click
|
||||
│ ├─ Mark/unmark cells on click
|
||||
│ └─ Show bingo popup + "Chờ X" toasts
|
||||
├─ [if settings.mode !== "player"]
|
||||
│ └─ Mount MasterPanel (controls + draw history, publishes to call-bus)
|
||||
└─ PageFooter (tagline + miti99 link)
|
||||
```
|
||||
|
||||
@@ -32,11 +34,15 @@ ascending top-to-bottom (lô tô hội chợ Tân Tân convention).
|
||||
|
||||
### Settings (`loto_settings`)
|
||||
```
|
||||
theme: "auto" | "light" | "dark" // Display mode
|
||||
masterMode: boolean // Show MasterPanel on /
|
||||
autoCallEnabled: boolean // Enable auto-call timer
|
||||
autoCallSpeed: number (1–10) // Speed in seconds
|
||||
emptyCellColor: "#rrggbb" // Hex color (default #7030A0 Excel Purple)
|
||||
theme: "auto" | "light" | "dark" // Display mode
|
||||
mode: "player" | "master" | "both" // Panel visibility (replaces masterMode)
|
||||
autoCallEnabled: boolean // Enable auto-call timer
|
||||
autoCallSpeed: number (1–10) // Speed in seconds
|
||||
emptyCellColor: "#rrggbb" // Hex color (default #7030A0 Excel Purple)
|
||||
voiceEnabledMaster: boolean // Speak called numbers (master view)
|
||||
voiceEnabledPlayer: boolean // Speak "Chờ"/"Kinh" (player events)
|
||||
voiceWaitingNumber: boolean // Include number after "Chờ"
|
||||
voice: string // Voice ID from audio manifest
|
||||
```
|
||||
|
||||
### Host State (`storagePrefix="loto_master"`)
|
||||
@@ -107,7 +113,19 @@ Files that are client-only:
|
||||
- `src/lib/PlayerBoard.svelte` (player card component)
|
||||
- `src/lib/MasterPanel.svelte` (host panel, mounted when `settings.masterMode`)
|
||||
|
||||
## Data Flow: Mark a Cell
|
||||
## Data Flow: Master Draw (mode: "both")
|
||||
|
||||
```
|
||||
1. Master taps "Xổ số" button in MasterPanel
|
||||
2. Draw logic removes number from remaining
|
||||
3. broadcastDraw(num) publishes to call-bus
|
||||
4. PlayerBoard's $effect listens to bus.lastDrawn
|
||||
5. Auto-marks cell in player's grid (if it exists)
|
||||
6. If row completes → bingo popup + auto "Kinh" voice
|
||||
7. MasterPanel displays hero token, appends to history
|
||||
```
|
||||
|
||||
## Data Flow: Mark a Cell (Player)
|
||||
|
||||
```
|
||||
1. User clicks button in PlayerBoard
|
||||
@@ -116,7 +134,7 @@ Files that are client-only:
|
||||
4. $effect listens to crossed → saveCrossedState()
|
||||
5. localStorage updated with new crossed state
|
||||
6. $derived updates rowCompleteness matrix
|
||||
7. If row complete → bingo popup; if waiting → toast
|
||||
7. If row complete → bingo popup; if waiting → "Chờ X" toast
|
||||
```
|
||||
|
||||
## Data Flow: Initial Load
|
||||
@@ -141,9 +159,27 @@ Files that are client-only:
|
||||
| toast | 5s forwards | "Chờ X" notification fade in/build |
|
||||
| cell-crossed::after | instant | Red diagonal line in marked cells |
|
||||
|
||||
## Call Bus (Master ↔ Player Wiring)
|
||||
|
||||
In `mode: "both"`, MasterPanel and PlayerBoard communicate via `call-bus.svelte.js`:
|
||||
|
||||
```
|
||||
call-bus.svelte.js:
|
||||
├─ bus.lastDrawn = { num, at } // Published by master draw
|
||||
├─ broadcastDraw(num) // Called by MasterPanel on each draw
|
||||
└─ resetBus() // Called on new game
|
||||
|
||||
PlayerBoard:
|
||||
└─ $effect(() => { bus.lastDrawn }) // Listens for draw broadcast
|
||||
└─ Auto-marks cell if number exists on board
|
||||
└─ Triggers bingo popup if row completes
|
||||
```
|
||||
|
||||
Each draw creates a new object (even repeat numbers) to ensure reactive re-fire.
|
||||
|
||||
## Offline Capability
|
||||
|
||||
All state is localStorage. No API calls. Fully functional offline after initial load.
|
||||
|
||||
Last reviewed: 2026-04-27
|
||||
Last synced: 2026-04-27 (6-phase refactor)
|
||||
Last synced: 2026-04-27 (three-mode + auto-tick feature)
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# Phase 1 — Three-mode settings (player / master / both)
|
||||
|
||||
## Overview
|
||||
|
||||
Replace `masterMode: boolean` with `mode: "player" | "master" | "both"`.
|
||||
Migrate existing saved state. Update settings UI from a single switch
|
||||
to a 3-button segmented picker. Conditionally render PlayerBoard
|
||||
and/or MasterPanel on `/` based on the new mode.
|
||||
|
||||
**Status**: not started
|
||||
**Priority**: P0 (foundational for phases 2 & 3)
|
||||
**Effort**: ~25 min
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/lib/settings-store.svelte.js` — replace key, add validator,
|
||||
migration in `loadSettings`, update `resetSettings`
|
||||
- `src/lib/settings-store.test.js` — update assertions
|
||||
- `src/lib/SettingsButton.svelte` — replace switch with 3-button picker
|
||||
- `src/routes/+page.svelte` — `mode === "master"` hides PlayerBoard;
|
||||
`mode === "player"` hides MasterPanel; `mode === "both"` renders both
|
||||
|
||||
## Key design
|
||||
|
||||
```js
|
||||
// settings-store.svelte.js
|
||||
export const DEFAULT_SETTINGS = Object.freeze({
|
||||
// ...existing keys...
|
||||
mode: /** @type {"player"|"master"|"both"} */ ("player"),
|
||||
// ...
|
||||
});
|
||||
|
||||
const VALID_MODES = ["player", "master", "both"];
|
||||
|
||||
function validMode(v) {
|
||||
return typeof v === "string" && VALID_MODES.includes(v) ? v : null;
|
||||
}
|
||||
|
||||
// In loadSettings:
|
||||
const parsedMode = validMode(parsed.mode);
|
||||
if (parsedMode) {
|
||||
settings.mode = parsedMode;
|
||||
} else if (parsed.masterMode === true) {
|
||||
settings.mode = "both"; // legacy migration
|
||||
} else {
|
||||
settings.mode = DEFAULT_SETTINGS.mode;
|
||||
}
|
||||
```
|
||||
|
||||
Drop `masterMode` from the saved JSON shape in `saveSettings` (which
|
||||
spreads `{ ...settings }`, so just don't keep `masterMode` on
|
||||
`settings`). Keep validator/load fallback for one release in case any
|
||||
future user reverts — actually no, YAGNI. Drop cleanly.
|
||||
|
||||
## UI — segmented picker
|
||||
|
||||
In `SettingsButton.svelte`, replace the single `switchRow` for
|
||||
`masterMode` with a 3-button group, mirroring the theme picker pattern
|
||||
already in the file:
|
||||
|
||||
```svelte
|
||||
<fieldset class="mb-5">
|
||||
<legend class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2">
|
||||
Chế độ hiển thị
|
||||
</legend>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">
|
||||
Chọn vai trò để chỉ hiện phần liên quan
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each MODES as [v, label] (v)}
|
||||
{@const selected = settings.mode === v}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onclick={() => pickMode(v)}
|
||||
class="px-2 py-2 rounded-lg border-2 text-sm font-medium transition-all
|
||||
{selected
|
||||
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300'
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-500'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
```js
|
||||
const MODES = /** @type {const} */ ([
|
||||
["player", "Người chơi"],
|
||||
["master", "Quản trò"],
|
||||
["both", "Cả hai"],
|
||||
]);
|
||||
|
||||
function pickMode(m) {
|
||||
settings.mode = m;
|
||||
saveSettings();
|
||||
}
|
||||
```
|
||||
|
||||
The "Tự động xổ" sub-fieldset, currently gated by `settings.masterMode`,
|
||||
becomes gated by `settings.mode !== "player"` (i.e. master is visible).
|
||||
|
||||
## +page.svelte — conditional render
|
||||
|
||||
Replace the existing `{#if settings.masterMode}` MasterPanel block and
|
||||
the unconditional `<PlayerBoard />` with:
|
||||
|
||||
```svelte
|
||||
{#if settings.mode !== "master"}
|
||||
<PlayerBoard />
|
||||
{/if}
|
||||
|
||||
{#if settings.mode !== "player"}
|
||||
<section class="mt-10" aria-label="Bảng quản trò"
|
||||
transition:slide={{ duration: 250 }}>
|
||||
<h2 class="...">Quản trò</h2>
|
||||
<MasterPanel />
|
||||
</section>
|
||||
{/if}
|
||||
```
|
||||
|
||||
Keep the section heading only when there's a player board above it
|
||||
(otherwise it's redundant). Conditional:
|
||||
|
||||
```svelte
|
||||
{#if settings.mode === "both"}
|
||||
<h2 class="...">Quản trò</h2>
|
||||
{/if}
|
||||
```
|
||||
|
||||
In `mode === "master"`, the page still has the brand header — the
|
||||
master panel becomes the main content area.
|
||||
|
||||
## Tests
|
||||
|
||||
Update `settings-store.test.js`:
|
||||
- `mode` defaults to `"player"`.
|
||||
- Persistence round-trip includes `mode`.
|
||||
- Legacy `masterMode: true` in stored JSON migrates to `mode: "both"`.
|
||||
- Legacy `masterMode: false` (or missing) → `mode: "player"`.
|
||||
- Invalid `mode` value falls back to default.
|
||||
- Drop the old `masterMode` assertions in the "persists ALL keys" test.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Add `mode` to `DEFAULT_SETTINGS`, drop `masterMode`
|
||||
- [ ] Add `validMode` helper
|
||||
- [ ] Implement migration in `loadSettings`
|
||||
- [ ] Update `resetSettings`
|
||||
- [ ] Update tests
|
||||
- [ ] Replace `masterMode` switch with 3-button picker in
|
||||
`SettingsButton.svelte`
|
||||
- [ ] Re-gate "Tự động xổ" fieldset on `mode !== "player"`
|
||||
- [ ] Update `+page.svelte` conditional rendering
|
||||
- [ ] `npm test && npm run build` — green
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `npm test` passes.
|
||||
- `npm run build` clean.
|
||||
- Manual: refresh with `localStorage.loto_settings = '{"masterMode":true}'`
|
||||
→ after load, settings panel shows "Cả hai" selected, both panels
|
||||
visible.
|
||||
- Manual: pick "Quản trò" → PlayerBoard disappears, MasterPanel shows
|
||||
alone.
|
||||
- Manual: pick "Người chơi" → MasterPanel disappears.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Touching shared settings store breaks other consumers**: low —
|
||||
voice flags and color settings are independent.
|
||||
- **Slide transition jank** when MasterPanel appears alone: the
|
||||
existing `transition:slide` is on the `<section>`. Will visually
|
||||
test in `master` mode where it's the only thing rendering.
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# Phase 2 — Master broadcast + player auto-tick
|
||||
|
||||
## Overview
|
||||
|
||||
In `mode === "both"`, when MasterPanel draws a number, PlayerBoard
|
||||
auto-marks that number on its grid (if present). Achieved with a tiny
|
||||
shared store that broadcasts `lastDrawn`. PlayerBoard reacts via
|
||||
`$effect`. Manual cell taps still work as before.
|
||||
|
||||
**Status**: not started
|
||||
**Priority**: P1
|
||||
**Effort**: ~35 min
|
||||
**Depends on**: Phase 1 (uses `settings.mode === "both"`)
|
||||
|
||||
## Files to create
|
||||
|
||||
- `src/lib/call-bus.svelte.js` — single reactive `lastDrawn` slot
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/lib/MasterPanel.svelte` — write to bus on draw
|
||||
- `src/lib/PlayerBoard.svelte` — read bus, auto-tick if present
|
||||
- `src/lib/call-bus.test.js` — new test file
|
||||
|
||||
## Design
|
||||
|
||||
### call-bus.svelte.js
|
||||
|
||||
```js
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
```
|
||||
|
||||
`at` is a tiebreaker: if master draws 17, player marks; player un-marks
|
||||
manually; master draws 17 again (impossible in normal play but
|
||||
possible after "Ván mới") — the new object reference triggers the
|
||||
effect cleanly.
|
||||
|
||||
Reset is called from MasterPanel's `handleNewGame` so a stale
|
||||
`lastDrawn` from a previous game can't race the new player grid.
|
||||
|
||||
### MasterPanel changes
|
||||
|
||||
In `handleDrawNext`:
|
||||
|
||||
```js
|
||||
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;
|
||||
scrollOnNextDraw = true;
|
||||
broadcastDraw(next); // NEW
|
||||
if (settings.voiceEnabledMaster) playNumber(next);
|
||||
}
|
||||
```
|
||||
|
||||
In `handleNewGame`, after creating fresh state, call `resetBus()`.
|
||||
|
||||
### PlayerBoard changes
|
||||
|
||||
Add a new `$effect` watching the bus:
|
||||
|
||||
```js
|
||||
import { bus } from "$lib/call-bus.svelte.js";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
|
||||
$effect(() => {
|
||||
const drawn = bus.lastDrawn;
|
||||
if (!drawn) return;
|
||||
if (settings.mode !== "both") return;
|
||||
if (!grid || crossed.length === 0) return;
|
||||
|
||||
// Find first occurrence (numbers are unique on a 9×9 lô tô card)
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] === drawn.num && !crossed[r][c]) {
|
||||
crossed = crossed.map((row, ri) =>
|
||||
ri === r ? row.map((v, ci) => (ci === c ? true : v)) : row,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Important**: only sets to `true`, never toggles off. If the cell is
|
||||
already crossed (manually), the effect short-circuits — no double-toggle
|
||||
bug.
|
||||
|
||||
Mode check is inside the effect, not a top-level guard, so the effect
|
||||
remains reactive to `settings.mode` (Svelte 5 runes track reads).
|
||||
|
||||
### Test plan (call-bus.test.js)
|
||||
|
||||
- `broadcastDraw(n)` updates `bus.lastDrawn.num` to `n`.
|
||||
- Calling twice with the same number yields a different object
|
||||
reference (so `$effect` re-fires).
|
||||
- `resetBus()` sets `bus.lastDrawn = null`.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Create `src/lib/call-bus.svelte.js`
|
||||
- [ ] Wire `broadcastDraw` into `handleDrawNext`
|
||||
- [ ] Wire `resetBus` into `handleNewGame`
|
||||
- [ ] Add bus-watching `$effect` in PlayerBoard
|
||||
- [ ] Confirm auto-tick fires Bingo/Chờ effects naturally (existing
|
||||
`$effect` on `crossed` will pick up the change)
|
||||
- [ ] Write `call-bus.test.js`
|
||||
- [ ] `npm test && npm run build` — green
|
||||
- [ ] Manual: in "Cả hai", draw → matching cell marks itself.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- All tests pass.
|
||||
- In `both` mode, drawing a number visible on the player grid
|
||||
auto-marks it; the existing waiting/bingo logic fires correctly.
|
||||
- In `player` or `master` mode alone, no cross-component effects.
|
||||
- Manual taps still toggle cells normally (no interference).
|
||||
- Auto-tick never un-marks a cell that's already crossed.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Effect runs on hot-reload with stale state**: Svelte 5 effects
|
||||
re-run on dep changes. The mode/grid guard makes this safe.
|
||||
- **Visual surprise**: host might want "preview" before marking.
|
||||
Decision: auto-tick is the point of this phase; not making it
|
||||
optional in v1. Can add a `autoTick` setting later if requested.
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Phase 3 — Master speaks Chờ / Kinh
|
||||
|
||||
## Overview
|
||||
|
||||
When `voiceEnabledMaster` is on AND `mode === "both"`, the master also
|
||||
speaks "Chờ" / "Kinh" announcements as the player board hits those
|
||||
states. Player voice toggle continues to work in solo `player` mode.
|
||||
No new setting; the existing `voiceEnabledMaster` flag covers it.
|
||||
|
||||
**Status**: not started
|
||||
**Priority**: P1
|
||||
**Effort**: ~20 min
|
||||
**Depends on**: Phase 1 (mode check)
|
||||
**Independent of**: Phase 2 (different code path, different effects)
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/lib/PlayerBoard.svelte` — broaden the voice trigger condition
|
||||
on the existing Bingo + Chờ effects
|
||||
- (optional) `src/lib/SettingsButton.svelte` — small text update on
|
||||
the "Quản trò đọc số" hint to mention Chờ/Kinh
|
||||
|
||||
## Design
|
||||
|
||||
Today's PlayerBoard runs:
|
||||
|
||||
```js
|
||||
if (settings.voiceEnabledPlayer) playBingo();
|
||||
// ...
|
||||
if (settings.voiceEnabledPlayer) playWaiting(waitNum);
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```js
|
||||
if (shouldAnnouncePlayerVoice()) playBingo();
|
||||
// ...
|
||||
if (shouldAnnouncePlayerVoice()) playWaiting(waitNum);
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
```js
|
||||
function shouldAnnouncePlayerVoice() {
|
||||
return (
|
||||
settings.voiceEnabledPlayer ||
|
||||
(settings.voiceEnabledMaster && settings.mode === "both")
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Inline the boolean if the helper feels heavy — DRY-but-tiny:
|
||||
|
||||
```js
|
||||
const announce =
|
||||
settings.voiceEnabledPlayer ||
|
||||
(settings.voiceEnabledMaster && settings.mode === "both");
|
||||
if (announce) playBingo();
|
||||
```
|
||||
|
||||
### Why no new setting
|
||||
|
||||
- `voiceEnabledMaster` already implies "the host's audio role".
|
||||
- In `both` mode, the master IS the announcer; Chờ/Kinh are part of
|
||||
what an announcer says.
|
||||
- Adds no UX complexity; the existing toggle just covers more cases.
|
||||
|
||||
### Why this can ship without phase 2
|
||||
|
||||
The Chờ/Kinh announcements fire from the player board's existing
|
||||
effect on `crossed` changes. Those changes happen whether the cell
|
||||
was crossed manually OR by phase 2's auto-tick. So phase 3 works
|
||||
solo: player taps a cell that completes a row → master speaks "Kinh"
|
||||
in `both` mode.
|
||||
|
||||
### Settings hint copy
|
||||
|
||||
Current "Quản trò đọc số" has no description. Add a one-line note:
|
||||
|
||||
```svelte
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1.5 px-1">
|
||||
Đọc số đã xổ + báo Chờ/Kinh khi ở "Cả hai".
|
||||
</p>
|
||||
```
|
||||
|
||||
(Only when in master-visible modes, to keep the panel uncluttered.)
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Add `announce` derived boolean in PlayerBoard before each
|
||||
voice call
|
||||
- [ ] Update both `playBingo()` and `playWaiting()` call sites
|
||||
- [ ] Add small clarifying hint under "Quản trò đọc số"
|
||||
- [ ] `npm test && npm run build` — green
|
||||
- [ ] Manual smoke:
|
||||
- mode=player, voicePlayer=on, voiceMaster=off → Chờ/Kinh play
|
||||
- mode=player, voicePlayer=off → silent
|
||||
- mode=both, voicePlayer=off, voiceMaster=on → Chờ/Kinh play
|
||||
- mode=both, voicePlayer=off, voiceMaster=off → silent
|
||||
- mode=master alone → no player events to announce
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Existing player-voice tests still pass.
|
||||
- New behavior verified manually with above matrix.
|
||||
- Voice cancellation logic in `voice.js` is unchanged; double-trigger
|
||||
scenarios resolve as "last wins" (already covered by existing
|
||||
cancellation pattern).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Surprise users who turned off player voice on purpose**: low —
|
||||
the master flag is on by default and they likely want all master
|
||||
audio. If they specifically want master-number-only, they'd need a
|
||||
finer-grained setting; out of scope for this iteration. Note in
|
||||
follow-up.
|
||||
- **Two clips overlapping**: if a draw fires `playNumber` (master) and
|
||||
the player auto-tick (phase 2) fires `playWaiting` immediately,
|
||||
voice.js cancels the in-flight clip. The most recent call wins.
|
||||
Acceptable tradeoff; the alternative (queueing) creates speech lag.
|
||||
|
||||
## Follow-up (out of scope)
|
||||
|
||||
- A "what does master speak?" sub-panel: number / Chờ / Kinh as
|
||||
individual toggles. Add only if a real user asks.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
slug: three-mode-and-master-auto-tick
|
||||
created: 2026-04-27
|
||||
status: completed
|
||||
completedAt: 2026-04-27
|
||||
mode: fast
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# Three-mode settings + master auto-tick + master Chờ/Kinh
|
||||
|
||||
## What this changes (in user terms)
|
||||
|
||||
- Settings replaces the single "Hiện bảng quản trò" switch with a 3-way
|
||||
picker: **Người chơi** (player only — today's default), **Quản trò**
|
||||
(master only — for projector/casting host), **Cả hai** (both inline,
|
||||
same as today's `masterMode=true`).
|
||||
- In **Cả hai** mode, when the master draws a number, the player board
|
||||
automatically marks that number if present — host doesn't have to
|
||||
tap twice.
|
||||
- "Quản trò đọc số" now ALSO speaks **Chờ** / **Kinh** announcements
|
||||
whenever they trigger (in addition to the called number). Player
|
||||
voice toggle stays for solo-player mode.
|
||||
|
||||
## Decisions (locked unless flagged)
|
||||
|
||||
- `masterMode: boolean` → `mode: "player" | "master" | "both"`. Saved
|
||||
data with `masterMode: true` migrates to `mode: "both"`; everything
|
||||
else falls to `"player"`.
|
||||
- Defaults: `mode = "player"`, `voiceEnabledMaster = true`,
|
||||
`voiceEnabledPlayer = false`, `voiceWaitingNumber = false` (unchanged).
|
||||
- Master ↔ Player wiring uses a tiny shared store (`call-bus.svelte.js`)
|
||||
— one reactive `lastDrawn` slot. Avoids prop-drilling through the
|
||||
page route, keeps each component self-contained.
|
||||
- Auto-tick is **only** active in `mode === "both"`. In `master` alone
|
||||
there is no player board, and in `player` alone there is no master.
|
||||
- "Master speaks Chờ/Kinh" is a behavior change of the existing
|
||||
`voiceEnabledMaster` flag — no new setting. Player voice events fire
|
||||
when `voiceEnabledPlayer` OR (`voiceEnabledMaster` AND `mode === "both"`).
|
||||
- No localStorage breakage: keeping all existing keys, only adding
|
||||
`mode`. Old `masterMode` is migrated then ignored.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | File | Status | Effort |
|
||||
|---|------|--------|--------|
|
||||
| 1 | `phase-01-three-mode-settings.md` | done | 25 min |
|
||||
| 2 | `phase-02-master-broadcast-and-auto-tick.md` | done | 35 min |
|
||||
| 3 | `phase-03-master-cho-kinh-announcements.md` | done | 20 min |
|
||||
|
||||
Total: ~80 min. Each phase ships independently, tests pass after each.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- None external. All work in `src/lib/` and `src/routes/+page.svelte`.
|
||||
- Phase 2 depends on phase 1 (mode === "both" check).
|
||||
- Phase 3 depends on phase 1 (mode === "both" check) but can ship
|
||||
independent of phase 2.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Migration silently dropping master mode users**: anyone currently
|
||||
using `masterMode: true` will get `mode: "both"` after the migration
|
||||
runs once. Verified by test (phase 1).
|
||||
- **Auto-tick + manual cross collision**: if a player crosses a cell,
|
||||
then master draws the same number, marking the cell again would
|
||||
toggle it OFF. Fix: auto-tick only sets `crossed=true` when not
|
||||
already true; never toggles.
|
||||
- **Voice race**: if master draws and the player board immediately
|
||||
hits Chờ in `both` mode, two clips would queue. Voice module already
|
||||
cancels in-flight playback on each `play*()` call — last wins. Verify.
|
||||
|
||||
## Rollback
|
||||
|
||||
Single-commit revert per phase. No DB, no migrations beyond a one-shot
|
||||
read transformation in `loadSettings()`.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Code Review — three-mode + auto-tick + master Chờ/Kinh
|
||||
|
||||
## Scope
|
||||
- Files: settings-store(.svelte.js + .test.js), SettingsButton.svelte, +page.svelte, call-bus(.svelte.js + .test.js), MasterPanel.svelte, PlayerBoard.svelte, voice.js
|
||||
- Focus: three-mode picker, legacy migration, master→player draw bus, auto-tick gating, master voice for Chờ/Kinh in `both`
|
||||
- Test/typecheck: 106/106 passing; `svelte-check` 0 errors / 0 warnings
|
||||
|
||||
## Overall Assessment
|
||||
Clean, small, idiomatic Svelte 5. Bus-as-rune is the right primitive — fresh-object trick (`{num,at}`) handles repeats correctly. Set-only auto-tick eliminates manual-vs-auto fights by construction. Migration is pure and one-shot via `saveSettings`. One real test bug found; everything else is minor.
|
||||
|
||||
## Critical Issues
|
||||
None.
|
||||
|
||||
## High Priority
|
||||
|
||||
### 1. Stale `masterMode` assertion in `settings-store.test.js:189` (vacuous test)
|
||||
```js
|
||||
expect(settings.masterMode).toBe(DEFAULT_SETTINGS.masterMode);
|
||||
```
|
||||
Both sides are `undefined` after the `mode` rename — the assertion passes for the wrong reason and gives false coverage. Replace with `expect(settings.mode).toBe(DEFAULT_SETTINGS.mode);` or delete (the per-key fallback is already covered elsewhere).
|
||||
|
||||
## Medium Priority
|
||||
|
||||
### 2. Auto-tick effect early-return collapses dependency tracking
|
||||
`PlayerBoard.svelte:134` reads `bus.lastDrawn` first (good) but the subsequent `settings.mode !== "both"` early-return means Svelte stops tracking on that path. In practice this works because the *next* draw retriggers via `bus.lastDrawn` and re-reads `settings.mode`. The user-stated intent ("changing mode mid-game tears down/re-arms cleanly") holds for *future* draws, **not retroactively** — a number drawn while in `master` mode and then switching to `both` will not back-fill ticks. If retroactive backfill is desired, tick from `state.called` on mode change. If not (likely correct UX — auto-tick is forward-only), add a one-line comment so a future reader doesn't think it's a bug.
|
||||
|
||||
### 3. Master voice cancellation collides with player voice in `both` mode
|
||||
Both panels share the same `voice.js` singleton (`activeClip`/`activeToken`). Sequence in `both`+both-voices-on: master draws → `playNumber(n)` starts → player auto-tick fires the same render → if the auto-tick completes a row, `playBingo()` cancels the in-flight number clip. End-user effect: "Bingo!" wins, number announcement gets cut off. This is arguably correct (Kinh is more important), but the tradeoff is undocumented. Consider: (a) leave + comment, or (b) queue Kinh after current clip via a small chain. KISS says leave it — but flag it in CHANGELOG so the "voice cuts off sometimes" report doesn't surprise you.
|
||||
|
||||
### 4. Voice flag composition repeated as inline expression
|
||||
`PlayerBoard.svelte:98-99` recomputes `voiceEnabledPlayer || (voiceEnabledMaster && mode === "both")` inline. If a third caller appears, factor a `$derived` on `settings` (stays one place). Skip for YAGNI.
|
||||
|
||||
## Low Priority
|
||||
- `+page.svelte:42` — `transition:slide` on the master section is fine but plays on every mount including initial page load. Acceptable; minor.
|
||||
- `call-bus.svelte.js` — module-level `$state` is fine for SvelteKit SSR (no client state leak across requests since it's reset on each app instance), but a comment noting this would help future readers who worry about it.
|
||||
|
||||
## Edge Cases (scout)
|
||||
- **Repeat draw**: bus publishes new object → effect re-runs → set-only guard finds cell already true → no-op. Correct.
|
||||
- **`Ván mới` mid-auto**: `MasterPanel.handleNewGame` calls `cancelPlayback()` + `autoRunning=false` + `resetBus()`. Bus reset to `null`, player effect early-returns. Correct.
|
||||
- **Mode flip mid-game from `both`→`master`**: PlayerBoard unmounts → `cancelPlayback()` cleanup runs → master clip stops. Master's `playNumber` next draw re-acquires audio. Correct.
|
||||
- **Migration idempotency**: `saveSettings` writes `mode:"both"` and drops `masterMode` key. Subsequent loads take the `validMode` branch. Correct.
|
||||
- **localStorage QuotaExceeded mid-save**: silently swallowed (existing behavior). OK.
|
||||
|
||||
## Test Coverage Gaps
|
||||
- No test asserts `bus.lastDrawn` integration with PlayerBoard auto-tick (would require a Svelte component test harness — currently the suite is unit-only). Given KISS, a single component-level integration test is worth it: render PlayerBoard with a fixed grid, set `settings.mode="both"`, call `broadcastDraw(n)`, assert `crossed[r][c]===true`. This is the only un-tested branch of the new feature.
|
||||
- No regression test for "auto-tick does NOT fire in `master` or `player` mode" — easy to add alongside the above.
|
||||
|
||||
## Positive Observations
|
||||
- Per-key validation pattern preserved on the new `mode` field — adding it didn't break the existing per-key fallback contract.
|
||||
- Fresh-object publish pattern (`{num, at: Date.now()}`) is the right primitive for "fire on every draw including repeats."
|
||||
- Set-only auto-tick eliminates an entire class of state-fight bugs without locking.
|
||||
- `voice.js` token + `activeResolver` cancellation is correct under interleaved cancel/play and was not regressed.
|
||||
- Migration is one-shot via natural `saveSettings` write — no extra "version" plumbing. KISS.
|
||||
|
||||
## Recommended Actions
|
||||
1. Fix vacuous `masterMode` assertion (test line 189) — high.
|
||||
2. Add one component-level integration test for the auto-tick path — medium.
|
||||
3. Add 1-line comment in PlayerBoard auto-tick `$effect` clarifying "forward-only, no backfill" — medium.
|
||||
4. CHANGELOG note: in `both` mode, completing a row may interrupt the number callout — low.
|
||||
|
||||
## Metrics
|
||||
- Tests: 106/106 pass
|
||||
- svelte-check: 0 errors, 0 warnings
|
||||
- New LOC: ~+50 net (call-bus + tests + PlayerBoard effect)
|
||||
|
||||
## Unresolved Questions
|
||||
- Is master-clip-cut-by-bingo intentional or worth a queue? (Q for product owner — UX call, not a bug.)
|
||||
- Should mode-flip retroactively auto-tick already-called numbers? (Current: no. If "no" is intentional, add comment; otherwise small change to seed from `state.called` once on mode→both.)
|
||||
@@ -63,6 +63,7 @@
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { broadcastDraw, resetBus } from "$lib/call-bus.svelte.js";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
import { cancelPlayback, playNumber } from "$lib/voice.js";
|
||||
|
||||
@@ -135,6 +136,7 @@
|
||||
autoRunning = false;
|
||||
state = createFreshState();
|
||||
lastCalled = null;
|
||||
resetBus();
|
||||
}
|
||||
|
||||
function handleDrawNext() {
|
||||
@@ -146,6 +148,7 @@
|
||||
};
|
||||
lastCalled = next;
|
||||
scrollOnNextDraw = true;
|
||||
broadcastDraw(next);
|
||||
if (settings.voiceEnabledMaster) playNumber(next);
|
||||
}
|
||||
|
||||
@@ -194,7 +197,7 @@
|
||||
|
||||
{#if settings.autoCallEnabled && state && state.remaining.length > 0}
|
||||
<div
|
||||
class="text-center text-xs text-slate-500 dark:text-slate-400 mb-4 tabular-nums"
|
||||
class="text-center text-sm text-slate-600 dark:text-slate-300 mb-4 tabular-nums"
|
||||
>
|
||||
Tự động: {settings.autoCallSpeed}s/số
|
||||
</div>
|
||||
@@ -205,7 +208,7 @@
|
||||
{@const lastIsLow = lastCalled <= 49}
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div
|
||||
class="text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-2"
|
||||
class="text-sm uppercase tracking-[0.2em] font-semibold text-slate-500 dark:text-slate-400 mb-2"
|
||||
>
|
||||
Số vừa xổ
|
||||
</div>
|
||||
@@ -233,9 +236,9 @@
|
||||
</span>
|
||||
</div>
|
||||
{#if state}
|
||||
<div class="mt-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
Đã xổ: {state.called.length}/90 · Còn lại: {state.remaining
|
||||
.length}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -244,19 +247,19 @@
|
||||
<!-- Called history -->
|
||||
{#if state && state.called.length > 0}
|
||||
<div class="mb-6 px-1">
|
||||
<div class="text-xs text-slate-400 dark:text-slate-500 mb-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)}
|
||||
{@const isLow = num <= 49}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9
|
||||
text-sm sm:text-base font-black tabular-nums rounded-full
|
||||
class="inline-flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10
|
||||
text-base sm:text-lg font-black tabular-nums rounded-full
|
||||
border-[3px] bg-amber-50 dark:bg-amber-100
|
||||
{isLow
|
||||
? 'border-pink-500 dark:border-pink-400 text-pink-500 dark:text-pink-400'
|
||||
: 'border-emerald-500 dark:border-emerald-400 text-emerald-500 dark:text-emerald-400'}"
|
||||
? 'border-pink-500 dark:border-pink-400 text-pink-600 dark:text-pink-500'
|
||||
: 'border-emerald-500 dark:border-emerald-400 text-emerald-600 dark:text-emerald-500'}"
|
||||
>
|
||||
{num}
|
||||
</span>
|
||||
@@ -292,13 +295,13 @@
|
||||
<div
|
||||
class="flex items-center justify-center
|
||||
w-[82%] h-[82%] rounded-full
|
||||
text-lg sm:text-xl font-black tabular-nums
|
||||
text-xl sm:text-2xl font-black tabular-nums
|
||||
border-[3px] transition-all
|
||||
{!isCalled
|
||||
? 'border-slate-300 dark:border-slate-600 bg-slate-50/40 dark:bg-slate-700/30 text-slate-400 dark:text-slate-500 opacity-70'
|
||||
: isLow
|
||||
? 'border-pink-500 dark:border-pink-400 bg-amber-50 dark:bg-amber-100 text-pink-500 dark:text-pink-400'
|
||||
: 'border-emerald-500 dark:border-emerald-400 bg-amber-50 dark:bg-amber-100 text-emerald-500 dark:text-emerald-400'}
|
||||
? 'border-pink-500 dark:border-pink-400 bg-amber-50 dark:bg-amber-100 text-pink-600 dark:text-pink-500'
|
||||
: 'border-emerald-500 dark:border-emerald-400 bg-amber-50 dark:bg-amber-100 text-emerald-600 dark:text-emerald-500'}
|
||||
{isLast
|
||||
? 'ring-2 ring-red-500 dark:ring-red-400 ring-offset-1 ring-offset-white dark:ring-offset-slate-800 scale-110 shadow-md'
|
||||
: ''}"
|
||||
@@ -307,7 +310,7 @@
|
||||
</div>
|
||||
{#if isCalled}
|
||||
<span
|
||||
class="absolute top-0.5 right-0.5 text-[9px] sm:text-[10px] font-semibold leading-none text-slate-500 dark:text-slate-400 tabular-nums"
|
||||
class="absolute top-0.5 right-0.5 text-[10px] sm:text-[11px] font-bold leading-none text-slate-600 dark:text-slate-300 tabular-nums"
|
||||
>
|
||||
{order}
|
||||
</span>
|
||||
@@ -318,7 +321,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center text-slate-400 dark:text-slate-500 py-10 text-sm">
|
||||
<div class="text-center text-slate-500 dark:text-slate-400 py-10 text-base">
|
||||
Nhấn "Ván mới" để bắt đầu
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { bus } from "$lib/call-bus.svelte.js";
|
||||
import {
|
||||
generateGrid,
|
||||
getWaitingNumber,
|
||||
@@ -91,6 +92,12 @@
|
||||
$effect(() => {
|
||||
if (!grid || crossed.length === 0) return;
|
||||
|
||||
// The master takes over announcer duties in "both" mode, so its
|
||||
// voice flag also drives Chờ/Kinh. Solo players keep their own flag.
|
||||
const announce =
|
||||
settings.voiceEnabledPlayer ||
|
||||
(settings.voiceEnabledMaster && settings.mode === "both");
|
||||
|
||||
// Pass 1: at most one bingo popup per render
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
|
||||
@@ -99,7 +106,7 @@
|
||||
congratsRow = i + 1;
|
||||
showCongrats = true;
|
||||
celebrationTier = celebratedRows.size >= 3 ? 2 : 1;
|
||||
if (settings.voiceEnabledPlayer) playBingo();
|
||||
if (announce) playBingo();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +118,7 @@
|
||||
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
|
||||
notifiedWaitingRows.add(i);
|
||||
showToast(`Chờ ${waitNum}`);
|
||||
if (settings.voiceEnabledPlayer) playWaiting(waitNum);
|
||||
if (announce) playWaiting(waitNum);
|
||||
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
|
||||
notifiedWaitingRows.delete(i);
|
||||
}
|
||||
@@ -121,6 +128,28 @@
|
||||
// Stop any in-flight clip on unmount so audio doesn't outlive the board.
|
||||
$effect(() => () => cancelPlayback());
|
||||
|
||||
// Auto-tick on master draw (only in "both" mode). Set-only — never
|
||||
// un-marks an already-crossed cell, so manual taps and auto-tick
|
||||
// can't fight each other. Forward-only: only marks the most recent
|
||||
// draw, doesn't backfill prior calls when mode flips to "both"
|
||||
// mid-game (use the master's "Ván mới" if you need a fresh state).
|
||||
$effect(() => {
|
||||
const drawn = bus.lastDrawn;
|
||||
if (!drawn) return;
|
||||
if (settings.mode !== "both") return;
|
||||
if (!grid || crossed.length === 0) return;
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] === drawn.num && !crossed[r][c]) {
|
||||
crossed = crossed.map((row, ri) =>
|
||||
ri === r ? row.map((v, ci) => (ci === c ? true : v)) : row,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleGenerate() {
|
||||
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
|
||||
cancelPlayback();
|
||||
@@ -243,14 +272,14 @@
|
||||
onclick={() => handleCellClick(row, col)}
|
||||
class="tan-tan-num relative flex items-center justify-center
|
||||
aspect-[3/4] sm:aspect-[3/5]
|
||||
text-lg sm:text-2xl md:text-3xl
|
||||
text-xl sm:text-2xl md:text-3xl
|
||||
border border-slate-400/50 dark:border-slate-600/40
|
||||
transition-all select-none cursor-pointer active:scale-90
|
||||
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
|
||||
{isCrossed
|
||||
? rowComplete
|
||||
? 'cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-600 dark:text-emerald-400'
|
||||
: 'cell-crossed bg-red-50 dark:bg-red-950/30 text-red-500 dark:text-red-500'
|
||||
? 'cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
|
||||
: 'cell-crossed bg-red-50 dark:bg-red-950/40 text-red-700 dark:text-red-300'
|
||||
: 'bg-white dark:bg-slate-800 text-black dark:text-slate-100 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400'}"
|
||||
>
|
||||
{num}
|
||||
@@ -326,12 +355,12 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 italic">
|
||||
<p class="text-base text-slate-600 dark:text-slate-300 italic">
|
||||
Nhấn
|
||||
<span class="font-semibold text-rose-500 dark:text-rose-400 not-italic">"Tạo bảng mới"</span>
|
||||
để bắt đầu chơi
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1.5">
|
||||
🎫 Chúc cả nhà một ván vui vẻ
|
||||
</p>
|
||||
</div>
|
||||
@@ -390,10 +419,10 @@
|
||||
>
|
||||
Kinh!
|
||||
</h2>
|
||||
<p class="mt-3 text-lg text-slate-600 dark:text-slate-300">
|
||||
<p class="mt-3 text-lg text-slate-700 dark:text-slate-200">
|
||||
Hàng <span class="font-bold text-pink-500">{congratsRow}</span> đã đầy đủ!
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-slate-400 dark:text-slate-500">
|
||||
<p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
Hãy hô to "Kinh!" 🎶
|
||||
</p>
|
||||
<button
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
["dark", "Tối"],
|
||||
]);
|
||||
|
||||
const MODES = /** @type {const} */ ([
|
||||
["player", "Người chơi"],
|
||||
["master", "Quản trò"],
|
||||
["both", "Cả hai"],
|
||||
]);
|
||||
|
||||
/** @param {string} hex */
|
||||
function pickColor(hex) {
|
||||
settings.emptyCellColor = hex;
|
||||
@@ -48,8 +54,9 @@
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function toggleMaster() {
|
||||
settings.masterMode = !settings.masterMode;
|
||||
/** @param {"player"|"master"|"both"} m */
|
||||
function pickMode(m) {
|
||||
settings.mode = m;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
@@ -78,6 +85,11 @@
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function toggleVoiceWaitingNumber() {
|
||||
settings.voiceWaitingNumber = !settings.voiceWaitingNumber;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
function pickVoice(id) {
|
||||
if (settings.voice !== id) clearAudioCache();
|
||||
@@ -150,11 +162,11 @@
|
||||
>
|
||||
<h2
|
||||
id="settings-title"
|
||||
class="text-xl font-bold text-slate-800 dark:text-slate-100 mb-1"
|
||||
class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-1"
|
||||
>
|
||||
Cài đặt
|
||||
</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-5">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-5">
|
||||
Tuỳ chỉnh giao diện bảng lô tô
|
||||
</p>
|
||||
|
||||
@@ -216,28 +228,43 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Master mode -->
|
||||
<!-- Display mode: player / master / both -->
|
||||
<fieldset class="mb-5">
|
||||
<legend
|
||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
|
||||
>
|
||||
Chế độ quản trò
|
||||
Chế độ hiển thị
|
||||
</legend>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||
Hiện bảng quản trò bên dưới bảng người chơi
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">
|
||||
Chọn vai trò để chỉ hiện phần liên quan
|
||||
</p>
|
||||
{@render switchRow("Hiện bảng quản trò", settings.masterMode, toggleMaster)}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each MODES as [v, label] (v)}
|
||||
{@const selected = settings.mode === v}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onclick={() => pickMode(v)}
|
||||
class="px-2 py-2 rounded-lg border-2 text-sm font-medium transition-all
|
||||
{selected
|
||||
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300'
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-500'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Auto-call (only relevant when master mode is on) -->
|
||||
{#if settings.masterMode}
|
||||
<!-- Auto-call (only relevant when master panel is visible) -->
|
||||
{#if settings.mode !== "player"}
|
||||
<fieldset class="mb-5">
|
||||
<legend
|
||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
|
||||
>
|
||||
Tự động xổ
|
||||
</legend>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">
|
||||
Tự xổ số liên tiếp khi đang ở chế độ quản trò
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
@@ -245,7 +272,7 @@
|
||||
</div>
|
||||
{#if settings.autoCallEnabled}
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-600 dark:text-slate-300">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200">
|
||||
Tốc độ: <strong class="tabular-nums"
|
||||
>{settings.autoCallSpeed}</strong
|
||||
> giây/số
|
||||
@@ -273,18 +300,32 @@
|
||||
>
|
||||
Âm thanh
|
||||
</legend>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">
|
||||
Đọc số bằng tiếng Việt
|
||||
</p>
|
||||
|
||||
<div class="mb-2">
|
||||
{@render switchRow("Quản trò đọc số", settings.voiceEnabledMaster, toggleVoiceMaster)}
|
||||
{#if settings.mode !== "player"}
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1.5 px-1">
|
||||
Đọc số đã xổ + báo Chờ/Kinh khi ở "Cả hai".
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{@render switchRow("Báo Chờ / Kinh", settings.voiceEnabledPlayer, toggleVoicePlayer)}
|
||||
|
||||
{#if settings.voiceEnabledPlayer}
|
||||
<div class="mt-2 pl-3 border-l-2 border-slate-200 dark:border-slate-600">
|
||||
{@render switchRow("Đọc thêm số đang chờ", settings.voiceWaitingNumber, toggleVoiceWaitingNumber)}
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1.5 px-1">
|
||||
Tắt: chỉ nói "Chờ". Bật: nói "Chờ {`{số}`}".
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if VOICES.length > 1}
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-3 mb-1">
|
||||
<p class="text-sm font-medium text-slate-600 dark:text-slate-300 mt-3 mb-1.5">
|
||||
Giọng đọc
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -11,22 +11,25 @@ import { DEFAULT_VOICE, VOICE_IDS } from "$lib/audio-manifest.js";
|
||||
const STORAGE_KEY = "loto_settings";
|
||||
const HEX6 = /^#[0-9a-fA-F]{6}$/;
|
||||
const VALID_THEMES = /** @type {const} */ (["auto", "light", "dark"]);
|
||||
const VALID_MODES = /** @type {const} */ (["player", "master", "both"]);
|
||||
|
||||
export const DEFAULT_SETTINGS = Object.freeze({
|
||||
/** Excel "Standard Color: Purple". */
|
||||
emptyCellColor: "#7030A0",
|
||||
/** "auto" follows OS prefers-color-scheme; "light"/"dark" overrides it. */
|
||||
theme: /** @type {"auto"|"light"|"dark"} */ ("auto"),
|
||||
/** When true, the master panel renders inline below the player board on `/`. */
|
||||
masterMode: false,
|
||||
/** Which panels are visible: player only, master only, or both inline. */
|
||||
mode: /** @type {"player"|"master"|"both"} */ ("player"),
|
||||
/** When true, the master "Xổ số" button becomes "Bắt đầu/Dừng" + auto interval. */
|
||||
autoCallEnabled: false,
|
||||
/** Auto-call interval, seconds per number. Integer 1..10. */
|
||||
autoCallSpeed: 5,
|
||||
/** Speak the called number aloud when master draws. */
|
||||
voiceEnabledMaster: true,
|
||||
/** Speak "Chờ N" / "Kinh" on player events. */
|
||||
voiceEnabledPlayer: true,
|
||||
/** Speak "Chờ" / "Kinh" on player events. */
|
||||
voiceEnabledPlayer: false,
|
||||
/** When voiceEnabledPlayer is on, also speak the awaited number after "Chờ". */
|
||||
voiceWaitingNumber: false,
|
||||
/** Active voice id; matches an entry in audio manifest. */
|
||||
voice: DEFAULT_VOICE,
|
||||
});
|
||||
@@ -48,6 +51,13 @@ function validBool(v) {
|
||||
return typeof v === "boolean" ? v : null;
|
||||
}
|
||||
/** @param {unknown} v */
|
||||
function validMode(v) {
|
||||
return typeof v === "string" &&
|
||||
VALID_MODES.includes(/** @type {any} */ (v))
|
||||
? /** @type {"player"|"master"|"both"} */ (v)
|
||||
: null;
|
||||
}
|
||||
/** @param {unknown} v */
|
||||
function validSpeed(v) {
|
||||
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 10
|
||||
? v
|
||||
@@ -117,8 +127,14 @@ export function loadSettings() {
|
||||
validColor(parsed.emptyCellColor) ?? DEFAULT_SETTINGS.emptyCellColor;
|
||||
settings.theme =
|
||||
validTheme(parsed.theme) ?? DEFAULT_SETTINGS.theme;
|
||||
settings.masterMode =
|
||||
validBool(parsed.masterMode) ?? DEFAULT_SETTINGS.masterMode;
|
||||
// Migration: legacy `masterMode: true` → `mode: "both"`. Anything
|
||||
// else (false / missing / invalid) falls through to the default.
|
||||
const parsedMode = validMode(parsed.mode);
|
||||
settings.mode = parsedMode
|
||||
? parsedMode
|
||||
: parsed.masterMode === true
|
||||
? "both"
|
||||
: DEFAULT_SETTINGS.mode;
|
||||
settings.autoCallEnabled =
|
||||
validBool(parsed.autoCallEnabled) ?? DEFAULT_SETTINGS.autoCallEnabled;
|
||||
settings.autoCallSpeed =
|
||||
@@ -129,6 +145,9 @@ export function loadSettings() {
|
||||
settings.voiceEnabledPlayer =
|
||||
validBool(parsed.voiceEnabledPlayer) ??
|
||||
DEFAULT_SETTINGS.voiceEnabledPlayer;
|
||||
settings.voiceWaitingNumber =
|
||||
validBool(parsed.voiceWaitingNumber) ??
|
||||
DEFAULT_SETTINGS.voiceWaitingNumber;
|
||||
settings.voice =
|
||||
validVoiceId(parsed.voice) ?? DEFAULT_SETTINGS.voice;
|
||||
}
|
||||
@@ -150,11 +169,12 @@ export function saveSettings() {
|
||||
export function resetSettings() {
|
||||
settings.emptyCellColor = DEFAULT_SETTINGS.emptyCellColor;
|
||||
settings.theme = DEFAULT_SETTINGS.theme;
|
||||
settings.masterMode = DEFAULT_SETTINGS.masterMode;
|
||||
settings.mode = DEFAULT_SETTINGS.mode;
|
||||
settings.autoCallEnabled = DEFAULT_SETTINGS.autoCallEnabled;
|
||||
settings.autoCallSpeed = DEFAULT_SETTINGS.autoCallSpeed;
|
||||
settings.voiceEnabledMaster = DEFAULT_SETTINGS.voiceEnabledMaster;
|
||||
settings.voiceEnabledPlayer = DEFAULT_SETTINGS.voiceEnabledPlayer;
|
||||
settings.voiceWaitingNumber = DEFAULT_SETTINGS.voiceWaitingNumber;
|
||||
settings.voice = DEFAULT_SETTINGS.voice;
|
||||
saveSettings();
|
||||
}
|
||||
@@ -45,11 +45,12 @@ beforeEach(() => {
|
||||
for (const k of /** @type {const} */ ([
|
||||
"emptyCellColor",
|
||||
"theme",
|
||||
"masterMode",
|
||||
"mode",
|
||||
"autoCallEnabled",
|
||||
"autoCallSpeed",
|
||||
"voiceEnabledMaster",
|
||||
"voiceEnabledPlayer",
|
||||
"voiceWaitingNumber",
|
||||
"voice",
|
||||
])) {
|
||||
/** @type {any} */ (settings)[k] = /** @type {any} */ (DEFAULT_SETTINGS)[k];
|
||||
@@ -72,15 +73,16 @@ describe("settings-store — defaults", () => {
|
||||
expect(DEFAULT_SETTINGS.theme).toBe("auto");
|
||||
});
|
||||
|
||||
it("masterMode + autoCallEnabled default false; autoCallSpeed default 5", () => {
|
||||
expect(DEFAULT_SETTINGS.masterMode).toBe(false);
|
||||
it("mode defaults to player; autoCallEnabled false; autoCallSpeed 5", () => {
|
||||
expect(DEFAULT_SETTINGS.mode).toBe("player");
|
||||
expect(DEFAULT_SETTINGS.autoCallEnabled).toBe(false);
|
||||
expect(DEFAULT_SETTINGS.autoCallSpeed).toBe(5);
|
||||
});
|
||||
|
||||
it("voice toggles default ON; voice id picks first manifest entry", () => {
|
||||
it("master voice ON, player voice + waiting-number OFF by default", () => {
|
||||
expect(DEFAULT_SETTINGS.voiceEnabledMaster).toBe(true);
|
||||
expect(DEFAULT_SETTINGS.voiceEnabledPlayer).toBe(true);
|
||||
expect(DEFAULT_SETTINGS.voiceEnabledPlayer).toBe(false);
|
||||
expect(DEFAULT_SETTINGS.voiceWaitingNumber).toBe(false);
|
||||
expect(typeof DEFAULT_SETTINGS.voice).toBe("string");
|
||||
expect(DEFAULT_SETTINGS.voice.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -184,7 +186,7 @@ describe("settings-store — loadSettings (per-key fallback)", () => {
|
||||
loadSettings();
|
||||
expect(settings.emptyCellColor).toBe("#1e88e5");
|
||||
expect(settings.theme).toBe(DEFAULT_SETTINGS.theme);
|
||||
expect(settings.masterMode).toBe(DEFAULT_SETTINGS.masterMode);
|
||||
expect(settings.mode).toBe(DEFAULT_SETTINGS.mode);
|
||||
});
|
||||
|
||||
it("loads valid theme values", () => {
|
||||
@@ -201,20 +203,45 @@ describe("settings-store — loadSettings (per-key fallback)", () => {
|
||||
expect(settings.theme).toBe(DEFAULT_SETTINGS.theme);
|
||||
});
|
||||
|
||||
it("loads boolean masterMode and autoCallEnabled", () => {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({ masterMode: true, autoCallEnabled: true }),
|
||||
);
|
||||
it("loads autoCallEnabled boolean", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ autoCallEnabled: true }));
|
||||
loadSettings();
|
||||
expect(settings.masterMode).toBe(true);
|
||||
expect(settings.autoCallEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-boolean for masterMode", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ masterMode: "yes" }));
|
||||
it("loads valid mode values", () => {
|
||||
for (const m of /** @type {const} */ (["player", "master", "both"])) {
|
||||
localStorage.setItem(KEY, JSON.stringify({ mode: m }));
|
||||
loadSettings();
|
||||
expect(settings.mode).toBe(m);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid mode value", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ mode: "spectator" }));
|
||||
loadSettings();
|
||||
expect(settings.masterMode).toBe(DEFAULT_SETTINGS.masterMode);
|
||||
expect(settings.mode).toBe(DEFAULT_SETTINGS.mode);
|
||||
});
|
||||
|
||||
it("migrates legacy masterMode=true → mode='both'", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ masterMode: true }));
|
||||
loadSettings();
|
||||
expect(settings.mode).toBe("both");
|
||||
});
|
||||
|
||||
it("legacy masterMode=false maps to default mode (player)", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ masterMode: false }));
|
||||
loadSettings();
|
||||
expect(settings.mode).toBe("player");
|
||||
});
|
||||
|
||||
it("explicit mode wins over legacy masterMode", () => {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({ mode: "master", masterMode: true }),
|
||||
);
|
||||
loadSettings();
|
||||
expect(settings.mode).toBe("master");
|
||||
});
|
||||
|
||||
it("loads autoCallSpeed in range 1..10", () => {
|
||||
@@ -238,11 +265,12 @@ describe("settings-store — saveSettings", () => {
|
||||
it("persists ALL keys, not just color", () => {
|
||||
settings.emptyCellColor = "#112233";
|
||||
settings.theme = "dark";
|
||||
settings.masterMode = true;
|
||||
settings.mode = "both";
|
||||
settings.autoCallEnabled = true;
|
||||
settings.autoCallSpeed = 7;
|
||||
settings.voiceEnabledMaster = false;
|
||||
settings.voiceEnabledPlayer = false;
|
||||
settings.voiceEnabledPlayer = true;
|
||||
settings.voiceWaitingNumber = true;
|
||||
settings.voice = "nam-minh";
|
||||
saveSettings();
|
||||
const raw = localStorage.getItem(KEY);
|
||||
@@ -250,11 +278,12 @@ describe("settings-store — saveSettings", () => {
|
||||
expect(JSON.parse(/** @type {string} */ (raw))).toEqual({
|
||||
emptyCellColor: "#112233",
|
||||
theme: "dark",
|
||||
masterMode: true,
|
||||
mode: "both",
|
||||
autoCallEnabled: true,
|
||||
autoCallSpeed: 7,
|
||||
voiceEnabledMaster: false,
|
||||
voiceEnabledPlayer: false,
|
||||
voiceEnabledPlayer: true,
|
||||
voiceWaitingNumber: true,
|
||||
voice: "nam-minh",
|
||||
});
|
||||
});
|
||||
@@ -272,12 +301,12 @@ describe("settings-store — resetSettings", () => {
|
||||
it("returns ALL settings to defaults and persists", () => {
|
||||
settings.emptyCellColor = "#000000";
|
||||
settings.theme = "dark";
|
||||
settings.masterMode = true;
|
||||
settings.mode = "both";
|
||||
saveSettings();
|
||||
resetSettings();
|
||||
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
|
||||
expect(settings.theme).toBe(DEFAULT_SETTINGS.theme);
|
||||
expect(settings.masterMode).toBe(DEFAULT_SETTINGS.masterMode);
|
||||
expect(settings.mode).toBe(DEFAULT_SETTINGS.mode);
|
||||
const stored = JSON.parse(
|
||||
/** @type {string} */ (localStorage.getItem(KEY)),
|
||||
);
|
||||
|
||||
@@ -24,25 +24,29 @@
|
||||
Lô tô
|
||||
</h1>
|
||||
<p
|
||||
class="text-[0.65rem] sm:text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400 italic mt-1"
|
||||
class="text-xs sm:text-sm uppercase tracking-[0.28em] text-slate-600 dark:text-slate-300 italic mt-1.5 font-medium"
|
||||
>
|
||||
Hội chợ TN1
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<PlayerBoard />
|
||||
{#if settings.mode !== "master"}
|
||||
<PlayerBoard />
|
||||
{/if}
|
||||
|
||||
{#if settings.masterMode}
|
||||
{#if settings.mode !== "player"}
|
||||
<section
|
||||
class="mt-10"
|
||||
class={settings.mode === "both" ? "mt-10" : ""}
|
||||
aria-label="Bảng quản trò"
|
||||
transition:slide={{ duration: 250 }}
|
||||
>
|
||||
<h2
|
||||
class="text-center text-lg sm:text-xl font-bold tracking-wider uppercase mb-4 text-orange-500 dark:text-orange-400"
|
||||
>
|
||||
Quản trò
|
||||
</h2>
|
||||
{#if settings.mode === "both"}
|
||||
<h2
|
||||
class="text-center text-lg sm:text-xl font-bold tracking-wider uppercase mb-4 text-orange-500 dark:text-orange-400"
|
||||
>
|
||||
Quản trò
|
||||
</h2>
|
||||
{/if}
|
||||
<MasterPanel />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
Reference in new issue
Block a user