Files
loto/web/docs/system-architecture.md
tiennm99 c6c839fd95 ci: consolidate web and android workflows into one pipeline
Both subprojects ship from the same commit, so ordinary CI is now a single
ci.yml; only the tag-driven release stands apart. The web app is built twice
per run — once per base path — and every consumer downloads the artifact
instead of rebuilding, replacing three redundant base-"" builds on main.

Nothing deploys unless the test job is green, and android-release runs the
suite before signing (ci.yml does not fire on tags, so it was the only gap).

Shared toolchain setup moves into composite actions, which puts the web build
and the APK on the same Node version for the first time. The Firebase PR path
was still on npm ci against a stale web/package-lock.json that could resolve a
different tree than pnpm-lock.yaml; drop the lockfile and the npm path with it.

Also: least-privilege permissions widened per job, persist-credentials off on
every checkout, concurrency groups that cancel superseded PRs but never a live
deploy, npm caching for android, and the Firebase action pinned by commit SHA
to match how the release actions were already pinned.
2026-08-09 15:21:23 +07:00

6.9 KiB
Raw Permalink Blame History

System Architecture

Page Flow

Entry (+layout.svelte)
  ├─ 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)
      ├─ [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)

State Model

Player Card (storagePrefix="loto")

grid: number[][]          // 9×9 numbers (0 = empty)
crossed: boolean[][]      // 9×9 marked state

Each row has exactly 5 non-zero numbers AND each column has exactly 5 (constraint-aware picker — no slack). Numbers within a column are sorted ascending top-to-bottom (lô tô hội chợ Tân Tân convention).

Settings (loto_settings)

theme: "auto" | "light" | "dark"    // Display mode
mode: "player" | "master" | "both"  // Panel visibility (replaces masterMode)
autoCallEnabled: boolean            // Enable auto-call timer
autoCallSpeed: number (110)        // 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")

called: number[]          // [5, 23, 67, ...] — drawn in order
remaining: number[]       // [1, 2, 3, ...] minus called — shuffled initially
autoRunning: boolean      // Auto-call timer active

Host's Card (storagePrefix="loto_master_card")

Same as player card; isolated by prefix to allow host to play.

localStorage Keys

Prefix Grid Key Crossed Key
"loto" loto_grid loto_crossed
"loto_master_card" loto_master_card_grid loto_master_card_crossed

(Special) loto_master stores { called, remaining } for the master state.

All keys are JSON stringified. Corruption is silent (returns null).

basePath & Asset Resolution

Production (GitHub Pages, canonical)

npm run build:gh
basePath="/loto"
Output: build/index.html with /loto prefix
Deploy: https://tiennm99.github.io/loto/
Workflow: .github/workflows/ci.yml (build "gh" -> deploy-pages)

Development (Local)

npm run dev
basePath="" (empty)
Dev server: http://localhost:5173

Development (Code-Server)

npm run dev:codeserver
.env.local: CODESERVER_HOST + CODESERVER_PORT
basePath="/absproxy/{PORT}"
Access: https://<proxy>/absproxy/{PORT}/

Note: Use /absproxy/{port}/proxy/{port} strips the path prefix and breaks the SvelteKit base path.

Client-Only Architecture

All pages are client-only (no SSR) because:

  • ssr: false in +layout.js disables server-side rendering
  • localStorage is unavailable on server
  • State initialization (grid, crossed) must run in browser

Files that are client-only:

  • src/routes/+page.svelte (single page)
  • src/lib/PlayerBoard.svelte (player card component)
  • src/lib/MasterPanel.svelte (host panel, mounted when settings.masterMode)

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
2. handleCellClick(row, col) fires
3. crossed = crossed.map(...) toggled state
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 → "Chờ X" toast

Data Flow: Initial Load

1. Component mounts
2. $effect runs initial loadGrid(storagePrefix)
3. If found, load crossed state (or initialize as all false)
4. Pre-populate celebratedRows + notifiedWaitingRows sets
5. Pass grid + crossed to UI render

Animations

Name Duration Use
fade-in 0.2s Modal background entry
pop-in 0.4s Bingo popup scale + scale-back
bounce-slow 1.5s infinite Emoji on bingo popup
spin-slow 3s infinite on bingo popup
spin-slow-reverse 3s infinite reverse 🎊 on bingo popup
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 & PWA

Static Storage: All game state in localStorage. No API calls. Fully functional offline.

Service Worker: @vite-pwa/sveltekit auto-generates /sw.js with:

  • App shell precache (~353 KB): HTML, CSS, JS bundles for instant load
  • Audio runtime caching: Voice clips cached on-demand (CacheFirst strategy)
  • Navigation: NetworkFirst (try network, fallback to cache)
  • Auto-update: Detects new versions, shows reload toast, skipWaiting on user action

Manifest: /manifest.webmanifest enables install prompt on Android/iOS:

  • Display: standalone (fullscreen app-like mode, no browser chrome)
  • Theme colors: Indigo primary, white background
  • Icons: 192px + 512px PNG (auto-generated)
  • Start URL: / with basePath awareness

Installation Flow:

  1. App loads, registers SW (production only, not dev)
  2. Browser detects manifest → shows install prompt (native, no custom banner)
  3. User installs → app appears on home screen, launches in standalone mode
  4. Updates: SW checks for new version; if found, toast appears; user can reload

Last reviewed: 2026-05-09 Last synced: 2026-05-09 (Cloudflare Pages removed; GitHub Pages is the sole production target)