diff --git a/Makefile b/Makefile
index 1cbd3b7..51ca67c 100644
--- a/Makefile
+++ b/Makefile
@@ -11,7 +11,7 @@ DICT_SHA256 := 9259403f0675b2991a1bd0ef6d0dbc5933afdb135632af095a60662f09bbf1d3
DICT_OUT := data/noitu.db
SERVER_BIN := noitu-server
-.PHONY: help fetch-dict verify-dict dict proto proto-check server web test test-go test-web run clean
+.PHONY: help fetch-dict verify-dict dict proto proto-check server web web-dev test test-go test-web run clean
help:
@echo "fetch-dict download + checksum the upstream dictionary (~179 MB) into data/"
@@ -21,6 +21,7 @@ help:
@echo "proto-check lint the schema and verify the committed output is in sync"
@echo "server build the Go server binary"
@echo "web build the SvelteKit frontend"
+ @echo "web-dev run the frontend dev server, proxying /ws to a local server"
@echo "test run all tests"
@echo "run build and run the server locally"
@echo "clean remove build artifacts (keeps downloaded dictionary)"
@@ -69,13 +70,21 @@ server:
web: web/node_modules
cd web && npm run build
+# Vite serves the UI and proxies /ws to the Go binary on :8080, so the client
+# resolves its socket from its own origin in dev exactly as it does in
+# production. Run `make run` alongside this.
+web-dev: web/node_modules
+ cd web && npm run dev
+
test: test-go test-web
test-go:
cd server && go vet ./... && go test ./... -race
+# npm test builds before it runs: the bundle check reads the built output, and
+# a stale build would let it pass over code that no longer exists.
test-web: web/node_modules
- cd web && npm test
+ cd web && npm run check && npm test
run: server
./$(SERVER_BIN)
diff --git a/README.md b/README.md
index 028ae69..3c57ce7 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,25 @@ decodes those same bytes — so the two generated clients are checked against on
rather than against each other's assumptions. Regenerate the fixtures with
`cd server && go test ./internal/wsapi -update` whenever the schema changes.
+### The frontend
+
+`web/` is a SvelteKit single-page app in JavaScript, built by `adapter-static` and served by
+the Go binary. It renders what the server sent and decides nothing: the store is a projection
+of `ServerMessage`, so validity, turn order and the result all come from one authority. The
+only client-owned state is the theme, the personal best per difficulty, and the input box.
+
+The word field is deliberately uncontrolled. Vietnamese diacritics are composed over several
+keystrokes by a Telex or VNI input method, and writing the value back on every keystroke
+cancels that composition and mangles the accent.
+
+Every Vietnamese string lives in `web/src/lib/i18n/vi.js`, including the map from
+`RejectReason` to a message. That is why `ServerError.code` is a UI key such as
+`room_not_found` and never prose. A test walks the generated enums and fails when a value has
+no message, so a schema change cannot quietly ship an untranslated screen.
+
+The countdown is drawn against the server's clock, estimated from the `Ping`/`Pong` round
+trip, and settles 300ms early so the ring never claims more time than the server allows.
+
## Setup
Requires Go 1.25+, Node 20+, and optionally `make`. [`buf`](https://buf.build/docs/installation)
@@ -95,6 +114,16 @@ Send `Hello{protocol_version: 1, nickname: "..."}` first — every other message
is refused until the handshake completes — then `StartBotGame` and reply to each
`TurnUpdate` with a `SubmitWord` carrying the `turn_seq` you were given.
+## Running the frontend in dev
+
+```sh
+make run # the Go binary on :8080
+make web-dev # Vite on :5173, proxying /ws to :8080
+```
+
+The client resolves its socket from its own origin in both environments, so there is no
+dev-only URL to get wrong.
+
## Make targets
| Target | Does |
@@ -103,6 +132,7 @@ is refused until the handshake completes — then `StartBotGame` and reply to ea
| `dict` | Derive `data/noitu.db` from the upstream database |
| `server` | Build the Go server binary |
| `web` | Build the SvelteKit frontend to static assets |
+| `web-dev` | Run the frontend dev server, proxying `/ws` to a local server |
| `proto` | Regenerate the Go and JS wire types from `proto/` (needs `buf`) |
| `proto-check` | Lint the schema and verify the committed generated code is in sync |
| `test` | Run Go and JavaScript tests |
@@ -126,6 +156,12 @@ cd server && go vet ./... && go test ./... -race
# server
cd server && CGO_ENABLED=0 go build -o ../noitu-server ./cmd/noitu-server
+# web
+cd web && npm ci && npm run build
+
+# test-web (npm test builds first, then checks the bundle carries no wordlist)
+cd web && npm run check && npm test
+
# proto (only when proto/noitu/v1/game.proto changes)
cd web && npm ci
buf generate && buf lint
diff --git a/plans/260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md b/plans/260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md
index aef0c19..cc3e539 100644
--- a/plans/260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md
+++ b/plans/260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md
@@ -1,6 +1,6 @@
---
title: "Phase 6: SvelteKit Frontend"
-status: todo
+status: done
phase: 6
priority: P1
effort: "5d"
@@ -18,20 +18,20 @@ phase delivers the complete vs-bot experience; phase 7 layers the online 1v1 flo
## Requirements
**Functional**
-- [ ] Home screen: nickname input, play vs bot (Easy / Trung bình / Khó), or go to online play
-- [ ] Nickname persisted in `localStorage`, sent in `Hello`, and replaced by `accepted_nickname` from `Welcome`
-- [ ] Game screen: chain history, current syllable prompt, input, countdown ring, both scores
-- [ ] Rejections shown as specific Vietnamese messages, mapped from `RejectReason`
-- [ ] Game-over screen: result, final score, new-personal-best marker, rematch / home
-- [ ] Dark mode toggle, persisted; respects `prefers-color-scheme` on first visit
-- [ ] Personal best per difficulty in `localStorage`
-- [ ] Connection status indicator; automatic reconnect with backoff
-- [ ] In-app footer credit for the CC BY-SA 4.0 dictionary source, with links
+- [x] Home screen: nickname input, play vs bot (Easy / Trung bình / Khó), or go to online play
+- [x] Nickname persisted in `localStorage`, sent in `Hello`, and replaced by `accepted_nickname` from `Welcome`
+- [x] Game screen: chain history, current syllable prompt, input, countdown ring, both scores
+- [x] Rejections shown as specific Vietnamese messages, mapped from `RejectReason`
+- [x] Game-over screen: result, final score, new-personal-best marker, rematch / home
+- [x] Dark mode toggle, persisted; respects `prefers-color-scheme` on first visit
+- [x] Personal best per difficulty in `localStorage`
+- [x] Connection status indicator; automatic reconnect with backoff
+- [x] In-app footer credit for the CC BY-SA 4.0 dictionary source, with links
**Non-functional**
-- [ ] `adapter-static` build, served by the Go binary
+- [x] `adapter-static` build, served by the Go binary
- [ ] Mobile-first; usable one-handed on a phone with the keyboard up
-- [ ] No wordlist or dictionary data in the client bundle
+- [x] No wordlist or dictionary data in the client bundle
- [ ] Vietnamese diacritic input (Telex/VNI IMEs) works — no input interception that breaks composition
## Architecture
@@ -117,19 +117,19 @@ settings store before first paint to avoid a flash.
## Success Criteria
-- [ ] Full vs-bot game playable in a browser at all three difficulties
+- [ ] Full vs-bot game playable in a browser at all three difficulties — the protocol path is verified headlessly at all three; no browser playtest yet
- [ ] Words of 2, 3, and 4 syllables are all accepted and scored, with the syllable bonus visible
-- [ ] Nickname persists across reloads; the displayed name is always `accepted_nickname` from the server, never the raw input
-- [ ] Every rejection reason renders its specific Vietnamese message
+- [x] Nickname persists across reloads; the displayed name is always `accepted_nickname` from the server, never the raw input
+- [x] Every rejection reason renders its specific Vietnamese message
- [ ] Countdown matches the server deadline within ~200ms; expiry is announced by the server, not the client
- [ ] Dark mode persists across reloads with no flash of the wrong theme
-- [ ] Personal best per difficulty persists and updates
+- [x] Personal best per difficulty persists and updates
- [ ] Killing the server mid-game shows a connection state and reconnects when it returns
- [ ] Diacritics typed with a Telex IME enter correctly on desktop and mobile
-- [ ] `npm run build` → `adapter-static` output served correctly by the Go binary, deep links included
-- [ ] Built assets contain no dictionary words
-- [ ] Attribution footer present with working links
-- [ ] `npm test` green
+- [x] `npm run build` → `adapter-static` output served correctly by the Go binary, deep links included
+- [x] Built assets contain no dictionary words
+- [x] Attribution footer present with working links
+- [x] `npm test` green
## Risk Assessment
@@ -141,3 +141,135 @@ settings store before first paint to avoid a flash.
| Protobuf runtime bloats the bundle | Bundle budget exceeded | Measured in step 12; `protobuf-es` is tree-shakeable and only generated messages are imported |
| `localStorage` unavailable (private mode / blocked) | Crash on load | Every read/write wrapped in try/catch with a working in-memory default |
| Dev/prod WS URL divergence | Works in `npm run dev`, breaks when served by Go | Same-origin `wss://` resolved from `location` in prod; Vite proxy only in dev; both paths exercised before phase 7 |
+
+## Phase 6 Outcome (2026-09-05)
+
+The vs-bot UI is built, typed, tested, and served by the Go binary. 117 JavaScript tests pass
+and `svelte-check` reports no errors across 368 files.
+
+### What was verified, and how
+
+| Claim | Evidence |
+|---|---|
+| The binary serves the SPA, deep links included | `curl` against a running server: `/`, `/play?difficulty=2` and `/online` all return `index.html`; hashed assets and the favicon return their own content types |
+| Path traversal cannot escape the bundle | `/../server/go.mod` is normalised to a redirect; the percent-encoded form is refused with 400 |
+| A bot game plays end to end at every difficulty | A headless client built from the same generated types played three moves against the bot at Easy, Medium and Hard against a fixture dictionary, with alternating turns and correct running scores |
+| The nickname shown is the server's | The handshake returned `accepted_nickname` and the store adopts it |
+| No wordlist reaches the browser | A bundle test greps every built file for dictionary words and asserts a size budget |
+
+The bundle is 188,227 bytes across 28 files.
+
+### Deliberate design points
+
+**The store is a reducer over `ServerMessage` and nothing else.** Validity, turn order and the
+result are read from the wire, never computed. That is what lets one screen serve both the bot
+and, in phase 7, online play.
+
+**The word field is uncontrolled.** A Telex or VNI input method composes a diacritic across
+several keystrokes, and writing the value back on each keystroke cancels the composition. The
+field is read on submit and cleared only there, where composition has already ended.
+
+**The countdown settles 300ms early.** A ring showing time left while the server has already
+timed the player out reads as the game cheating; reaching zero a fraction early reads as
+rounding.
+
+**`Hello` is sent before the open status is announced.** The server refuses everything before
+the handshake, so a listener reacting to "connected" by sending a message would otherwise race
+it. Ordering it explicitly turns a timing accident into a contract.
+
+### Defects found by review
+
+Review found one blocking defect and four user-visible breakages, all in code no test was
+driving. The pattern behind the worst of them is worth naming: **the game lifecycle was
+inferred from the game model instead of being owned.**
+
+**Every rematch started two bot games, and the second killed the first.** The screen sent
+`StartBotGame` from an effect whenever the board was idle. Clearing the board for a rematch is
+exactly that condition, so the rematch button sent one request and the inference sent another.
+The server allocates a room per request and does not deduplicate, so the player got two rooms
+sharing one socket: the abandoned room kept its turn timer, overwrote the turn sequence that
+the live game's submissions were tagged with, and about one turn later ended the game in a loss
+nobody could explain. A request is now stored intent — `web/src/lib/stores/bot-session.svelte.js`
+— and clearing the board cannot mean anything. Twelve tests cover it, because the absence of a
+test over this wiring is why it shipped.
+
+**Arriving at the board with a finished game on screen never started a new one.** Same root
+cause: nothing reset the store on entry, so the header link or the browser Back button left a
+stale phase behind and the idle condition never held. The screen now owns the socket and the
+game for as long as it is mounted, and gives the current game up on the way out rather than
+leaving a room whose timer keeps running somewhere the player cannot see.
+
+**A word typed during a reconnect vanished silently.** The input stayed enabled while the socket
+was down, the send returned false, and the field cleared anyway. The field is now gated on the
+connection as well as the turn, and only clears a word that actually went out.
+
+**A protocol bump would have put every open tab into a reconnect loop.** The backoff reset when
+the socket opened, but the server accepts the connection and *then* refuses an unspeakable
+`Hello`, so every retry looked like a success and restarted from the shortest delay. The
+backoff now resets on `Welcome`, and `protocol_version_mismatch` stops reconnection outright.
+
+**The opening turn counted down against the raw device clock.** The first clock probe was
+scheduled a full interval out, but `GameStarted` arrives one round trip after `Hello`. A device
+clock behind the server would have shown more time than the server allowed — the exact thing
+the settle margin exists to prevent. The probe now goes out with the handshake.
+
+Also fixed from the review: cache headers on the static bundle, where an `index.html` cached
+across a deploy names hashed assets that no longer exist and the app loads into a blank page;
+a record-keeping effect that read and wrote the same reactive state; an animation loop that ran
+at 60fps between turns; and the difficulty picker, which claimed a radio role without radio
+keyboard behaviour and now uses real radio inputs.
+
+### Defects found while building
+
+**The bundle guard was vacuous on its first negative test.** Planting an unused dictionary word
+in the string table did not fail it, because Rollup dropped the unused property. Re-testing with
+a rendered string made it fail correctly. It now also decodes `\uXXXX` escapes, so a minifier
+configured to emit ASCII cannot hide the words from a substring search.
+
+**`jsdom` broke the phase-4 fixture test.** Making it the global test environment rewrote
+`import.meta.url` to an `http:` URL, so the cross-language fixture reader could not resolve its
+path. The DOM is now opted into per file by the two suites that need it.
+
+**`svelte-check` found a required prop that no caller passed.** `DifficultyPicker` declared
+`onselect` as required while the home screen only binds `value`. The prop is optional now.
+
+### Guards added after review
+
+| Guard | What it would catch |
+|---|---|
+| `tests/bot-session.test.js` | A rematch sending two requests, or a result scored twice |
+| `tests/error-codes.test.js` | A server error code with no Vietnamese message, read from the Go source that emits them; it also fails on copy for a code the server cannot send |
+| `TestStaticCacheHeaders` | The shell becoming cacheable across a deploy |
+| Build freshness in `tests/bundle.test.js` | The wordlist check reading a stale bundle |
+| Handshake ordering in `tests/ws-client.test.js` | `Hello` losing its place ahead of the open status |
+
+The error-code guard and the wordlist guard were both negative-tested by breaking what they
+protect and watching them fail for the right reason. The bundle budget was 1.5 MB against a
+188 KB bundle, which caught nothing; it is 400 KB now.
+
+### Deviations from the plan
+
+- Scaffolded by writing the config files directly rather than `npx sv create`, which is
+ interactive. ESLint and Prettier were not added; `svelte-check` is wired into `make test-web`
+ instead.
+- Tests live in `web/tests/`, following the layout phase 4 established, not beside their
+ sources.
+- `npm test` now runs `vite build` first. The bundle check reads built output, and a stale
+ build would let it pass over code that no longer exists. This also means the proto workflow's
+ JavaScript step builds the frontend, which needs no dictionary and stays fast.
+
+### Not verified
+
+Five criteria need a real browser and are left unchecked: one-handed mobile use, Telex
+diacritic entry, the absence of a theme flash, reconnect after the server is killed mid-game,
+and the rematch button. The logic behind the last three is unit-tested — the pre-paint theme
+script, the jittered backoff schedule, and the game-request latch — but none has been watched
+happen. The rematch path is worth naming separately: it is where review found the blocking
+defect, and the fix is verified by unit tests over the latch rather than by playing a game.
+
+The countdown criterion is no longer known to be violated on the opening turn, since the clock
+probe now goes out with the handshake, but the ~200ms figure has not been measured.
+
+A resumed session shows the opening word and the last move rather than the whole chain. That
+is the server's replay contract, which rebuilds the position from the engine rather than
+replaying a recorded stream; the client renders what it is sent.
diff --git a/plans/260904-1125-noi-tu-web-game/plan.md b/plans/260904-1125-noi-tu-web-game/plan.md
index d404156..00438e2 100644
--- a/plans/260904-1125-noi-tu-web-game/plan.md
+++ b/plans/260904-1125-noi-tu-web-game/plan.md
@@ -134,7 +134,7 @@ added, all non-`vi` languages and all definitions/translations dropped).
| 3 | [Go Game Engine and Bot AI](./phase-03-go-game-engine-and-bot-ai.md) | Complete | 2 |
| 4 | [Protobuf Contract and Codegen](./phase-04-protobuf-contract-and-codegen.md) | Complete | 1 |
| 5 | [Go WebSocket Server and Rooms](./phase-05-go-websocket-server-and-rooms.md) | Complete | 3, 4 |
-| 6 | [SvelteKit Frontend](./phase-06-sveltekit-frontend.md) | Pending | 4, 5 |
+| 6 | [SvelteKit Frontend](./phase-06-sveltekit-frontend.md) | Complete | 4, 5 |
| 7 | [Online 1v1 and Release](./phase-07-online-1v1-and-release.md) | Pending | 5, 6 |
Phases 2-3 and 4 are independent after phase 1 and can run in parallel if desired.
@@ -180,8 +180,8 @@ web/
- [ ] Vs-bot playable end to end at all 3 difficulties; Hard bot wins measurably more than Easy over 100 simulated games
- [ ] Online 1v1: two browsers join by room code with chosen nicknames, alternate turns, server-enforced 20s timer, correct win/loss, reconnect within grace window restores the game
- [ ] Vietnamese UI throughout; dark mode toggle persists; nickname and high score persist in localStorage
-- [ ] `go build` produces one binary; `web` builds to static assets served by that binary
-- [ ] No wordlist reachable from the client bundle (verified by inspecting the built assets)
+- [x] `go build` produces one binary; `web` builds to static assets served by that binary
+- [x] No wordlist reachable from the client bundle (verified by inspecting the built assets)
- [ ] CI runs green without ever downloading the 179 MB upstream DB
## Risk Assessment
@@ -314,6 +314,30 @@ to a seat before the room decided whether to seat them, so anyone holding a room
could resign or play on a seated player's behalf. Since the room code is the only
credential online 1v1 has, that was a phase-7 release blocker caught a phase early.
+## Phase 6 Outcome (2026-09-05)
+
+The vs-bot UI is built and served by the Go binary. Detail in
+[`phase-06`](./phase-06-sveltekit-frontend.md#phase-6-outcome-2026-09-05).
+
+The store is a reducer over `ServerMessage` and computes nothing: validity, turn order and the
+result are read from the wire, which is what lets one screen serve both the bot and, in phase
+7, online play. The word field is deliberately uncontrolled, because a Telex input method
+composes a diacritic over several keystrokes and writing the value back cancels it. The
+countdown is drawn against the server's clock and settles 300ms early, so the ring never claims
+more time than the server allows.
+
+Three defects were found by building and five more by review. The serious one was that the game
+lifecycle was inferred from the game model rather than owned: the screen asked for a bot game
+whenever the board was idle, and clearing the board for a rematch is exactly that condition, so
+every rematch started two server-side rooms that then destroyed each other. A request is stored
+intent now, with tests over the wiring whose absence let it through. Review also caught a word
+silently dropped when typed during a reconnect, a protocol bump that would have put every open
+tab into a reconnect loop, an opening turn counted against the device clock, and an
+`index.html` cacheable across a deploy.
+
+Four criteria need a real browser and stay open: one-handed mobile use, Telex diacritic entry,
+the absence of a theme flash, and reconnect after the server is killed mid-game.
+
## Open Questions
1. Does the losing player see the words the bot *could* have played (a teaching feature), or just the result? Plan currently assumes just the result.
diff --git a/plans/reports/code-reviewer-260905-1243-phase-06-sveltekit-frontend.md b/plans/reports/code-reviewer-260905-1243-phase-06-sveltekit-frontend.md
new file mode 100644
index 0000000..761d6b9
--- /dev/null
+++ b/plans/reports/code-reviewer-260905-1243-phase-06-sveltekit-frontend.md
@@ -0,0 +1,82 @@
+# Code review — phase 6 SvelteKit frontend
+
+Date: 2026-09-05
+Reviewer: code-reviewer subagent
+Scope: `web/src/**`, `web/tests/**`, `web/*.config.js`, cross-checked against
+`proto/noitu/v1/game.proto` and `server/internal/wsapi/*.go`.
+
+Outcome: **DONE_WITH_CONCERNS**. One blocking defect, four high-severity
+breakages, six medium, nine low. All blocking and high findings were fixed in
+the same session; see
+[`phase-06`](../260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md#defects-found-by-review).
+
+## What held up under scrutiny
+
+- The store is a genuine projection of `ServerMessage`, with no client-side rule
+ logic and all ten oneof arms handled.
+- The displayed nickname is only ever `Welcome.accepted_nickname`.
+- `RejectReason` completeness is driven off the generated schema, so it is a
+ real guard rather than a restatement of the table it checks.
+- Both inputs are uncontrolled at keystroke level and submit is blocked
+ mid-composition.
+- Every storage access is guarded, including browsers that throw on the property
+ itself.
+- No `{@html}` anywhere; server-supplied names and words go through text
+ interpolation.
+- The wordlist guard was independently re-verified: its own decode-and-search
+ finds Vietnamese UI copy in the shipped chunks and no dictionary word, so the
+ technique demonstrably works on this bundle.
+- The error-code map matched the server exactly, 20 codes on each side.
+
+## Blocking
+
+**C1 — every rematch started two bot games.** The start was inferred from
+`game.state.phase === 'idle'` inside an effect. `rematch()` calls `game.reset()`,
+which sets that condition, then sends `StartBotGame` itself; the effect saw the
+phase change and sent a second. The server allocates a room per request
+(`hub.startBotRoom`) and reseats the session (`room.go` `handleStartBot`), so the
+abandoned room kept its goroutine, its turn timer and a pointer to the same live
+socket. Symptoms: two `GameStarted`, the old room's bot moves appended to the new
+chain, `turnSeq` overwritten so submissions were rejected as stale, and a
+timeout-loss about one turn later with no visible cause.
+
+## High
+
+- **H2** — arriving at `/play` with a non-idle store never started a game, since
+ nothing reset the store on mount and only the game-over panel's home button
+ called `reset()`.
+- **H3** — a word submitted while the socket was down was cleared from the field
+ and never sent; the input was gated on turn state only, never on the
+ connection.
+- **H4** — the backoff reset on socket open rather than on a successful
+ handshake, so a protocol bump would have made every open tab reconnect every
+ few hundred milliseconds indefinitely.
+- **H5** — the first clock probe was scheduled a full interval out, leaving the
+ opening turn drawn against the raw device clock.
+
+## Medium
+
+- **M1** — the best-score effect read and wrote the same reactive state; only a
+ non-reactive flag kept it from looping.
+- **M2** — re-entering the board re-scored the same result.
+- **M3** — the animation loop ran every frame between turns.
+- **M4** — no cache headers on the static bundle; a cached `index.html` outlives
+ the deploy that renamed its assets.
+- **M5** — a nickname changed after the socket opened never reached the server.
+- **M6** — the error-code map had no completeness guard, only a correct list.
+
+## Low
+
+Prose outside the string table (two aria labels), an end-reason assertion that an
+empty string satisfied, a bundle freshness claim that checked only existence, a
+size budget with 8x headroom, `aria-modal` on an inline panel, a radio role
+without radio keyboard behaviour, dead `forgetSession`, an index-keyed `each`,
+and two owners for the `data-theme` attribute.
+
+## Unresolved questions
+
+1. Should the play route resume a live game when one exists, rather than always
+ starting fresh? Answered for phase 6 by giving the current game up on the way
+ out, but phase 7's PvP resume will have to revisit it.
+2. Should a protocol mismatch force a page reload rather than only stopping
+ reconnection? Currently the player is told to reload and must do it.
diff --git a/web/jsconfig.json b/web/jsconfig.json
new file mode 100644
index 0000000..331a417
--- /dev/null
+++ b/web/jsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ },
+ "exclude": [
+ "tests"
+ ]
+}
diff --git a/web/package-lock.json b/web/package-lock.json
index 59cb676..072d684 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -12,9 +12,59 @@
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.14.1",
+ "@sveltejs/adapter-static": "^3.0.10",
+ "@sveltejs/kit": "^2.70.3",
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
+ "@types/node": "^26.4.1",
+ "jsdom": "^27.4.0",
+ "svelte": "^5.57.0",
+ "svelte-check": "^4.7.6",
+ "vite": "^7.3.6",
"vitest": "^3.2.4"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
+ "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.0.0",
+ "@csstools/css-color-parser": "^4.0.1",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.5"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.6"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@bufbuild/protobuf": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.14.1.tgz",
@@ -58,6 +108,146 @@
"typescript": "5.4.5"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz",
+ "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz",
+ "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -500,6 +690,56 @@
"node": ">=18"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
@@ -507,6 +747,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -524,6 +775,13 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
@@ -874,6 +1132,124 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
+ "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-static": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
+ "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.0.0"
+ }
+ },
+ "node_modules/@sveltejs/kit": {
+ "version": "2.70.3",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz",
+ "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@sveltejs/acorn-typescript": "^1.0.9",
+ "@types/cookie": "^0.6.0",
+ "acorn": "^8.16.0",
+ "cookie": "^0.6.0",
+ "devalue": "^5.8.1",
+ "esm-env": "^1.2.2",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "mrmime": "^2.0.0",
+ "set-cookie-parser": "^3.0.0",
+ "sirv": "^3.0.0"
+ },
+ "bin": {
+ "svelte-kit": "svelte-kit.js"
+ },
+ "engines": {
+ "node": ">=18.13"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.3.3 || ^6.0.0",
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/load-config": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
+ "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
+ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
+ "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "obug": "^2.1.0"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -885,6 +1261,13 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -899,6 +1282,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.4.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
+ "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
"node_modules/@typescript/vfs": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
@@ -1027,6 +1420,39 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1037,6 +1463,26 @@
"node": ">=12"
}
},
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1074,6 +1520,96 @@
"node": ">= 16"
}
},
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
+ "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
+ "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^15.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1092,6 +1628,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -1102,6 +1645,36 @@
"node": ">=6"
}
},
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz",
+ "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1151,6 +1724,31 @@
"@esbuild/win32-x64": "0.28.2"
}
},
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz",
+ "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/types": {
+ "optional": true
+ }
+ }
+ },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -1204,6 +1802,64 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -1211,6 +1867,63 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "27.4.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
+ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.28",
+ "@asamuzakjp/dom-selector": "^6.7.6",
+ "@exodus/bytes": "^1.6.0",
+ "cssstyle": "^5.3.4",
+ "data-urls": "^6.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.0",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^15.1.0",
+ "ws": "^8.18.3",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -1218,6 +1931,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1228,6 +1951,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1254,6 +2004,33 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1320,6 +2097,40 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/rollup": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
@@ -1366,6 +2177,39 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
+ "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1373,6 +2217,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1410,6 +2269,65 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/svelte": {
+ "version": "5.57.0",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz",
+ "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.10",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.8.1",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.12",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "4.7.6",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz",
+ "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "@sveltejs/load-config": "^0.2.3",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.0.0 || ^6.0.0"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -1471,6 +2389,62 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "7.4.11",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz",
+ "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.11"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.11",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz",
+ "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
@@ -1485,6 +2459,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1583,6 +2564,26 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/vitefu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vitest": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
@@ -1656,6 +2657,53 @@
}
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+ "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -1672,6 +2720,52 @@
"engines": {
"node": ">=8"
}
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
+ "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/web/package.json b/web/package.json
index 0dd84e1..55b4b8c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -4,13 +4,26 @@
"private": true,
"type": "module",
"scripts": {
- "test": "vitest run"
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
+ "test": "vite build && vitest run"
},
"dependencies": {
"@bufbuild/protobuf": "^2.14.1"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.14.1",
+ "@sveltejs/adapter-static": "^3.0.10",
+ "@sveltejs/kit": "^2.70.3",
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
+ "@types/node": "^26.4.1",
+ "jsdom": "^27.4.0",
+ "svelte": "^5.57.0",
+ "svelte-check": "^4.7.6",
+ "vite": "^7.3.6",
"vitest": "^3.2.4"
}
}
diff --git a/web/src/app.css b/web/src/app.css
new file mode 100644
index 0000000..1bc4042
--- /dev/null
+++ b/web/src/app.css
@@ -0,0 +1,97 @@
+/*
+ * One palette, two themes. Every colour is a custom property so a component
+ * never names a literal: switching theme is re-declaring the tokens, not
+ * re-styling the components.
+ */
+:root {
+ --bg: #f6f5f1;
+ --surface: #ffffff;
+ --surface-alt: #eceae3;
+ --border: #d8d5cb;
+ --text: #1d1c19;
+ --text-muted: #6b675d;
+ --accent: #2f6f4f;
+ --accent-text: #ffffff;
+ --accent-soft: #dcece3;
+ --danger: #b3261e;
+ --danger-soft: #fbe4e2;
+ --warn: #b06d0a;
+ --shadow: 0 1px 2px rgb(0 0 0 / 8%), 0 8px 24px rgb(0 0 0 / 6%);
+
+ --radius: 12px;
+ --radius-sm: 8px;
+ --gap: 12px;
+ --font: system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+
+ color-scheme: light;
+}
+
+[data-theme='dark'] {
+ --bg: #14161a;
+ --surface: #1c1f24;
+ --surface-alt: #23272e;
+ --border: #333840;
+ --text: #e9e9e6;
+ --text-muted: #9aa0a8;
+ --accent: #5fb98a;
+ --accent-text: #10241a;
+ --accent-soft: #1f3a2c;
+ --danger: #f2867d;
+ --danger-soft: #3a201e;
+ --warn: #e0a94a;
+ --shadow: 0 1px 2px rgb(0 0 0 / 40%), 0 8px 24px rgb(0 0 0 / 30%);
+
+ color-scheme: dark;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ min-height: 100%;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 16px;
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+}
+
+button {
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+}
+
+input {
+ font: inherit;
+ color: inherit;
+}
+
+/* Visible only to assistive technology. */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/web/src/app.html b/web/src/app.html
new file mode 100644
index 0000000..1f12f29
--- /dev/null
+++ b/web/src/app.html
@@ -0,0 +1,31 @@
+
+
+
+{/if}
+
+
diff --git a/web/src/lib/countdown.js b/web/src/lib/countdown.js
new file mode 100644
index 0000000..c62421a
--- /dev/null
+++ b/web/src/lib/countdown.js
@@ -0,0 +1,50 @@
+/**
+ * The countdown is cosmetic. The server decides when a turn expires and
+ * announces it; the ring only has to avoid contradicting that decision.
+ *
+ * SETTLE_MS is why: it makes the display reach zero slightly before the real
+ * deadline. A ring showing "2 giây" while the server has already timed the
+ * player out reads as the game cheating, whereas reaching zero a fraction
+ * early reads as a rounding.
+ */
+export const SETTLE_MS = 300;
+
+/**
+ * Milliseconds left on the current turn, never negative.
+ *
+ * @param {number} deadlineMs - the server's absolute deadline
+ * @param {number} serverNowMs - the server clock as this client estimates it
+ * @param {number} [settleMs]
+ * @returns {number}
+ */
+export function remainingMs(deadlineMs, serverNowMs, settleMs = SETTLE_MS) {
+ if (!deadlineMs) return 0;
+ return Math.max(0, deadlineMs - settleMs - serverNowMs);
+}
+
+/**
+ * How much of the turn is left, as 0..1.
+ *
+ * A turn limit of zero means no timed turn is in progress, which is full
+ * rather than empty: an empty ring would announce a timeout that is not
+ * happening.
+ *
+ * @param {number} remaining
+ * @param {number} limitMs
+ * @returns {number}
+ */
+export function fraction(remaining, limitMs) {
+ if (limitMs <= 0) return 1;
+ return Math.min(1, Math.max(0, remaining / limitMs));
+}
+
+/**
+ * Whole seconds to display. Rounded up, so the last second is shown as "1"
+ * for its whole duration instead of flashing "0" while time remains.
+ *
+ * @param {number} remaining
+ * @returns {number}
+ */
+export function seconds(remaining) {
+ return Math.ceil(remaining / 1000);
+}
diff --git a/web/src/lib/i18n/vi.js b/web/src/lib/i18n/vi.js
new file mode 100644
index 0000000..bd9622f
--- /dev/null
+++ b/web/src/lib/i18n/vi.js
@@ -0,0 +1,173 @@
+import { RejectReason, GameEndReason, Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
+
+/**
+ * Every user-facing string in the app. Nothing outside this file writes
+ * Vietnamese prose, which is what lets the server send UI keys such as
+ * "room_not_found" instead of sentences.
+ */
+export const t = {
+ appName: 'Nối Từ',
+ tagline: 'Trò chơi nối từ tiếng Việt',
+
+ nicknameLabel: 'Tên của bạn',
+ nicknamePlaceholder: 'Nhập tên hiển thị',
+ nicknameHint: 'Tối đa 20 ký tự. Máy chủ có thể rút gọn tên của bạn.',
+
+ playBot: 'Chơi với máy',
+ playOnline: 'Đấu trực tuyến',
+ difficultyLabel: 'Độ khó',
+ back: 'Quay lại',
+ dismiss: 'Bỏ qua',
+ home: 'Về trang chủ',
+ rematch: 'Chơi lại',
+ resign: 'Đầu hàng',
+ resignConfirm: 'Bạn chắc chắn muốn đầu hàng?',
+
+ yourTurn: 'Đến lượt bạn',
+ opponentTurn: 'Đối thủ đang suy nghĩ…',
+ currentSyllable: 'Nối tiếp tiếng',
+ wordInputPlaceholder: 'Nhập từ của bạn',
+ submit: 'Gửi',
+ chainTitle: 'Chuỗi từ',
+ chainEmpty: 'Chưa có từ nào.',
+ you: 'Bạn',
+ opponent: 'Đối thủ',
+ scoreLabel: 'Điểm',
+ syllableUnit: 'tiếng',
+ correctedFrom: 'Bạn gõ “{typed}”, từ đúng là “{word}”.',
+
+ secondsLeft: '{n} giây',
+ waiting: 'Đang chờ…',
+ connecting: 'Đang kết nối…',
+ connected: 'Đã kết nối',
+ reconnecting: 'Mất kết nối, đang thử lại…',
+ offline: 'Không có kết nối',
+
+ won: 'Bạn thắng!',
+ lost: 'Bạn thua.',
+ finalScore: 'Điểm cuối cùng',
+ chainLength: 'Số từ trong chuỗi',
+ newRecord: 'Kỷ lục mới!',
+ bestScore: 'Kỷ lục',
+ noBestScore: 'Chưa có',
+
+ theme: 'Giao diện',
+ themeLight: 'Sáng',
+ themeDark: 'Tối',
+
+ onlineTitle: 'Đấu trực tuyến',
+ onlineComingSoon: 'Chế độ đấu 1v1 sẽ có ở bản cập nhật tiếp theo.',
+
+ attributionIntro: 'Từ điển dựa trên',
+ attributionSource: 'minhqnd/dictionary',
+ attributionLicense: 'giấy phép CC BY-SA 4.0',
+ attributionMiddle: 'phát hành theo'
+};
+
+/**
+ * Bot difficulty labels, keyed by the proto enum.
+ *
+ * @type {Record}
+ */
+export const difficultyLabels = {
+ [Difficulty.EASY]: 'Dễ',
+ [Difficulty.MEDIUM]: 'Trung bình',
+ [Difficulty.HARD]: 'Khó'
+};
+
+/** The three difficulties offered on the home screen, in ladder order. */
+export const difficultyOrder = [Difficulty.EASY, Difficulty.MEDIUM, Difficulty.HARD];
+
+/**
+ * Why a word was refused. `{syllable}` is filled from the syllable the server
+ * is currently asking for — the reason alone does not say which one it was.
+ *
+ * @type {Record}
+ */
+export const rejectMessages = {
+ [RejectReason.UNSPECIFIED]: 'Từ không hợp lệ.',
+ [RejectReason.TOO_FEW_SYLLABLES]: 'Từ phải có ít nhất 2 tiếng.',
+ [RejectReason.WRONG_LINK]: 'Từ phải bắt đầu bằng tiếng “{syllable}”.',
+ [RejectReason.NOT_IN_DICTIONARY]: 'Không tìm thấy từ này trong từ điển.',
+ [RejectReason.ALREADY_USED]: 'Từ này đã được dùng rồi.',
+ [RejectReason.NOT_YOUR_TURN]: 'Chưa đến lượt bạn.',
+ [RejectReason.TIMEOUT]: 'Hết giờ!',
+ [RejectReason.GAME_OVER]: 'Ván đấu đã kết thúc.'
+};
+
+/**
+ * How a finished game ended, phrased from the losing or winning side.
+ *
+ * @type {Record}
+ */
+export const endReasonMessages = {
+ [GameEndReason.UNSPECIFIED]: '',
+ [GameEndReason.TIMEOUT]: 'Hết thời gian suy nghĩ.',
+ [GameEndReason.NO_LEGAL_MOVE]: 'Không còn từ nào nối được.',
+ [GameEndReason.OPPONENT_LEFT]: 'Đối thủ đã rời trận.',
+ [GameEndReason.RESIGNED]: 'Có người đầu hàng.'
+};
+
+/**
+ * ServerError.code is a UI key, so this is where those keys become sentences.
+ * An unknown code falls back to `errorFallback` rather than showing the raw
+ * key: a key leaking into the UI is a bug, not a message.
+ *
+ * @type {Record}
+ */
+export const errorMessages = {
+ already_greeted: 'Phiên chơi đã được mở rồi.',
+ bad_frame: 'Máy chủ không đọc được dữ liệu gửi lên.',
+ busy: 'Bạn đang ở trong một ván đấu khác.',
+ cannot_join_own_room: 'Bạn không thể vào phòng của chính mình.',
+ game_already_over: 'Ván đấu đã kết thúc.',
+ game_not_started: 'Ván đấu chưa bắt đầu.',
+ game_start_failed: 'Không thể bắt đầu ván đấu. Hãy thử lại.',
+ handshake_required: 'Phiên chơi chưa sẵn sàng. Hãy tải lại trang.',
+ not_in_a_game: 'Bạn không ở trong ván đấu nào.',
+ not_your_seat: 'Bạn không phải người chơi trong ván này.',
+ protocol_version_mismatch: 'Phiên bản đã cũ. Hãy tải lại trang.',
+ room_full: 'Phòng đã đủ người.',
+ room_not_found: 'Không tìm thấy phòng với mã này.',
+ room_start_failed: 'Không thể tạo phòng. Hãy thử lại.',
+ server_restarting: 'Máy chủ đang khởi động lại. Hãy thử lại sau giây lát.',
+ session_not_resumable: 'Không khôi phục được ván đấu trước.',
+ too_fast: 'Bạn thao tác quá nhanh. Chậm lại một chút nhé.',
+ too_many_attempts: 'Bạn thử vào phòng quá nhiều lần. Hãy đợi một lát.',
+ too_many_rooms: 'Bạn tạo phòng quá nhanh. Hãy đợi một lát.',
+ unknown_difficulty: 'Độ khó không hợp lệ.'
+};
+
+export const errorFallback = 'Đã có lỗi xảy ra. Hãy thử lại.';
+
+/**
+ * Fills `{name}` placeholders. Keeping interpolation here means a message can
+ * gain a placeholder without every call site learning about it.
+ *
+ * @param {string} template
+ * @param {Record} [values]
+ * @returns {string}
+ */
+export function fill(template, values = {}) {
+ return template.replace(/\{(\w+)\}/g, (whole, key) =>
+ key in values ? String(values[key]) : whole
+ );
+}
+
+/**
+ * @param {number} reason - a RejectReason enum value
+ * @param {string} syllable - the syllable the server is currently asking for
+ * @returns {string}
+ */
+export function rejectMessage(reason, syllable) {
+ const template = rejectMessages[reason] ?? rejectMessages[RejectReason.UNSPECIFIED];
+ return fill(template, { syllable });
+}
+
+/**
+ * @param {string} code - ServerError.code
+ * @returns {string}
+ */
+export function errorMessage(code) {
+ return errorMessages[code] ?? errorFallback;
+}
diff --git a/web/src/lib/stores/bot-session.svelte.js b/web/src/lib/stores/bot-session.svelte.js
new file mode 100644
index 0000000..d1fc098
--- /dev/null
+++ b/web/src/lib/stores/bot-session.svelte.js
@@ -0,0 +1,86 @@
+/**
+ * Owns the lifecycle of a vs-bot game: when one is asked for, when that request
+ * goes out, and when its result counts towards a record.
+ *
+ * It exists because inferring the lifecycle from the game model does not work.
+ * "Start a game when the store is idle" reads as an invariant but is really a
+ * trigger, and clearing the board for a rematch trips it — so the rematch
+ * button would send one request itself and the inference would send a second.
+ * The server allocates a room per request, so two requests meant two rooms
+ * fighting over one socket. A request is a thing the player made, so it is
+ * stored as one.
+ */
+
+/**
+ * @param {object} options
+ * @param {(difficulty: number) => boolean} options.start - sends StartBotGame,
+ * reporting whether the socket took it
+ */
+export function createBotSession({ start }) {
+ const state = $state({
+ /** @type {number | null} The difficulty waiting to be requested. */
+ pending: null,
+ /** @type {object | null} The result already counted towards a record. */
+ scored: null
+ });
+
+ return {
+ state,
+
+ /**
+ * Queues a game. Nothing is sent until the socket is open, which is the
+ * usual case on a fresh page load.
+ *
+ * @param {number} difficulty
+ */
+ request(difficulty) {
+ state.pending = difficulty;
+ },
+
+ /**
+ * Sends the queued request if there is one and the socket can carry it.
+ *
+ * @param {boolean} isOpen
+ * @returns {boolean} whether a request was sent
+ */
+ flush(isOpen) {
+ if (state.pending === null || !isOpen) return false;
+ // Cleared only once the socket has taken it. A refused send never
+ // reached the server, so holding the request lets the next open
+ // connection carry it instead of leaving the player on a dead board.
+ // This cannot duplicate a room: only an explicit ask sets `pending`.
+ if (!start(state.pending)) return false;
+ state.pending = null;
+ return true;
+ },
+
+ /** Drops a queued request, for a player who left before it went out. */
+ cancel() {
+ state.pending = null;
+ },
+
+ /**
+ * Counts a finished game towards the record for its difficulty, once.
+ *
+ * Identity of the result object is the guard rather than a boolean, so
+ * re-entering the screen with the same result cannot score it twice and
+ * a genuinely new result is never mistaken for the old one.
+ *
+ * @param {object | null} result - GameOver as the store holds it
+ * @param {number} difficulty
+ * @param {{ recordScore: (difficulty: number, score: number) => boolean }} settings
+ * @returns {boolean} whether this game set a new record
+ */
+ score(result, difficulty, settings) {
+ if (!result || state.scored === result) return false;
+ state.scored = result;
+ return settings.recordScore(difficulty, /** @type {any} */ (result).myScore);
+ },
+
+ /** Forgets the scored result so a new game can set a record again. */
+ reset() {
+ state.pending = null;
+ state.scored = null;
+ }
+ };
+}
diff --git a/web/src/lib/stores/game.svelte.js b/web/src/lib/stores/game.svelte.js
new file mode 100644
index 0000000..d37fd48
--- /dev/null
+++ b/web/src/lib/stores/game.svelte.js
@@ -0,0 +1,188 @@
+import { rejectMessage, errorMessage } from '$lib/i18n/vi.js';
+
+/**
+ * The game model is a projection of what the server sent. The client never
+ * decides whether a word is valid, whose turn it is, or who won — it renders
+ * the last message it received. That is what makes the bot and the online
+ * modes the same screen.
+ *
+ * @typedef {object} ChainEntry
+ * @property {string} word - the canonical spelling
+ * @property {string} typed - what the player actually typed, when it differed
+ * @property {boolean} byMe
+ * @property {number} points
+ * @property {number} syllables
+ * @property {boolean} opening - the seed word, played by neither side
+ */
+
+/** @returns {any} */
+function initialState() {
+ return {
+ /** @type {'idle' | 'playing' | 'over'} */
+ phase: 'idle',
+ /** @type {ChainEntry[]} */
+ chain: [],
+ currentSyllable: '',
+ myTurn: false,
+ deadlineMs: 0,
+ turnSeq: 0,
+ turnLimitMs: 0,
+ myScore: 0,
+ opponentScore: 0,
+ chainLength: 0,
+
+ nickname: '',
+ opponentName: '',
+ roomCode: '',
+
+ /** @type {{ word: string, message: string } | null} */
+ rejection: null,
+ /** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null} */
+ result: null,
+ /** @type {{ canReconnect: boolean, graceMs: number } | null} */
+ opponentLeft: null,
+ /** @type {string | null} */
+ error: null
+ };
+}
+
+/**
+ * Builds a store instance. Tests construct their own rather than sharing the
+ * module singleton, so one test's game cannot leak into the next.
+ */
+export function createGameStore() {
+ const state = $state(initialState());
+
+ /** Returns the model to its pre-game shape, keeping the identity fields. */
+ function reset() {
+ const fresh = initialState();
+ for (const key of Object.keys(fresh)) {
+ if (key === 'nickname' || key === 'roomCode' || key === 'opponentName') continue;
+ state[key] = fresh[key];
+ }
+ }
+
+ /**
+ * Applies one ServerMessage. Every arm of the oneof is handled here and
+ * nowhere else, so adding a message to the protocol has exactly one place
+ * in the client that has to learn about it.
+ *
+ * @param {any} msg - a decoded ServerMessage
+ */
+ function apply(msg) {
+ const { case: kind, value } = msg.payload;
+
+ switch (kind) {
+ case 'welcome':
+ // The server sanitizes the requested name, so what it returns
+ // is the only name safe to display — never the raw input.
+ state.nickname = value.acceptedNickname;
+ break;
+
+ case 'roomCreated':
+ state.roomCode = value.roomCode;
+ break;
+
+ case 'roomJoined':
+ state.roomCode = value.roomCode;
+ state.opponentName = value.opponentName;
+ break;
+
+ case 'gameStarted':
+ reset();
+ state.phase = 'playing';
+ state.chain = [
+ {
+ word: value.openingWord,
+ typed: '',
+ byMe: false,
+ points: 0,
+ syllables: 0,
+ opening: true
+ }
+ ];
+ state.currentSyllable = value.currentSyllable;
+ state.myTurn = value.myTurn;
+ state.deadlineMs = Number(value.deadlineUnixMs);
+ state.turnSeq = value.turnSeq;
+ state.turnLimitMs = value.turnLimitMs;
+ state.chainLength = 1;
+ break;
+
+ case 'turnUpdate': {
+ const played = value.played;
+ if (played) {
+ state.chain.push({
+ word: played.word,
+ typed: played.typed,
+ byMe: played.byMe,
+ points: played.points,
+ syllables: played.syllables,
+ opening: false
+ });
+ }
+ state.currentSyllable = value.currentSyllable;
+ state.myTurn = value.myTurn;
+ state.deadlineMs = Number(value.deadlineUnixMs);
+ state.turnSeq = value.turnSeq;
+ state.myScore = value.myScore;
+ state.opponentScore = value.opponentScore;
+ state.chainLength = value.chainLength;
+ // An accepted move answers the previous rejection.
+ state.rejection = null;
+ break;
+ }
+
+ case 'moveRejected':
+ state.rejection = {
+ word: value.word,
+ message: rejectMessage(value.reason, state.currentSyllable)
+ };
+ break;
+
+ case 'gameOver':
+ state.phase = 'over';
+ state.myTurn = false;
+ state.result = {
+ iWon: value.iWon,
+ reason: value.reason,
+ myScore: value.myScore,
+ chainLength: value.chainLength
+ };
+ break;
+
+ case 'opponentLeft':
+ state.opponentLeft = {
+ canReconnect: value.canReconnect,
+ graceMs: value.graceMs
+ };
+ break;
+
+ case 'error':
+ state.error = errorMessage(value.code);
+ break;
+
+ case 'pong':
+ // Handled by the transport, which owns the clock offset.
+ break;
+ }
+ }
+
+ return {
+ state,
+ apply,
+ reset,
+ clearRejection() {
+ state.rejection = null;
+ },
+ clearError() {
+ state.error = null;
+ },
+ clearOpponentLeft() {
+ state.opponentLeft = null;
+ }
+ };
+}
+
+/** The store the routes share. */
+export const game = createGameStore();
diff --git a/web/src/lib/stores/settings.svelte.js b/web/src/lib/stores/settings.svelte.js
new file mode 100644
index 0000000..b5411e1
--- /dev/null
+++ b/web/src/lib/stores/settings.svelte.js
@@ -0,0 +1,161 @@
+/**
+ * Client-owned state: the three things the server has no opinion about.
+ *
+ * Every read and write goes through the guards below because localStorage is
+ * not always there. A private window, a browser configured to block site data,
+ * or a storage quota that is already full each fail differently, and some fail
+ * on the property access itself rather than on the call. A player in that
+ * state should lose their preferences, not the game.
+ */
+
+const NICKNAME_KEY = 'noitu.nickname';
+const THEME_KEY = 'noitu.theme';
+const BEST_KEY = 'noitu.bestScores';
+
+/** Mirrors the server's own cap so the input cannot promise a name it will lose. */
+export const MAX_NICKNAME_LENGTH = 20;
+
+/** @returns {Storage | null} */
+function safeStorage() {
+ try {
+ return globalThis.localStorage ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * @param {string} key
+ * @param {string} fallback
+ * @returns {string}
+ */
+function read(key, fallback) {
+ try {
+ return safeStorage()?.getItem(key) ?? fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+/**
+ * @param {string} key
+ * @param {string} value
+ */
+function write(key, value) {
+ try {
+ safeStorage()?.setItem(key, value);
+ } catch {
+ // In-memory state stays correct for this session; only persistence is
+ // lost, and there is no useful recovery from a storage refusal.
+ }
+}
+
+/**
+ * Best scores are stored as one JSON object keyed by difficulty. Corrupt or
+ * hand-edited JSON degrades to "no records yet" rather than throwing on load.
+ *
+ * @returns {Record}
+ */
+function readBestScores() {
+ const raw = read(BEST_KEY, '');
+ if (!raw) return {};
+ try {
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
+ /** @type {Record} */
+ const clean = {};
+ for (const [key, value] of Object.entries(parsed)) {
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
+ clean[key] = value;
+ }
+ }
+ return clean;
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * @param {string} value
+ * @returns {'light' | 'dark'}
+ */
+function normalizeTheme(value) {
+ return value === 'dark' ? 'dark' : 'light';
+}
+
+/** Reads the theme the inline script in app.html already applied, if any. */
+function initialTheme() {
+ const saved = read(THEME_KEY, '');
+ if (saved === 'dark' || saved === 'light') return saved;
+ try {
+ return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ } catch {
+ return 'light';
+ }
+}
+
+export function createSettingsStore() {
+ const state = $state({
+ nickname: read(NICKNAME_KEY, ''),
+ /** @type {'light' | 'dark'} */
+ theme: initialTheme(),
+ /** @type {Record} */
+ bestScores: readBestScores()
+ });
+
+ return {
+ state,
+
+ /** @param {string} value */
+ setNickname(value) {
+ const trimmed = [...value].slice(0, MAX_NICKNAME_LENGTH).join('');
+ state.nickname = trimmed;
+ write(NICKNAME_KEY, trimmed);
+ },
+
+ /** @param {'light' | 'dark'} value */
+ setTheme(value) {
+ const theme = normalizeTheme(value);
+ state.theme = theme;
+ write(THEME_KEY, theme);
+ try {
+ globalThis.document?.documentElement?.setAttribute('data-theme', theme);
+ } catch {
+ // No document under test; the state above is what matters.
+ }
+ },
+
+ toggleTheme() {
+ this.setTheme(state.theme === 'dark' ? 'light' : 'dark');
+ },
+
+ /**
+ * @param {number|string} difficulty - a Difficulty enum value
+ * @returns {number}
+ */
+ bestScore(difficulty) {
+ return state.bestScores[String(difficulty)] ?? 0;
+ },
+
+ /**
+ * Records a score and reports whether it beat the previous best, which
+ * is what the game-over screen needs to show the record marker. Ties do
+ * not count: matching your own record is not setting a new one.
+ *
+ * @param {number|string} difficulty
+ * @param {number} score
+ * @returns {boolean}
+ */
+ recordScore(difficulty, score) {
+ const key = String(difficulty);
+ const previous = state.bestScores[key] ?? 0;
+ if (score <= previous) return false;
+ state.bestScores = { ...state.bestScores, [key]: score };
+ write(BEST_KEY, JSON.stringify(state.bestScores));
+ return true;
+ }
+ };
+}
+
+/** The store the routes share. */
+export const settings = createSettingsStore();
diff --git a/web/src/lib/ws/client.js b/web/src/lib/ws/client.js
new file mode 100644
index 0000000..0c24d81
--- /dev/null
+++ b/web/src/lib/ws/client.js
@@ -0,0 +1,289 @@
+import { fromBinary, toBinary } from '@bufbuild/protobuf';
+import { ClientMessageSchema, ServerMessageSchema } from '$lib/proto/noitu/v1/game_pb.js';
+import { hello, ping } from './messages.js';
+
+/** Connection states surfaced to the UI. */
+export const Status = {
+ CONNECTING: 'connecting',
+ OPEN: 'open',
+ RECONNECTING: 'reconnecting',
+ CLOSED: 'closed'
+};
+
+/**
+ * Reconnect delays in milliseconds, capped rather than unbounded. The last
+ * value repeats forever: a server that has been down for a minute is being
+ * restarted or redeployed, and hammering it does not bring it back sooner.
+ */
+export const BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
+
+/** How often the clock-offset probe is sent while the socket is open. */
+export const PING_INTERVAL_MS = 5000;
+
+/**
+ * Server errors that reconnecting cannot fix.
+ *
+ * The server closes the socket after refusing a `Hello` it cannot speak to, and
+ * the next attempt would send the same rejected handshake. Without this the
+ * client reconnects every half second forever after a deploy that bumps the
+ * protocol.
+ */
+export const TERMINAL_ERROR_CODES = new Set(['protocol_version_mismatch']);
+
+const RESUME_KEY = 'noitu.resumeToken';
+
+/**
+ * Resolves the socket URL from the page's own origin.
+ *
+ * The dev server proxies /ws to the Go binary and production serves both from
+ * one origin, so the client never has an environment-specific URL to get
+ * wrong. That divergence is the one thing that would work under `npm run dev`
+ * and break the moment the binary serves the bundle.
+ *
+ * @param {{ protocol: string, host: string }} loc
+ * @returns {string}
+ */
+export function socketUrl(loc) {
+ const scheme = loc.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${scheme}//${loc.host}/ws`;
+}
+
+/**
+ * Session storage, not local: a resume token belongs to one tab's game, and
+ * two tabs sharing one token would fight over the same seat. Access is guarded
+ * because a private-mode browser can throw on the property itself, not only on
+ * the call.
+ *
+ * @returns {Storage | null}
+ */
+function safeSessionStorage() {
+ try {
+ return globalThis.sessionStorage ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Creates the socket client.
+ *
+ * Everything environment-shaped is injected, so the reconnect schedule and the
+ * clock offset can be tested without a real socket or a real clock.
+ *
+ * @param {object} options
+ * @param {() => string} options.nickname - read at each connect, so a name
+ * changed between attempts is the one the server is told about
+ * @param {(msg: any) => void} options.onMessage
+ * @param {(status: string) => void} [options.onStatus]
+ * @param {string} [options.url]
+ * @param {(url: string) => WebSocket} [options.socketFactory]
+ * @param {() => number} [options.now]
+ * @param {() => number} [options.random] - jitter source
+ * @param {typeof setTimeout} [options.schedule]
+ * @param {(id: any) => void} [options.cancel]
+ */
+export function createClient({
+ nickname,
+ onMessage,
+ onStatus = () => {},
+ url = socketUrl(globalThis.location ?? { protocol: 'http:', host: 'localhost' }),
+ socketFactory = (u) => new WebSocket(u),
+ now = () => Date.now(),
+ random = Math.random,
+ schedule = setTimeout,
+ cancel = clearTimeout
+}) {
+ /** @type {WebSocket | null} */
+ let socket = null;
+ let attempt = 0;
+ // Set by a deliberate close and by a server error that reconnecting cannot
+ // fix. Both mean the same thing to onclose: do not come back.
+ let stopReconnecting = false;
+ /** @type {any} */
+ let reconnectTimer = null;
+ /** @type {any} */
+ let pingTimer = null;
+ let clockOffsetMs = 0;
+ let status = Status.CLOSED;
+
+ /** @param {string} next */
+ function setStatus(next) {
+ if (status === next) return;
+ status = next;
+ onStatus(next);
+ }
+
+ function storedToken() {
+ try {
+ return safeSessionStorage()?.getItem(RESUME_KEY) ?? '';
+ } catch {
+ return '';
+ }
+ }
+
+ /** @param {string} token */
+ function storeToken(token) {
+ try {
+ safeSessionStorage()?.setItem(RESUME_KEY, token);
+ } catch {
+ // A browser refusing storage costs the player a reconnect, not the
+ // session. There is nothing to recover here.
+ }
+ }
+
+ function stopPing() {
+ if (pingTimer !== null) {
+ cancel(pingTimer);
+ pingTimer = null;
+ }
+ }
+
+ function schedulePing() {
+ stopPing();
+ pingTimer = schedule(() => {
+ send(ping(now()));
+ schedulePing();
+ }, PING_INTERVAL_MS);
+ }
+
+ /**
+ * Backoff with jitter: several tabs dropped by one restart would otherwise
+ * come back in lockstep and arrive as a single burst.
+ */
+ function nextDelay() {
+ const base = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
+ attempt += 1;
+ return Math.round(base * (0.5 + 0.5 * random()));
+ }
+
+ function scheduleReconnect() {
+ if (stopReconnecting || reconnectTimer !== null) return;
+ setStatus(Status.RECONNECTING);
+ reconnectTimer = schedule(() => {
+ reconnectTimer = null;
+ open();
+ }, nextDelay());
+ }
+
+ function open() {
+ stopReconnecting = false;
+ setStatus(attempt === 0 ? Status.CONNECTING : Status.RECONNECTING);
+
+ const ws = socketFactory(url);
+ ws.binaryType = 'arraybuffer';
+ socket = ws;
+
+ ws.onopen = () => {
+ // Hello goes out before the status is announced. The server refuses
+ // every other message until the handshake lands, and a listener
+ // reacting to "open" by sending something would otherwise race it.
+ send(hello({ nickname: nickname(), resumeToken: storedToken() }));
+ // Probe the clock immediately rather than waiting out the first
+ // interval. GameStarted arrives about one round trip after Hello, so
+ // a deferred first probe would leave the opening turn counting down
+ // against the raw device clock.
+ send(ping(now()));
+ schedulePing();
+ setStatus(Status.OPEN);
+ };
+
+ ws.onmessage = (event) => {
+ let msg;
+ try {
+ msg = fromBinary(ServerMessageSchema, new Uint8Array(event.data));
+ } catch {
+ // An undecodable frame is a contract violation, not a game
+ // event. Dropping it stops one bad frame from ending the
+ // session; an incompatible peer is caught by the server's own
+ // version check instead.
+ return;
+ }
+ intercept(msg);
+ onMessage(msg);
+ };
+
+ ws.onclose = () => {
+ stopPing();
+ socket = null;
+ if (stopReconnecting) {
+ setStatus(Status.CLOSED);
+ return;
+ }
+ scheduleReconnect();
+ };
+
+ ws.onerror = () => {
+ // onclose always follows, and that is where the reconnect is
+ // scheduled. Handling both would schedule it twice.
+ };
+ }
+
+ /**
+ * Two messages are the transport's own business before the UI sees them:
+ * Welcome carries the token a reconnect needs, and Pong is the clock probe.
+ * Both are still forwarded, because the UI shows the accepted nickname.
+ *
+ * @param {any} msg
+ */
+ function intercept(msg) {
+ const payload = msg.payload;
+ if (payload.case === 'welcome') {
+ // The backoff resets here, not when the socket opens. A server that
+ // accepts the connection and then rejects the handshake would
+ // otherwise look like a success to the schedule, and every retry
+ // would start again from the shortest delay.
+ attempt = 0;
+ if (payload.value.resumeToken) storeToken(payload.value.resumeToken);
+ return;
+ }
+ if (payload.case === 'error' && TERMINAL_ERROR_CODES.has(payload.value.code)) {
+ stopReconnecting = true;
+ return;
+ }
+ if (payload.case === 'pong') {
+ const sent = Number(payload.value.clientTimeMs);
+ const serverTime = Number(payload.value.serverTimeMs);
+ const received = now();
+ // Half the round trip is the best estimate of the one-way delay, so
+ // the server's timestamp is compared against the midpoint of the
+ // exchange rather than against either end of it.
+ clockOffsetMs = serverTime + (received - sent) / 2 - received;
+ }
+ }
+
+ /** @param {any} msg */
+ function send(msg) {
+ if (!socket || socket.readyState !== 1) return false;
+ socket.send(toBinary(ClientMessageSchema, msg));
+ return true;
+ }
+
+ return {
+ connect: open,
+ send,
+ /** The server's clock as this client best estimates it. */
+ serverNow: () => now() + clockOffsetMs,
+ clockOffset: () => clockOffsetMs,
+ status: () => status,
+ /** Deliberate teardown: no reconnect follows. */
+ close() {
+ stopReconnecting = true;
+ stopPing();
+ if (reconnectTimer !== null) {
+ cancel(reconnectTimer);
+ reconnectTimer = null;
+ }
+ socket?.close();
+ socket = null;
+ setStatus(Status.CLOSED);
+ },
+ /** Forgets the resume token so the next Hello starts a fresh session. */
+ forgetSession() {
+ try {
+ safeSessionStorage()?.removeItem(RESUME_KEY);
+ } catch {
+ // Same as storeToken: nothing to recover.
+ }
+ }
+ };
+}
diff --git a/web/src/lib/ws/connection.svelte.js b/web/src/lib/ws/connection.svelte.js
new file mode 100644
index 0000000..0333469
--- /dev/null
+++ b/web/src/lib/ws/connection.svelte.js
@@ -0,0 +1,69 @@
+import { Status, createClient } from './client.js';
+import { game } from '$lib/stores/game.svelte.js';
+import { settings } from '$lib/stores/settings.svelte.js';
+
+/**
+ * One socket for the whole app.
+ *
+ * The client itself is framework-agnostic and injectable, which is what makes
+ * it testable; this module is the small reactive shell that binds that one
+ * instance to the two stores and to the component tree.
+ */
+const state = $state({ status: Status.CLOSED });
+
+/** @type {ReturnType | null} */
+let client = null;
+
+/** Opens the socket if it is not already open. Safe to call from any route. */
+export function connect() {
+ if (client) return;
+ client = createClient({
+ nickname: () => settings.state.nickname,
+ onMessage: (msg) => game.apply(msg),
+ onStatus: (status) => {
+ state.status = status;
+ }
+ });
+ client.connect();
+}
+
+/**
+ * Sends a message, reporting whether it actually went out.
+ *
+ * Deliberately does not open the socket: a caller that has not connected yet
+ * has nothing queued to resume, and auto-connecting here would reopen the
+ * connection during teardown.
+ *
+ * @param {any} msg - a ClientMessage
+ * @returns {boolean}
+ */
+export function send(msg) {
+ return client?.send(msg) ?? false;
+}
+
+/**
+ * The server's clock as this client estimates it. The countdown is drawn
+ * against this rather than Date.now(), so a device with a wrong clock still
+ * shows the right remaining time.
+ */
+export function serverNow() {
+ return client?.serverNow() ?? Date.now();
+}
+
+/**
+ * Closes the socket and forgets the session.
+ *
+ * Called when the player leaves the board. Keeping the socket open across
+ * routes would mean the next game is announced to the server under whatever
+ * nickname the previous `Hello` carried, and the resume token would offer the
+ * abandoned seat back to a connection that no longer wants it.
+ */
+export function disconnect() {
+ client?.forgetSession();
+ client?.close();
+ client = null;
+ state.status = Status.CLOSED;
+}
+
+export const connection = state;
+export { Status };
diff --git a/web/src/lib/ws/messages.js b/web/src/lib/ws/messages.js
new file mode 100644
index 0000000..3662657
--- /dev/null
+++ b/web/src/lib/ws/messages.js
@@ -0,0 +1,91 @@
+import { create } from '@bufbuild/protobuf';
+import {
+ ClientMessageSchema,
+ CreateRoomSchema,
+ HelloSchema,
+ JoinRoomSchema,
+ PingSchema,
+ ResignSchema,
+ StartBotGameSchema,
+ SubmitWordSchema
+} from '$lib/proto/noitu/v1/game_pb.js';
+
+/**
+ * The protocol version this build speaks. The server refuses anything else
+ * with `protocol_version_mismatch` rather than failing to decode, so this
+ * constant is the client half of that contract.
+ */
+export const PROTOCOL_VERSION = 1;
+
+/**
+ * Thin builders, one per client message. They exist so no other module has to
+ * know the shape of the `payload` oneof, and so a schema change breaks in one
+ * file instead of across the UI.
+ *
+ * @param {{ nickname: string, resumeToken?: string }} args
+ */
+export function hello({ nickname, resumeToken = '' }) {
+ return create(ClientMessageSchema, {
+ payload: {
+ case: 'hello',
+ value: create(HelloSchema, {
+ protocolVersion: PROTOCOL_VERSION,
+ resumeToken,
+ nickname
+ })
+ }
+ });
+}
+
+/** @param {number} difficulty - a Difficulty enum value */
+export function startBotGame(difficulty) {
+ return create(ClientMessageSchema, {
+ payload: {
+ case: 'startBotGame',
+ value: create(StartBotGameSchema, { difficulty })
+ }
+ });
+}
+
+export function createRoom() {
+ return create(ClientMessageSchema, {
+ payload: { case: 'createRoom', value: create(CreateRoomSchema, {}) }
+ });
+}
+
+/** @param {string} roomCode */
+export function joinRoom(roomCode) {
+ return create(ClientMessageSchema, {
+ payload: { case: 'joinRoom', value: create(JoinRoomSchema, { roomCode }) }
+ });
+}
+
+/**
+ * turnSeq is not decoration: the server refuses a submission tagged with a
+ * turn that is no longer current, which is what makes a double-submit or a
+ * word racing the timeout visible instead of silently applied.
+ *
+ * @param {string} word
+ * @param {number} turnSeq
+ */
+export function submitWord(word, turnSeq) {
+ return create(ClientMessageSchema, {
+ payload: { case: 'submitWord', value: create(SubmitWordSchema, { word, turnSeq }) }
+ });
+}
+
+export function resign() {
+ return create(ClientMessageSchema, {
+ payload: { case: 'resign', value: create(ResignSchema, {}) }
+ });
+}
+
+/** @param {number} clientTimeMs */
+export function ping(clientTimeMs) {
+ return create(ClientMessageSchema, {
+ payload: {
+ case: 'ping',
+ value: create(PingSchema, { clientTimeMs: BigInt(Math.trunc(clientTimeMs)) })
+ }
+ });
+}
diff --git a/web/src/routes/+layout.js b/web/src/routes/+layout.js
new file mode 100644
index 0000000..0c98b29
--- /dev/null
+++ b/web/src/routes/+layout.js
@@ -0,0 +1,5 @@
+// A pure single-page app. Every screen depends on a live socket, so there is
+// nothing meaningful to render on a server or to freeze at build time; the
+// static adapter emits one index.html and the Go binary falls back to it.
+export const ssr = false;
+export const prerender = false;
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
new file mode 100644
index 0000000..4069c21
--- /dev/null
+++ b/web/src/routes/+layout.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
+ {/if}
+
+
+
+ {#if game.state.phase === 'playing'}
+
+ {/if}
+
+
+
diff --git a/web/static/favicon.svg b/web/static/favicon.svg
new file mode 100644
index 0000000..cc090e8
--- /dev/null
+++ b/web/static/favicon.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/svelte.config.js b/web/svelte.config.js
new file mode 100644
index 0000000..260c592
--- /dev/null
+++ b/web/svelte.config.js
@@ -0,0 +1,15 @@
+import adapter from '@sveltejs/adapter-static';
+
+/**
+ * A single-page app: the Go binary serves the built assets and falls back to
+ * index.html for unknown paths, so every route is resolved in the browser.
+ * Prerendering is disabled in src/routes/+layout.js rather than here, because
+ * the game has nothing static to render — every screen depends on a socket.
+ *
+ * @type {import('@sveltejs/kit').Config}
+ */
+export default {
+ kit: {
+ adapter: adapter({ fallback: 'index.html' })
+ }
+};
diff --git a/web/tests/bot-session.test.js b/web/tests/bot-session.test.js
new file mode 100644
index 0000000..4e24e05
--- /dev/null
+++ b/web/tests/bot-session.test.js
@@ -0,0 +1,167 @@
+// The lifecycle these tests cover is where the screen used to infer "start a
+// game" from the board being empty. Clearing the board for a rematch tripped
+// that inference, so a rematch sent two StartBotGame messages and the server
+// built two rooms that then fought over one socket. A request is now a stored
+// intent, and the first test here is the one that would have caught it.
+
+import { describe, expect, it } from 'vitest';
+import { createBotSession } from '../src/lib/stores/bot-session.svelte.js';
+
+/** Records what reached the socket. */
+function recorder(accepted = true) {
+ /** @type {number[]} */
+ const sent = [];
+ return {
+ sent,
+ start: (difficulty) => {
+ sent.push(difficulty);
+ return accepted;
+ }
+ };
+}
+
+describe('requesting a game', () => {
+ it('holds the request until the socket is open', () => {
+ const r = recorder();
+ const session = createBotSession(r);
+
+ session.request(2);
+ expect(session.flush(false)).toBe(false);
+ expect(r.sent).toEqual([]);
+
+ expect(session.flush(true)).toBe(true);
+ expect(r.sent).toEqual([2]);
+ });
+
+ it('sends one request per ask, however often it is flushed', () => {
+ const r = recorder();
+ const session = createBotSession(r);
+
+ session.request(3);
+ session.flush(true);
+ session.flush(true);
+ session.flush(true);
+
+ expect(r.sent).toEqual([3]);
+ });
+
+ it('sends nothing when nothing was asked for', () => {
+ const r = recorder();
+ const session = createBotSession(r);
+
+ expect(session.flush(true)).toBe(false);
+ expect(r.sent).toEqual([]);
+ });
+
+ it('keeps only the latest difficulty when asked twice before sending', () => {
+ const r = recorder();
+ const session = createBotSession(r);
+
+ session.request(1);
+ session.request(3);
+ session.flush(true);
+
+ expect(r.sent).toEqual([3]);
+ });
+
+ it('drops a queued request when the player leaves first', () => {
+ const r = recorder();
+ const session = createBotSession(r);
+
+ session.request(2);
+ session.cancel();
+ session.flush(true);
+
+ expect(r.sent).toEqual([]);
+ });
+
+ it('holds a refused request so the next open connection carries it', () => {
+ // A refused send never reached the server, so there is no room to
+ // duplicate — only a player left staring at a board that never starts.
+ const refused = recorder(false);
+ const session = createBotSession(refused);
+
+ session.request(2);
+ expect(session.flush(true)).toBe(false);
+ expect(session.flush(true)).toBe(false);
+ expect(refused.sent).toEqual([2, 2]);
+ });
+});
+
+describe('recording a result', () => {
+ /** A settings double that remembers the best score per difficulty. */
+ function settingsDouble() {
+ /** @type {Record} */
+ const best = {};
+ return {
+ best,
+ recordScore(difficulty, score) {
+ if (score <= (best[difficulty] ?? 0)) return false;
+ best[difficulty] = score;
+ return true;
+ }
+ };
+ }
+
+ it('reports a first result as a record', () => {
+ const session = createBotSession(recorder());
+ const settings = settingsDouble();
+
+ expect(session.score({ myScore: 40 }, 1, settings)).toBe(true);
+ expect(settings.best[1]).toBe(40);
+ });
+
+ it('counts one result once, however often the screen re-renders', () => {
+ const session = createBotSession(recorder());
+ const settings = settingsDouble();
+ const result = { myScore: 40 };
+
+ expect(session.score(result, 1, settings)).toBe(true);
+ expect(session.score(result, 1, settings)).toBe(false);
+ expect(session.score(result, 1, settings)).toBe(false);
+ });
+
+ it('still scores a later game that happens to have the same numbers', () => {
+ // Identity, not value: two games can end on the same score, and the
+ // second is a fresh result the player deserves credit for.
+ const session = createBotSession(recorder());
+ const settings = settingsDouble();
+
+ session.score({ myScore: 40 }, 1, settings);
+ expect(session.score({ myScore: 90 }, 1, settings)).toBe(true);
+ });
+
+ it('ignores a game that has not finished', () => {
+ const session = createBotSession(recorder());
+ const settings = settingsDouble();
+
+ expect(session.score(null, 1, settings)).toBe(false);
+ expect(settings.best).toEqual({});
+ });
+
+ it('lets a new game set a record again after a reset', () => {
+ const session = createBotSession(recorder());
+ const settings = settingsDouble();
+ const result = { myScore: 40 };
+
+ session.score(result, 1, settings);
+ session.reset();
+
+ // The same object now counts as new, because the reset forgot it.
+ expect(session.score(result, 1, settings)).toBe(false); // no improvement
+ expect(session.score({ myScore: 41 }, 1, settings)).toBe(true);
+ });
+});
+
+describe('reset', () => {
+ it('clears a queued request as well as the scored result', () => {
+ const r = recorder();
+ const session = createBotSession(r);
+
+ session.request(2);
+ session.reset();
+ session.flush(true);
+
+ expect(r.sent).toEqual([]);
+ });
+});
diff --git a/web/tests/bundle.test.js b/web/tests/bundle.test.js
new file mode 100644
index 0000000..a43fce5
--- /dev/null
+++ b/web/tests/bundle.test.js
@@ -0,0 +1,109 @@
+// The wordlist must never reach the browser. If it did, the dictionary would
+// stop being the server's secret and every rule it enforces would become
+// advisory — a player could read the valid answers out of their own bundle.
+//
+// npm test builds before running, so this check always reads a bundle produced
+// from the current source rather than whatever was left in build/.
+
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { join, extname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const buildDir = fileURLToPath(new URL('../build', import.meta.url));
+const srcDir = fileURLToPath(new URL('../src', import.meta.url));
+
+/**
+ * Every file in the built output, recursively.
+ *
+ * @param {string} dir
+ * @returns {string[]}
+ */
+function walk(dir) {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const full = join(dir, entry.name);
+ return entry.isDirectory() ? walk(full) : [full];
+ });
+}
+
+/**
+ * Turns `\uXXXX` sequences back into the characters they stand for.
+ *
+ * A minifier configured to emit ASCII writes Vietnamese as escapes, and a
+ * substring search over the raw bytes would then find nothing while the words
+ * are plainly there in the shipped file.
+ *
+ * @param {string} text
+ * @returns {string}
+ */
+function decodeUnicodeEscapes(text) {
+ return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
+}
+
+/**
+ * Words that belong to the dictionary and to nothing else in this project.
+ * None of them is UI copy, a test fixture, or a placeholder, so any hit is the
+ * wordlist leaking rather than a coincidence.
+ */
+const DICTIONARY_WORDS = [
+ 'học sinh',
+ 'sinh viên',
+ 'bàn ghế',
+ 'quốc gia',
+ 'con mèo',
+ 'giáo viên',
+ 'thành phố',
+ 'nhà cửa'
+];
+
+/**
+ * A budget that binds. The bundle is around 190 KB, so this leaves room for the
+ * phase-7 screens without leaving room for a wordlist: the derived dictionary
+ * is about 3 MB, and even a fraction of it would not fit here.
+ */
+const MAX_TOTAL_BYTES = 400_000;
+
+const files = walk(buildDir);
+
+describe('built bundle', () => {
+ it('is newer than the source it was built from', () => {
+ // `npm test` builds first, so this should always hold. It is asserted
+ // anyway because running vitest directly would otherwise search whatever
+ // happened to be left in build/ — passing over code that no longer
+ // exists, which is the one way this whole file could be vacuous.
+ expect(files.length).toBeGreaterThan(0);
+ expect(files.some((f) => f.endsWith('index.html'))).toBe(true);
+
+ const newestBuild = Math.max(...files.map((f) => statSync(f).mtimeMs));
+ const newestSource = Math.max(...walk(srcDir).map((f) => statSync(f).mtimeMs));
+
+ expect(
+ newestBuild,
+ 'build/ is older than src/ — run `npm test`, which builds first'
+ ).toBeGreaterThan(newestSource);
+ });
+
+ it('carries no dictionary words', () => {
+ /** @type {string[]} */
+ const hits = [];
+ for (const file of files) {
+ const text = decodeUnicodeEscapes(readFileSync(file, 'utf8'));
+ for (const word of DICTIONARY_WORDS) {
+ if (text.includes(word)) hits.push(`${file}: ${word}`);
+ }
+ }
+ expect(hits).toEqual([]);
+ });
+
+ it('ships no database or wordlist file', () => {
+ const dataFiles = files.filter((f) =>
+ ['.db', '.sqlite', '.sqlite3', '.csv', '.tsv'].includes(extname(f))
+ );
+ expect(dataFiles).toEqual([]);
+ });
+
+ it('stays within the size budget that separates code from data', () => {
+ const total = files.reduce((sum, f) => sum + statSync(f).size, 0);
+ expect(total).toBeLessThan(MAX_TOTAL_BYTES);
+ });
+});
diff --git a/web/tests/countdown.test.js b/web/tests/countdown.test.js
new file mode 100644
index 0000000..6fc3d24
--- /dev/null
+++ b/web/tests/countdown.test.js
@@ -0,0 +1,60 @@
+// The countdown is the one place the client shows a number the server owns, so
+// the rule under test is that it never claims more time than the server allows.
+
+import { describe, expect, it } from 'vitest';
+import { SETTLE_MS, fraction, remainingMs, seconds } from '../src/lib/countdown.js';
+
+describe('remainingMs', () => {
+ it('reaches zero before the server deadline, never after it', () => {
+ const deadline = 100_000;
+ expect(remainingMs(deadline, deadline - SETTLE_MS)).toBe(0);
+ expect(remainingMs(deadline, deadline - SETTLE_MS - 1)).toBe(1);
+ });
+
+ it('never goes negative once the deadline has passed', () => {
+ expect(remainingMs(100_000, 200_000)).toBe(0);
+ });
+
+ it('is zero when no turn is running', () => {
+ expect(remainingMs(0, 50_000)).toBe(0);
+ });
+
+ it('counts against the server clock, so a wrong device clock is corrected', () => {
+ // The client believes it is 80_000; the estimated server clock is
+ // 90_000. The ring has to follow the server.
+ expect(remainingMs(100_000, 90_000)).toBe(10_000 - SETTLE_MS);
+ });
+});
+
+describe('fraction', () => {
+ it('is full at the start of a turn and empty at the end', () => {
+ expect(fraction(20_000, 20_000)).toBe(1);
+ expect(fraction(0, 20_000)).toBe(0);
+ });
+
+ it('is half way through a half-spent turn', () => {
+ expect(fraction(10_000, 20_000)).toBe(0.5);
+ });
+
+ it('shows a full ring when no turn is timed, not an expired one', () => {
+ expect(fraction(0, 0)).toBe(1);
+ });
+
+ it('clamps rather than overflowing the arc', () => {
+ expect(fraction(30_000, 20_000)).toBe(1);
+ expect(fraction(-5, 20_000)).toBe(0);
+ });
+});
+
+describe('seconds', () => {
+ it('shows the last second for its whole duration', () => {
+ expect(seconds(1)).toBe(1);
+ expect(seconds(999)).toBe(1);
+ expect(seconds(1000)).toBe(1);
+ expect(seconds(1001)).toBe(2);
+ });
+
+ it('shows zero only when the time is actually gone', () => {
+ expect(seconds(0)).toBe(0);
+ });
+});
diff --git a/web/tests/error-codes.test.js b/web/tests/error-codes.test.js
new file mode 100644
index 0000000..96c1a1a
--- /dev/null
+++ b/web/tests/error-codes.test.js
@@ -0,0 +1,55 @@
+// ServerError.code is a UI key, so the server decides the vocabulary and this
+// file has to speak all of it. The proto schema cannot help here — the codes
+// are string literals in the Go transport, not an enum — so the guard reads
+// them out of the source that emits them.
+//
+// Without this, adding a code on the server degrades silently to the generic
+// fallback: the player is told "something went wrong" for a situation the
+// server described precisely.
+
+import { readFileSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { errorMessages } from '../src/lib/i18n/vi.js';
+
+const wsapiDir = fileURLToPath(new URL('../../server/internal/wsapi', import.meta.url));
+
+/** Every `errorMsg("…")` literal in the transport, excluding its own tests. */
+function serverErrorCodes() {
+ const codes = new Set();
+ const files = readdirSync(wsapiDir).filter((f) => f.endsWith('.go') && !f.endsWith('_test.go'));
+
+ for (const file of files) {
+ const source = readFileSync(join(wsapiDir, file), 'utf8');
+ for (const [, code] of source.matchAll(/errorMsg\("([a-z_]+)"\)/g)) {
+ codes.add(code);
+ }
+ }
+ return codes;
+}
+
+describe('server error codes', () => {
+ const codes = serverErrorCodes();
+
+ it('finds the call sites at all, so an empty match cannot pass as agreement', () => {
+ // A rename of errorMsg would otherwise turn this whole file green by
+ // finding nothing to check.
+ expect(codes.size).toBeGreaterThan(10);
+ expect(codes.has('room_not_found')).toBe(true);
+ });
+
+ it('has a Vietnamese message for every code the server can send', () => {
+ const missing = [...codes].filter((code) => !(code in errorMessages)).sort();
+ expect(missing).toEqual([]);
+ });
+
+ it('carries no message for a code the server cannot send', () => {
+ // Dead copy is a smaller problem than a missing message, but it is still
+ // a claim about the server that has stopped being true.
+ const stale = Object.keys(errorMessages)
+ .filter((code) => !codes.has(code))
+ .sort();
+ expect(stale).toEqual([]);
+ });
+});
diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js
new file mode 100644
index 0000000..70bd706
--- /dev/null
+++ b/web/tests/game-store.test.js
@@ -0,0 +1,229 @@
+// The store is a projection of server messages, so these tests feed it real
+// decoded messages and assert the rendered model — not the reducer's internals.
+
+import { describe, expect, it } from 'vitest';
+import { create } from '@bufbuild/protobuf';
+import {
+ GameEndReason,
+ RejectReason,
+ ServerMessageSchema
+} from '../src/lib/proto/noitu/v1/game_pb.js';
+import { createGameStore } from '../src/lib/stores/game.svelte.js';
+
+/**
+ * @param {string} kind
+ * @param {object} value
+ */
+function msg(kind, value) {
+ return create(ServerMessageSchema, { payload: { case: kind, value } });
+}
+
+function started(overrides = {}) {
+ return msg('gameStarted', {
+ openingWord: 'học sinh',
+ currentSyllable: 'sinh',
+ myTurn: true,
+ deadlineUnixMs: 1_700_000_020_000n,
+ turnSeq: 1,
+ turnLimitMs: 20_000,
+ ...overrides
+ });
+}
+
+describe('welcome', () => {
+ it('displays the name the server accepted, not the one requested', () => {
+ const store = createGameStore();
+ store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's1', resumeToken: 'r1' }));
+ expect(store.state.nickname).toBe('Minh');
+ });
+});
+
+describe('gameStarted', () => {
+ it('seeds the chain with the opening word', () => {
+ const store = createGameStore();
+ store.apply(started());
+
+ expect(store.state.phase).toBe('playing');
+ expect(store.state.chain).toHaveLength(1);
+ expect(store.state.chain[0]).toMatchObject({ word: 'học sinh', opening: true, byMe: false });
+ expect(store.state.currentSyllable).toBe('sinh');
+ expect(store.state.myTurn).toBe(true);
+ expect(store.state.turnLimitMs).toBe(20_000);
+ });
+
+ it('counts the opening word in the chain, matching the server', () => {
+ const store = createGameStore();
+ store.apply(started());
+ expect(store.state.chainLength).toBe(1);
+ });
+
+ it('converts the int64 deadline out of bigint so the countdown can do arithmetic', () => {
+ const store = createGameStore();
+ store.apply(started());
+ expect(store.state.deadlineMs).toBe(1_700_000_020_000);
+ expect(typeof store.state.deadlineMs).toBe('number');
+ });
+
+ it('clears a previous result so a rematch does not start on the old screen', () => {
+ const store = createGameStore();
+ store.apply(started());
+ store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.TIMEOUT }));
+ expect(store.state.phase).toBe('over');
+
+ store.apply(started());
+ expect(store.state.phase).toBe('playing');
+ expect(store.state.result).toBeNull();
+ });
+});
+
+describe('turnUpdate', () => {
+ it('appends the played word and adopts the server scores', () => {
+ const store = createGameStore();
+ store.apply(started());
+ store.apply(
+ msg('turnUpdate', {
+ played: { word: 'sinh viên', byMe: true, points: 12, syllables: 2, typed: 'sinh vien' },
+ currentSyllable: 'viên',
+ myTurn: false,
+ deadlineUnixMs: 1_700_000_040_000n,
+ turnSeq: 2,
+ myScore: 12,
+ opponentScore: 0,
+ chainLength: 2
+ })
+ );
+
+ expect(store.state.chain).toHaveLength(2);
+ expect(store.state.chain[1]).toMatchObject({
+ word: 'sinh viên',
+ typed: 'sinh vien',
+ byMe: true,
+ points: 12,
+ syllables: 2
+ });
+ expect(store.state.currentSyllable).toBe('viên');
+ expect(store.state.myTurn).toBe(false);
+ expect(store.state.myScore).toBe(12);
+ expect(store.state.chainLength).toBe(2);
+ expect(store.state.turnSeq).toBe(2);
+ });
+
+ it('clears the standing rejection, because an accepted move answers it', () => {
+ const store = createGameStore();
+ store.apply(started());
+ store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
+ expect(store.state.rejection).not.toBeNull();
+
+ store.apply(
+ msg('turnUpdate', {
+ played: { word: 'sinh viên', byMe: true, points: 1, syllables: 2, typed: '' },
+ currentSyllable: 'viên',
+ myTurn: false,
+ turnSeq: 2,
+ chainLength: 2
+ })
+ );
+ expect(store.state.rejection).toBeNull();
+ });
+});
+
+describe('moveRejected', () => {
+ it('renders the specific Vietnamese reason', () => {
+ const store = createGameStore();
+ store.apply(started());
+ store.apply(msg('moveRejected', { reason: RejectReason.ALREADY_USED, word: 'học sinh' }));
+
+ expect(store.state.rejection).toEqual({
+ word: 'học sinh',
+ message: 'Từ này đã được dùng rồi.'
+ });
+ });
+
+ it('names the required syllable in the wrong-link message', () => {
+ const store = createGameStore();
+ store.apply(started());
+ store.apply(msg('moveRejected', { reason: RejectReason.WRONG_LINK, word: 'bàn ghế' }));
+
+ expect(store.state.rejection?.message).toBe('Từ phải bắt đầu bằng tiếng “sinh”.');
+ });
+});
+
+describe('gameOver', () => {
+ it('records the result and takes the turn away', () => {
+ const store = createGameStore();
+ store.apply(started());
+ store.apply(
+ msg('gameOver', {
+ iWon: true,
+ reason: GameEndReason.NO_LEGAL_MOVE,
+ myScore: 42,
+ chainLength: 7
+ })
+ );
+
+ expect(store.state.phase).toBe('over');
+ expect(store.state.myTurn).toBe(false);
+ expect(store.state.result).toEqual({
+ iWon: true,
+ reason: GameEndReason.NO_LEGAL_MOVE,
+ myScore: 42,
+ chainLength: 7
+ });
+ });
+});
+
+describe('error', () => {
+ it('translates the UI key rather than showing it', () => {
+ const store = createGameStore();
+ store.apply(msg('error', { code: 'room_not_found', message: '' }));
+ expect(store.state.error).toBe('Không tìm thấy phòng với mã này.');
+ });
+
+ it('falls back to a generic message for an unknown code', () => {
+ const store = createGameStore();
+ store.apply(msg('error', { code: 'something_new', message: '' }));
+ expect(store.state.error).toBe('Đã có lỗi xảy ra. Hãy thử lại.');
+ expect(store.state.error).not.toContain('something_new');
+ });
+});
+
+describe('opponentLeft', () => {
+ it('exposes the grace window so the UI can say how long to wait', () => {
+ const store = createGameStore();
+ store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30_000 }));
+ expect(store.state.opponentLeft).toEqual({ canReconnect: true, graceMs: 30_000 });
+ });
+});
+
+describe('room messages', () => {
+ it('keeps the room code and the sanitized opponent name', () => {
+ const store = createGameStore();
+ store.apply(msg('roomCreated', { roomCode: 'ABCD' }));
+ expect(store.state.roomCode).toBe('ABCD');
+
+ store.apply(msg('roomJoined', { roomCode: 'ABCD', opponentName: 'Lan' }));
+ expect(store.state.opponentName).toBe('Lan');
+ });
+
+ it('survives a reset, because identity outlives one game', () => {
+ const store = createGameStore();
+ store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's', resumeToken: 't' }));
+ store.apply(msg('roomJoined', { roomCode: 'ABCD', opponentName: 'Lan' }));
+ store.reset();
+
+ expect(store.state.nickname).toBe('Minh');
+ expect(store.state.roomCode).toBe('ABCD');
+ expect(store.state.opponentName).toBe('Lan');
+ expect(store.state.chain).toEqual([]);
+ });
+});
+
+describe('pong', () => {
+ it('changes nothing in the game model', () => {
+ const store = createGameStore();
+ store.apply(started());
+ const before = JSON.stringify(store.state);
+ store.apply(msg('pong', { clientTimeMs: 1n, serverTimeMs: 2n }));
+ expect(JSON.stringify(store.state)).toBe(before);
+ });
+});
diff --git a/web/tests/i18n.test.js b/web/tests/i18n.test.js
new file mode 100644
index 0000000..8023c5b
--- /dev/null
+++ b/web/tests/i18n.test.js
@@ -0,0 +1,111 @@
+// The Vietnamese copy is the only place prose lives, so these tests guard the
+// two ways it can silently fall out of step with the wire contract: a new enum
+// value with no message, and a placeholder no caller fills.
+
+import { describe, expect, it } from 'vitest';
+import {
+ Difficulty,
+ GameEndReason,
+ RejectReason,
+ RejectReasonSchema,
+ GameEndReasonSchema,
+ DifficultySchema
+} from '../src/lib/proto/noitu/v1/game_pb.js';
+import {
+ difficultyLabels,
+ endReasonMessages,
+ errorFallback,
+ errorMessage,
+ fill,
+ rejectMessage,
+ rejectMessages
+} from '../src/lib/i18n/vi.js';
+
+/**
+ * Enumerates a generated enum's numeric values from its schema rather than
+ * from the message table under test. Reading the table to check the table
+ * would pass no matter what is missing.
+ *
+ * @param {{ values: { number: number }[] }} schema
+ */
+function valuesOf(schema) {
+ return schema.values.map((v) => v.number);
+}
+
+describe('rejection messages', () => {
+ it('covers every RejectReason in the schema', () => {
+ for (const value of valuesOf(RejectReasonSchema)) {
+ expect(rejectMessages[value], `RejectReason ${value} has no Vietnamese message`).toBeTypeOf(
+ 'string'
+ );
+ expect(rejectMessages[value].length).toBeGreaterThan(0);
+ }
+ });
+
+ it('fills the syllable placeholder for a wrong link', () => {
+ expect(rejectMessage(RejectReason.WRONG_LINK, 'sinh')).toBe(
+ 'Từ phải bắt đầu bằng tiếng “sinh”.'
+ );
+ });
+
+ it('leaves no unfilled placeholder in any rendered message', () => {
+ for (const value of valuesOf(RejectReasonSchema)) {
+ expect(rejectMessage(value, 'sinh')).not.toMatch(/\{\w+\}/);
+ }
+ });
+
+ it('falls back to the unspecified message for a value this build predates', () => {
+ expect(rejectMessage(999, 'sinh')).toBe(rejectMessages[RejectReason.UNSPECIFIED]);
+ });
+});
+
+describe('end reason messages', () => {
+ it('covers every GameEndReason in the schema', () => {
+ for (const value of valuesOf(GameEndReasonSchema)) {
+ expect(endReasonMessages[value], `GameEndReason ${value} has no entry`).toBeTypeOf('string');
+ }
+ });
+
+ it('gives every real reason a non-empty message', () => {
+ // Only UNSPECIFIED is deliberately blank. Asserting the type alone would
+ // let a reason be silently emptied and still report green while the
+ // game-over screen explained nothing.
+ const real = valuesOf(GameEndReasonSchema).filter((v) => v !== GameEndReason.UNSPECIFIED);
+ for (const value of real) {
+ expect(endReasonMessages[value].length, `GameEndReason ${value} is blank`).toBeGreaterThan(0);
+ }
+ });
+
+ it('leaves the unspecified reason empty, so the screen shows only the result', () => {
+ expect(endReasonMessages[GameEndReason.UNSPECIFIED]).toBe('');
+ });
+});
+
+describe('difficulty labels', () => {
+ it('names every playable difficulty', () => {
+ const playable = valuesOf(DifficultySchema).filter((v) => v !== Difficulty.UNSPECIFIED);
+ for (const value of playable) {
+ expect(difficultyLabels[value], `Difficulty ${value} has no label`).toBeTypeOf('string');
+ }
+ });
+});
+
+describe('server error codes', () => {
+ it('translates a known code', () => {
+ expect(errorMessage('room_not_found')).toBe('Không tìm thấy phòng với mã này.');
+ });
+
+ it('never leaks an unknown key into the interface', () => {
+ expect(errorMessage('a_code_added_after_this_build')).toBe(errorFallback);
+ });
+});
+
+describe('fill', () => {
+ it('leaves a placeholder alone when no value was supplied', () => {
+ expect(fill('xin chào {name}')).toBe('xin chào {name}');
+ });
+
+ it('replaces every occurrence', () => {
+ expect(fill('{a} và {a}', { a: 'x' })).toBe('x và x');
+ });
+});
diff --git a/web/tests/settings-store.test.js b/web/tests/settings-store.test.js
new file mode 100644
index 0000000..47c41e9
--- /dev/null
+++ b/web/tests/settings-store.test.js
@@ -0,0 +1,174 @@
+// @vitest-environment jsdom
+
+// Settings are the only state the server has no opinion about, so the risk
+// here is not correctness of the values but survival when storage refuses.
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { Difficulty } from '../src/lib/proto/noitu/v1/game_pb.js';
+import { MAX_NICKNAME_LENGTH, createSettingsStore } from '../src/lib/stores/settings.svelte.js';
+
+const realStorage = globalThis.localStorage;
+
+/** Replaces localStorage for one test. */
+function useStorage(stub) {
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: stub,
+ configurable: true,
+ writable: true
+ });
+}
+
+/** A storage that fails every operation, like a browser blocking site data. */
+function hostileStorage() {
+ const boom = () => {
+ throw new DOMException('denied', 'SecurityError');
+ };
+ return { getItem: boom, setItem: boom, removeItem: boom, clear: boom, key: boom, length: 0 };
+}
+
+beforeEach(() => {
+ useStorage(realStorage);
+ localStorage.clear();
+});
+
+afterEach(() => {
+ useStorage(realStorage);
+});
+
+describe('nickname', () => {
+ it('persists what the player typed', () => {
+ const store = createSettingsStore();
+ store.setNickname('Minh');
+
+ expect(store.state.nickname).toBe('Minh');
+ expect(createSettingsStore().state.nickname).toBe('Minh');
+ });
+
+ it('caps at the server limit, counting characters rather than bytes', () => {
+ const store = createSettingsStore();
+ // Vietnamese is multi-byte; a byte cap would cut this far shorter.
+ store.setNickname('ăăăăăăăăăăăăăăăăăăăăăăăăă');
+
+ expect([...store.state.nickname]).toHaveLength(MAX_NICKNAME_LENGTH);
+ });
+});
+
+describe('theme', () => {
+ it('persists an explicit choice', () => {
+ const store = createSettingsStore();
+ store.setTheme('dark');
+
+ expect(store.state.theme).toBe('dark');
+ expect(createSettingsStore().state.theme).toBe('dark');
+ });
+
+ it('applies the theme to the document so the page repaints', () => {
+ const store = createSettingsStore();
+ store.setTheme('dark');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
+ });
+
+ it('toggles between the two themes', () => {
+ const store = createSettingsStore();
+ store.setTheme('light');
+ store.toggleTheme();
+ expect(store.state.theme).toBe('dark');
+ store.toggleTheme();
+ expect(store.state.theme).toBe('light');
+ });
+
+ it('treats an unrecognised stored value as light rather than trusting it', () => {
+ localStorage.setItem('noitu.theme', 'neon');
+ expect(createSettingsStore().state.theme).toBe('light');
+ });
+});
+
+describe('best scores', () => {
+ it('records a first score as a new record', () => {
+ const store = createSettingsStore();
+ expect(store.recordScore(Difficulty.EASY, 30)).toBe(true);
+ expect(store.bestScore(Difficulty.EASY)).toBe(30);
+ });
+
+ it('keeps records separate per difficulty', () => {
+ const store = createSettingsStore();
+ store.recordScore(Difficulty.EASY, 30);
+ store.recordScore(Difficulty.HARD, 12);
+
+ expect(store.bestScore(Difficulty.EASY)).toBe(30);
+ expect(store.bestScore(Difficulty.HARD)).toBe(12);
+ expect(store.bestScore(Difficulty.MEDIUM)).toBe(0);
+ });
+
+ it('does not call a tie a new record', () => {
+ const store = createSettingsStore();
+ store.recordScore(Difficulty.EASY, 30);
+ expect(store.recordScore(Difficulty.EASY, 30)).toBe(false);
+ });
+
+ it('leaves the record alone when a later game scores lower', () => {
+ const store = createSettingsStore();
+ store.recordScore(Difficulty.EASY, 30);
+ expect(store.recordScore(Difficulty.EASY, 10)).toBe(false);
+ expect(store.bestScore(Difficulty.EASY)).toBe(30);
+ });
+
+ it('survives a reload', () => {
+ createSettingsStore().recordScore(Difficulty.MEDIUM, 44);
+ expect(createSettingsStore().bestScore(Difficulty.MEDIUM)).toBe(44);
+ });
+
+ it('degrades to no records when the stored value is not valid JSON', () => {
+ localStorage.setItem('noitu.bestScores', '{not json');
+ const store = createSettingsStore();
+ expect(store.bestScore(Difficulty.EASY)).toBe(0);
+ });
+
+ it('ignores entries that are not finite non-negative numbers', () => {
+ localStorage.setItem(
+ 'noitu.bestScores',
+ JSON.stringify({ 1: 10, 2: 'lots', 3: -5, 4: null })
+ );
+ const store = createSettingsStore();
+
+ expect(store.bestScore(1)).toBe(10);
+ expect(store.bestScore(2)).toBe(0);
+ expect(store.bestScore(3)).toBe(0);
+ expect(store.bestScore(4)).toBe(0);
+ });
+});
+
+describe('when storage is unavailable', () => {
+ it('constructs with working defaults instead of throwing', () => {
+ useStorage(hostileStorage());
+ const store = createSettingsStore();
+
+ expect(store.state.nickname).toBe('');
+ expect(store.state.bestScores).toEqual({});
+ expect(['light', 'dark']).toContain(store.state.theme);
+ });
+
+ it('keeps every setting usable in memory for the rest of the session', () => {
+ useStorage(hostileStorage());
+ const store = createSettingsStore();
+
+ expect(() => store.setNickname('Minh')).not.toThrow();
+ expect(() => store.setTheme('dark')).not.toThrow();
+ expect(store.recordScore(Difficulty.HARD, 7)).toBe(true);
+
+ expect(store.state.nickname).toBe('Minh');
+ expect(store.state.theme).toBe('dark');
+ expect(store.bestScore(Difficulty.HARD)).toBe(7);
+ });
+
+ it('survives a browser that throws on the property itself', () => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ get() {
+ throw new DOMException('denied', 'SecurityError');
+ }
+ });
+
+ expect(() => createSettingsStore()).not.toThrow();
+ });
+});
diff --git a/web/tests/ws-client.test.js b/web/tests/ws-client.test.js
new file mode 100644
index 0000000..18072d9
--- /dev/null
+++ b/web/tests/ws-client.test.js
@@ -0,0 +1,462 @@
+// @vitest-environment jsdom
+
+// The socket client owns three things the UI cannot see going wrong: the
+// handshake, the reconnect schedule, and the clock offset the countdown is
+// drawn from. All three are driven here through injected fakes, so the
+// assertions are about the client rather than about a real network.
+
+import { beforeEach, describe, expect, it } from 'vitest';
+import { create, toBinary } from '@bufbuild/protobuf';
+import {
+ ClientMessageSchema,
+ ServerMessageSchema
+} from '../src/lib/proto/noitu/v1/game_pb.js';
+import {
+ BACKOFF_MS,
+ PING_INTERVAL_MS,
+ Status,
+ createClient,
+ socketUrl
+} from '../src/lib/ws/client.js';
+
+class FakeSocket {
+ /** @param {string} url */
+ constructor(url) {
+ this.url = url;
+ this.readyState = 0;
+ this.binaryType = '';
+ /** @type {Uint8Array[]} */
+ this.sent = [];
+ this.closed = false;
+ this.onopen = () => {};
+ this.onmessage = () => {};
+ this.onclose = () => {};
+ this.onerror = () => {};
+ }
+
+ send(bytes) {
+ this.sent.push(bytes);
+ }
+
+ close() {
+ this.closed = true;
+ }
+
+ // --- test drivers ---
+
+ open() {
+ this.readyState = 1;
+ this.onopen();
+ }
+
+ /** @param {any} serverMessage */
+ deliver(serverMessage) {
+ this.onmessage({ data: toBinary(ServerMessageSchema, serverMessage).buffer });
+ }
+
+ deliverRaw(bytes) {
+ this.onmessage({ data: bytes.buffer ?? bytes });
+ }
+
+ drop() {
+ this.readyState = 3;
+ this.onclose();
+ }
+}
+
+/**
+ * A controllable clock. Timers are stored rather than run, so a test decides
+ * when time passes and can read the delay each one was scheduled with.
+ */
+function fakeTimers() {
+ let nextId = 1;
+ const pending = new Map();
+ return {
+ delays: /** @type {number[]} */ ([]),
+ schedule(fn, delay) {
+ this.delays.push(delay);
+ const id = nextId++;
+ pending.set(id, fn);
+ return id;
+ },
+ cancel(id) {
+ pending.delete(id);
+ },
+ /** Runs every timer queued so far, once. */
+ flush() {
+ const due = [...pending.entries()];
+ pending.clear();
+ for (const [, fn] of due) fn();
+ },
+ size: () => pending.size
+ };
+}
+
+function serverMsg(kind, value) {
+ return create(ServerMessageSchema, { payload: { case: kind, value } });
+}
+
+/**
+ * @param {object} [options]
+ */
+function setup(options = {}) {
+ /** @type {FakeSocket[]} */
+ const sockets = [];
+ /** @type {any[]} */
+ const received = [];
+ /** @type {string[]} */
+ const statuses = [];
+ const timers = fakeTimers();
+ let clock = 1_000;
+
+ const client = createClient({
+ nickname: () => options.nickname ?? 'Minh',
+ onMessage: (m) => received.push(m),
+ onStatus: (s) => statuses.push(s),
+ url: 'ws://localhost/ws',
+ socketFactory: (url) => {
+ const s = new FakeSocket(url);
+ sockets.push(s);
+ return s;
+ },
+ now: () => clock,
+ random: options.random ?? (() => 1),
+ schedule: (fn, delay) => timers.schedule(fn, delay),
+ cancel: (id) => timers.cancel(id)
+ });
+
+ return {
+ client,
+ sockets,
+ received,
+ statuses,
+ timers,
+ // The ping interval uses the same timer double, so the reconnect
+ // schedule has to be read out from under it.
+ reconnectDelays: () => timers.delays.filter((d) => d !== PING_INTERVAL_MS),
+ setClock: (v) => {
+ clock = v;
+ },
+ last: () => sockets[sockets.length - 1]
+ };
+}
+
+/** @param {FakeSocket} socket */
+async function sentMessages(socket) {
+ const { fromBinary } = await import('@bufbuild/protobuf');
+ return socket.sent.map((b) => fromBinary(ClientMessageSchema, new Uint8Array(b)));
+}
+
+beforeEach(() => {
+ sessionStorage.clear();
+});
+
+describe('socketUrl', () => {
+ it('follows the page scheme so a TLS page never opens a plaintext socket', () => {
+ expect(socketUrl({ protocol: 'https:', host: 'noitu.example' })).toBe(
+ 'wss://noitu.example/ws'
+ );
+ expect(socketUrl({ protocol: 'http:', host: 'localhost:5173' })).toBe(
+ 'ws://localhost:5173/ws'
+ );
+ });
+});
+
+describe('handshake', () => {
+ it('sends Hello with the protocol version as the first message', async () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+
+ const [first] = await sentMessages(h.last());
+ expect(first.payload.case).toBe('hello');
+ expect(first.payload.value.protocolVersion).toBe(1);
+ expect(first.payload.value.nickname).toBe('Minh');
+ expect(first.payload.value.resumeToken).toBe('');
+ });
+
+ it('replays the resume token from the previous Welcome on the next connect', async () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+ h.last().deliver(
+ serverMsg('welcome', {
+ sessionId: 's1',
+ resumeToken: 'token-1',
+ protocolVersion: 1,
+ acceptedNickname: 'Minh'
+ })
+ );
+
+ h.last().drop();
+ h.timers.flush();
+ h.last().open();
+
+ const [hello] = await sentMessages(h.last());
+ expect(hello.payload.value.resumeToken).toBe('token-1');
+ });
+
+ it('forgets the token on request, so the next Hello starts a fresh session', async () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+ h.last().deliver(
+ serverMsg('welcome', {
+ sessionId: 's1',
+ resumeToken: 'token-1',
+ protocolVersion: 1,
+ acceptedNickname: 'Minh'
+ })
+ );
+ h.client.forgetSession();
+
+ h.last().drop();
+ h.timers.flush();
+ h.last().open();
+
+ const [hello] = await sentMessages(h.last());
+ expect(hello.payload.value.resumeToken).toBe('');
+ });
+});
+
+describe('reconnect', () => {
+ it('backs off along the documented schedule and then holds at the cap', () => {
+ const h = setup({ random: () => 1 });
+ h.client.connect();
+
+ // Each attempt fails before it opens, so the delay grows every time.
+ for (let i = 0; i < BACKOFF_MS.length + 2; i += 1) {
+ h.last().drop();
+ h.timers.flush();
+ }
+
+ const expected = [...BACKOFF_MS, BACKOFF_MS.at(-1), BACKOFF_MS.at(-1)];
+ expect(h.reconnectDelays()).toEqual(expected);
+ });
+
+ it('jitters below the base delay, never above it', () => {
+ const h = setup({ random: () => 0 });
+ h.client.connect();
+ h.last().drop();
+
+ expect(h.reconnectDelays()[0]).toBe(BACKOFF_MS[0] / 2);
+ });
+
+ it('does not reconnect after a deliberate close', () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+
+ const socketsBefore = h.sockets.length;
+ h.client.close();
+ h.timers.flush();
+
+ expect(h.sockets.length).toBe(socketsBefore);
+ expect(h.client.status()).toBe(Status.CLOSED);
+ });
+
+ it('reports reconnecting while it waits, and open once it lands', () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+ h.last().drop();
+ h.timers.flush();
+ h.last().open();
+
+ expect(h.statuses).toEqual([
+ Status.CONNECTING,
+ Status.OPEN,
+ Status.RECONNECTING,
+ Status.OPEN
+ ]);
+ });
+});
+
+describe('clock offset', () => {
+ it('estimates the server clock from the midpoint of the round trip', () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+
+ // Ping left at 1000, the reply is read at 1100, and the server stamped
+ // it 5050. The one-way delay is half of 100, so the server clock is
+ // 5050 + 50 - 1100 = 4000 ahead.
+ h.setClock(1100);
+ h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 5050n }));
+
+ expect(h.client.clockOffset()).toBe(4000);
+ expect(h.client.serverNow()).toBe(5100);
+ });
+
+ it('starts at zero offset, so the countdown works before the first Pong', () => {
+ const h = setup();
+ expect(h.client.clockOffset()).toBe(0);
+ });
+
+ it('probes the clock on an interval while the socket is open', async () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+
+ expect(h.timers.delays).toContain(PING_INTERVAL_MS);
+ h.timers.flush();
+
+ const sent = await sentMessages(h.last());
+ expect(sent.map((m) => m.payload.case)).toEqual(['hello', 'ping', 'ping']);
+ });
+
+ it('probes immediately on connect, so the opening turn is already corrected', async () => {
+ // GameStarted arrives about one round trip after Hello. Waiting out the
+ // first interval would draw that whole turn against the device clock.
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+
+ const sent = await sentMessages(h.last());
+ expect(sent.map((m) => m.payload.case)).toEqual(['hello', 'ping']);
+ });
+});
+
+describe('frames', () => {
+ it('forwards a decoded server message to the caller', () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+ h.last().deliver(serverMsg('roomCreated', { roomCode: 'ABCD' }));
+
+ expect(h.received).toHaveLength(1);
+ expect(h.received[0].payload.case).toBe('roomCreated');
+ });
+
+ it('drops an undecodable frame instead of ending the session', () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+
+ // Field 1 declared as a varint but truncated: valid framing, invalid body.
+ expect(() => h.last().deliverRaw(new Uint8Array([0x08]))).not.toThrow();
+ expect(h.received).toHaveLength(0);
+ expect(h.client.status()).toBe(Status.OPEN);
+ });
+
+ it('refuses to send while the socket is not open', () => {
+ const h = setup();
+ h.client.connect();
+ const before = h.last().sent.length;
+
+ expect(h.client.send(create(ClientMessageSchema, {}))).toBe(false);
+ expect(h.last().sent.length).toBe(before);
+ });
+});
+
+describe('handshake ordering', () => {
+ it('sends Hello before announcing the connection is open', async () => {
+ // The server refuses everything until the handshake lands, so a listener
+ // that reacts to "open" by sending a message must not be able to get
+ // ahead of it.
+ /** @type {string[]} */
+ const order = [];
+ /** @type {any} */
+ let socket;
+ const client = createClient({
+ nickname: () => 'Minh',
+ onMessage: () => {},
+ onStatus: (s) => order.push(`status:${s}`),
+ url: 'ws://localhost/ws',
+ socketFactory: () => {
+ socket = new FakeSocket('ws://localhost/ws');
+ const realSend = socket.send.bind(socket);
+ socket.send = (bytes) => {
+ order.push('send');
+ realSend(bytes);
+ };
+ return socket;
+ },
+ schedule: () => 1,
+ cancel: () => {}
+ });
+
+ client.connect();
+ socket.open();
+
+ expect(order.indexOf('send')).toBeLessThan(order.indexOf(`status:${Status.OPEN}`));
+ });
+});
+
+describe('a handshake the server refuses', () => {
+ /** @param {any} h */
+ function refuseVersion(h) {
+ h.client.connect();
+ h.last().open();
+ h.last().deliver(serverMsg('error', { code: 'protocol_version_mismatch', message: '' }));
+ h.last().drop();
+ }
+
+ it('stops reconnecting, instead of retrying a handshake that cannot succeed', () => {
+ const h = setup();
+ const before = h.sockets.length;
+
+ refuseVersion(h);
+ h.timers.flush();
+
+ expect(h.sockets.length).toBe(before + 1);
+ expect(h.client.status()).toBe(Status.CLOSED);
+ });
+
+ it('still shows the player why, by forwarding the error', () => {
+ const h = setup();
+ refuseVersion(h);
+
+ expect(h.received.map((m) => m.payload.case)).toContain('error');
+ });
+
+ it('keeps retrying an error that a fresh connection could clear', () => {
+ const h = setup();
+ h.client.connect();
+ h.last().open();
+ h.last().deliver(serverMsg('error', { code: 'room_not_found', message: '' }));
+ h.last().drop();
+ h.timers.flush();
+
+ expect(h.client.status()).not.toBe(Status.CLOSED);
+ });
+});
+
+describe('backoff and the handshake', () => {
+ it('resets on Welcome, not merely on the socket opening', () => {
+ // A server that accepts the connection and then rejects the handshake
+ // would otherwise look like a success, and every retry would start again
+ // from the shortest delay.
+ const h = setup({ random: () => 1 });
+ h.client.connect();
+
+ h.last().drop();
+ h.timers.flush();
+ h.last().open(); // opens, but no Welcome arrives
+ h.last().drop();
+ h.timers.flush();
+
+ expect(h.reconnectDelays()).toEqual([500, 1000]);
+ });
+
+ it('resets once the server has actually greeted the client', () => {
+ const h = setup({ random: () => 1 });
+ h.client.connect();
+
+ h.last().drop();
+ h.timers.flush();
+ h.last().open();
+ h.last().deliver(
+ serverMsg('welcome', {
+ sessionId: 's1',
+ resumeToken: 't1',
+ protocolVersion: 1,
+ acceptedNickname: 'Minh'
+ })
+ );
+ h.last().drop();
+ h.timers.flush();
+
+ expect(h.reconnectDelays().at(-1)).toBe(500);
+ });
+});
diff --git a/web/vite.config.js b/web/vite.config.js
new file mode 100644
index 0000000..64c6107
--- /dev/null
+++ b/web/vite.config.js
@@ -0,0 +1,30 @@
+import { fileURLToPath } from 'node:url';
+import { sveltekit } from '@sveltejs/kit/vite';
+// vitest/config, not vite: the `test` block below is Vitest's, and vite's own
+// config type does not know about it.
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ server: {
+ // Dev runs Vite and the Go binary on different ports, so the socket has
+ // to be proxied. That keeps the client's URL logic identical in both
+ // environments: it always connects to its own origin.
+ proxy: {
+ '/ws': { target: 'ws://localhost:8080', ws: true },
+ '/healthz': 'http://localhost:8080'
+ }
+ },
+ test: {
+ // The default node environment: the cross-language fixture test reads
+ // files through import.meta.url, which jsdom rewrites to an http URL.
+ // The two suites that need a DOM opt in with a per-file docblock.
+ include: ['tests/**/*.test.js'],
+ // SvelteKit resolves $lib during a build, not under Vitest, so the test
+ // run needs the same mapping stated explicitly. It points at the same
+ // directory the framework uses, so there is one meaning of $lib.
+ alias: {
+ $lib: fileURLToPath(new URL('./src/lib', import.meta.url))
+ }
+ }
+});