# Lô Tô Web version of *Lô tô hội chợ* — Vietnamese fairground bingo. Built with SvelteKit (static export). The caller draws numbers one at a time; a host panel manages the game and called numbers are announced aloud in Vietnamese using bundled audio clips. No runtime TTS or server required. Live: [tiennm99.github.io/loto](https://tiennm99.github.io/loto) Android port: [`android/`](../android) — a native Kotlin + Jetpack Compose app at feature parity with this one (this app is the behavioral spec). It mounts `static/audio/` straight into its APK, so the voice clips exist once. --- ## Features - **Number board** — 1–90 grid; called numbers highlight as they are drawn - **Host panel** — enable "Chế độ quản trò" (host mode) in settings to reveal the master draw panel inline below your player card - **Vietnamese voice callouts** — pre-generated MP3 clips for every number using Microsoft Edge TTS (`edge-tts`); multiple voices selectable in settings; no API key or internet connection needed at runtime - **Auto-cross player card** — optional automatic crossing of numbers on the player's bingo card as they are called - **Auto-countdown** — configurable delay between number draws for unattended calling - **PWA** — installable as a progressive web app; works offline after first load - **Static export** — no server; deploys to any static host or GitHub Pages --- ## Requirements [Node.js](https://nodejs.org) 18+ and npm. --- ## Quick Start ```sh git clone https://github.com/tiennm99/loto cd loto npm install npm run dev # dev server at http://localhost:5173 ``` --- ## Commands | Command | Description | |---|---| | `npm run dev` | Start development server | | `npm run build` | Production build (root base path) | | `npm run build:gh` | Production build for GitHub Pages (`/loto` base path) | | `npm run preview` | Preview production build locally | | `npm test` | Run unit tests | | `npm run lint` | ESLint check | | `npm run check` | svelte-check type check (JSDoc) | | `npm run verify:pwa` | Check the service worker in `build/` (after a build, same `BUILD_PROFILE`) | --- ## Configuration Copy `.env.example` to `.env.local` and set: | Variable | Description | |---|---| | `CODESERVER_HOST` | Hostname for code-server reverse-proxy dev (optional) | | `CODESERVER_PORT` | Port for code-server proxy (optional) | When running inside code-server use `npm run dev:codeserver` and open `https:///absproxy//` (use `/absproxy/`, not `/proxy/` — the latter strips the path prefix and breaks SvelteKit routing). --- ## Architecture Single-page SvelteKit app, statically exported. No backend. ``` src/ ├── routes/ # Single route: / ├── lib/ │ ├── game-logic.js # Draw state, number pool, called-number tracking │ ├── master-store.svelte.js # Host panel state (Svelte 5 runes store) │ ├── settings-store.svelte.js │ ├── voice.js # Audio manifest loader + clip player │ ├── player-auto-cross.js # Auto-cross logic │ ├── MasterPanel.svelte # Host draw UI │ ├── PlayerBoard.svelte # Bingo card + number grid │ └── AutoCountdown.svelte # Countdown timer between draws static/ └── audio/{voiceId}/ # Pre-generated MP3 clips (one per number per voice) scripts/ └── generate-audio.py # Regenerates audio clips via edge-tts ``` Game state lives entirely in Svelte 5 rune stores; no server round-trips. ### Build config - **No `svelte.config.js`.** SvelteKit 3 takes its options (adapter-static, `paths.base` from `base-path.js`) as arguments to the `sveltekit()` plugin in `vite.config.js`. - **`#lib/*` imports.** `src/lib` is reached through the `imports` field in `package.json` (`import { settings } from "#lib/settings-store.svelte.js"`), always with the file extension. `jsconfig.json` extends `$app/tsconfig`. - **Base-path-safe URLs.** Static files go through `asset()` from `$app/paths` (no leading `/`: `asset("audio/…")`), so they resolve under both `""` and `/loto`. ### PWA The service worker comes from [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) (`generateSW`, `registerType: "prompt"`); the manifest is the hand-written `static/manifest.webmanifest`. `+layout.svelte` registers it through `virtual:pwa-register` and wires `onNeedRefresh` to the update banner in `src/lib/update-prompt.svelte.js`. It precaches the app shell plus the default voice; other voices are cached on first play. `@vite-pwa/sveltekit` is not used: it generates `sw.js` when Vite's SSR build closes, which assumed SvelteKit 2's build order. SvelteKit 3 builds SSR first, then the client, then prerenders, all inside its `buildApp` hook, so `vite.config.js` drops vite-plugin-pwa's own build step and calls its `generateSW()` from a `buildApp` hook that runs after SvelteKit's and before the adapter copies `.svelte-kit/output` into `build/`. `npm run verify:pwa` checks the result after a build (run it with the same `BUILD_PROFILE`). Audio manifest is generated once at build prep time — the app reads a JSON manifest on load and plays the appropriate clip for each drawn number. --- ## Regenerating Audio Voice clips in `static/audio/` were generated with [edge-tts](https://github.com/rany2/edge-tts) (free, no API key). To regenerate (e.g. to add a new Microsoft `vi-*` voice): ```sh pip install edge-tts python3 scripts/generate-audio.py ``` The script auto-discovers all available `vi-*` voices and writes an updated manifest. --- ## Regenerating Icons `static/icons/source.svg` is the source for `icon-192.png` and `icon-512.png`; `static/icons/source-maskable.svg` (full-bleed background, wordmark inside the 80% safe zone) is the source for `icon-maskable-512.png`. Render them with the real **Roboto Condensed Black Italic** TTF loaded explicitly — a fallback font is wider and clips the wordmark. Any SVG rasterizer that accepts a font file works, e.g. [`@resvg/resvg-js`](https://github.com/thx/resvg-js) with `font: { fontFiles: [...], loadSystemFonts: false }` and `fitTo` set to each PNG's exact pixel width. Open the results and check nothing touches the edges. --- ## Deployment Deployed to GitHub Pages by the `deploy-pages` job in `.github/workflows/ci.yml` on push to `main`. Build command: `npm run build:gh` (sets `/loto` base path). Static output in `build/`. The same workflow also deploys the base-`""` build to Firebase Hosting and bundles it into the debug APK, so `web/` is built once per base path per run. --- ## License Apache-2.0 — see [`LICENSE`](../LICENSE) at the repository root.