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 @@ + + + + + + + Nối Từ + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/components/AttributionFooter.svelte b/web/src/lib/components/AttributionFooter.svelte new file mode 100644 index 0000000..bb5926e --- /dev/null +++ b/web/src/lib/components/AttributionFooter.svelte @@ -0,0 +1,41 @@ + + + + + + diff --git a/web/src/lib/components/ChainHistory.svelte b/web/src/lib/components/ChainHistory.svelte new file mode 100644 index 0000000..94416b3 --- /dev/null +++ b/web/src/lib/components/ChainHistory.svelte @@ -0,0 +1,136 @@ + + +
+

{t.chainTitle}

+ {#if game.state.chain.length === 0} +

{t.chainEmpty}

+ {:else} +
    + {#each game.state.chain as entry, index} +
  1. + {entry.word} + + {#if entry.syllables > 2} + {entry.syllables} {t.syllableUnit} + {/if} + {#if entry.points > 0} + +{entry.points} + {/if} + + {#if entry.byMe && entry.typed && entry.typed !== entry.word} + + + {fill(t.correctedFrom, { typed: entry.typed, word: entry.word })} + + {/if} +
  2. + {/each} +
+ {/if} +
+ + diff --git a/web/src/lib/components/ConnectionBadge.svelte b/web/src/lib/components/ConnectionBadge.svelte new file mode 100644 index 0000000..b6b7435 --- /dev/null +++ b/web/src/lib/components/ConnectionBadge.svelte @@ -0,0 +1,66 @@ + + + +

+ + {label} +

+ + diff --git a/web/src/lib/components/CountdownRing.svelte b/web/src/lib/components/CountdownRing.svelte new file mode 100644 index 0000000..153d293 --- /dev/null +++ b/web/src/lib/components/CountdownRing.svelte @@ -0,0 +1,103 @@ + + +
+ + {running ? left : '–'} +
+ + diff --git a/web/src/lib/components/DifficultyPicker.svelte b/web/src/lib/components/DifficultyPicker.svelte new file mode 100644 index 0000000..88d37ec --- /dev/null +++ b/web/src/lib/components/DifficultyPicker.svelte @@ -0,0 +1,88 @@ + + + +
+ {t.difficultyLabel} +
+ {#each difficultyOrder as difficulty (difficulty)} + {@const best = settings.bestScore(difficulty)} + + {/each} +
+
+ + diff --git a/web/src/lib/components/GameOverPanel.svelte b/web/src/lib/components/GameOverPanel.svelte new file mode 100644 index 0000000..a87a891 --- /dev/null +++ b/web/src/lib/components/GameOverPanel.svelte @@ -0,0 +1,117 @@ + + +{#if result} +
+

{result.iWon ? t.won : t.lost}

+ + {#if endReasonMessages[result.reason]} +

{endReasonMessages[result.reason]}

+ {/if} + +
+
+
{t.finalScore}
+
{result.myScore}
+
+
+
{t.chainLength}
+
{result.chainLength}
+
+
+ + {#if isRecord} +

{t.newRecord}

+ {/if} + +
+ + +
+
+{/if} + + diff --git a/web/src/lib/components/NicknameInput.svelte b/web/src/lib/components/NicknameInput.svelte new file mode 100644 index 0000000..592ab6c --- /dev/null +++ b/web/src/lib/components/NicknameInput.svelte @@ -0,0 +1,58 @@ + + +
+ + + settings.setNickname(event.currentTarget.value)} + /> +

{t.nicknameHint}

+
+ + diff --git a/web/src/lib/components/ScoreBoard.svelte b/web/src/lib/components/ScoreBoard.svelte new file mode 100644 index 0000000..09b154b --- /dev/null +++ b/web/src/lib/components/ScoreBoard.svelte @@ -0,0 +1,67 @@ + + +
+
+ {me} + {game.state.myScore} +
+ +
+ {opponentLabel} + {game.state.opponentScore} +
+
+ + diff --git a/web/src/lib/components/ThemeToggle.svelte b/web/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..13fffee --- /dev/null +++ b/web/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,36 @@ + + + + + diff --git a/web/src/lib/components/WordInput.svelte b/web/src/lib/components/WordInput.svelte new file mode 100644 index 0000000..40354e8 --- /dev/null +++ b/web/src/lib/components/WordInput.svelte @@ -0,0 +1,126 @@ + + +
+ + (composing = true)} + oncompositionend={() => (composing = false)} + /> + +
+ +{#if game.state.rejection} + +{/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 @@ + + +
+
+ {t.appName} + +
+ +
+ {@render children()} +
+ + +
+ + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..cc85045 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,62 @@ + + +
+

{t.tagline}

+ + + + +
+ + {t.playOnline} +
+
+ + diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte new file mode 100644 index 0000000..e21cecf --- /dev/null +++ b/web/src/routes/online/+page.svelte @@ -0,0 +1,44 @@ + + + +
+

{t.onlineTitle}

+

{t.onlineComingSoon}

+ {t.back} +
+ + diff --git a/web/src/routes/play/+page.svelte b/web/src/routes/play/+page.svelte new file mode 100644 index 0000000..3e4b20e --- /dev/null +++ b/web/src/routes/play/+page.svelte @@ -0,0 +1,220 @@ + + +
+
+ + {difficultyLabels[difficulty]} +
+ + + + {#if game.state.error} + + {/if} + + {#if game.state.phase === 'over'} + + {:else} +
+ +
+

{game.state.myTurn ? t.yourTurn : t.opponentTurn}

+

+ {t.currentSyllable} + {game.state.currentSyllable || '…'} +

+
+
+ + + {/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 @@ + + + NT + 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)) + } + } +});