mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(web): add SvelteKit vs-bot frontend
The board, the home screen and the game-over panel, in Vietnamese, served by the Go binary as a static single-page app. The store is a reducer over ServerMessage and computes nothing. Validity, turn order, scores and the result are read from the wire, which is what lets one screen serve the bot now and online play later. Every Vietnamese string lives in one module, including the map from RejectReason to a message, so the server can send UI keys instead of prose. The word field is uncontrolled. A Telex or VNI input method composes a diacritic across several keystrokes, and writing the value back on each one cancels the composition. It is read on submit and cleared only there. The countdown is drawn against the server's clock, estimated from the ping round trip, and settles 300ms early so the ring never claims more time than the server allows. The screen owns the socket and the game while it is mounted, and gives the current game up on the way out. Asking for a game is stored intent rather than a condition inferred from the board being empty: clearing the board for a rematch is that same condition, so the inference sent a second StartBotGame and the server built two rooms that then destroyed each other. A test greps the built bundle for dictionary words and holds a size budget, so the wordlist cannot reach the browser unnoticed. Another reads the error codes out of the Go transport and fails when one has no Vietnamese message.
This commit is contained in:
1 parent
5935f5c879
commit
64106735ec
44 files changed
+5309
-26
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Generated
+1094
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="description" content="Trò chơi nối từ tiếng Việt: chơi với máy hoặc đấu 1v1 trực tuyến." />
|
||||
<title>Nối Từ</title>
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<!--
|
||||
Applied before the first paint. Reading the theme inside a component
|
||||
would run after the document is already on screen, which is exactly
|
||||
the flash of the wrong palette this avoids. Kept inline and tiny for
|
||||
the same reason: an external file would be a second round trip.
|
||||
-->
|
||||
<script>
|
||||
try {
|
||||
const saved = localStorage.getItem('noitu.theme');
|
||||
const dark =
|
||||
saved === 'dark' ||
|
||||
(saved !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
}
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
The user-visible half of the CC BY-SA 4.0 obligation. The wordlist is
|
||||
derived from a share-alike source, so the credit and the licence link have
|
||||
to reach the player, not only the repository.
|
||||
-->
|
||||
<footer class="credit">
|
||||
<p>
|
||||
{t.attributionIntro}
|
||||
<a href="https://github.com/minhqnd/dictionary" target="_blank" rel="noreferrer noopener">
|
||||
{t.attributionSource}
|
||||
</a>, {t.attributionMiddle}
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/deed.vi"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t.attributionLicense}
|
||||
</a>.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.credit {
|
||||
padding: 20px 16px 28px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.credit p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let list = $state();
|
||||
|
||||
// Keep the newest word in view as the chain grows. Reading chain.length in
|
||||
// the effect is what subscribes it to new moves.
|
||||
$effect(() => {
|
||||
game.state.chain.length;
|
||||
list?.scrollTo({ top: list.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="chain" aria-label={t.chainTitle}>
|
||||
<h2>{t.chainTitle}</h2>
|
||||
{#if game.state.chain.length === 0}
|
||||
<p class="empty">{t.chainEmpty}</p>
|
||||
{:else}
|
||||
<ol bind:this={list}>
|
||||
{#each game.state.chain as entry, index}
|
||||
<li
|
||||
class:mine={entry.byMe}
|
||||
class:opening={entry.opening}
|
||||
class:latest={index === game.state.chain.length - 1}
|
||||
>
|
||||
<span class="word">{entry.word}</span>
|
||||
<span class="meta">
|
||||
{#if entry.syllables > 2}
|
||||
<span class="badge">{entry.syllables} {t.syllableUnit}</span>
|
||||
{/if}
|
||||
{#if entry.points > 0}
|
||||
<span class="points">+{entry.points}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if entry.byMe && entry.typed && entry.typed !== entry.word}
|
||||
<!-- The server accepted a different spelling from the one typed.
|
||||
Saying so beats silently rewriting the player's word. -->
|
||||
<span class="corrected">
|
||||
{fill(t.correctedFrom, { typed: entry.typed, word: entry.word })}
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.chain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
ol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
li.mine {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
li.opening {
|
||||
border-style: dashed;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
li.latest {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.word {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.points {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.corrected {
|
||||
flex-basis: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { Status, connection } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
const label = $derived(
|
||||
{
|
||||
[Status.CONNECTING]: t.connecting,
|
||||
[Status.OPEN]: t.connected,
|
||||
[Status.RECONNECTING]: t.reconnecting,
|
||||
[Status.CLOSED]: t.offline
|
||||
}[connection.status] ?? t.offline
|
||||
);
|
||||
</script>
|
||||
|
||||
<!--
|
||||
aria-live, because losing the connection is the one status change a player
|
||||
needs told rather than shown: they may be looking at the input, not here.
|
||||
-->
|
||||
<p class="badge" data-status={connection.status} aria-live="polite">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.badge[data-status='open'] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge[data-status='reconnecting'],
|
||||
.badge[data-status='connecting'] {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge[data-status='closed'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge[data-status='reconnecting'] .dot {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script>
|
||||
import { fraction, remainingMs, seconds } from '$lib/countdown.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { serverNow } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
const RADIUS = 34;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
const URGENT_SECONDS = 5;
|
||||
|
||||
let now = $state(serverNow());
|
||||
|
||||
const running = $derived(game.state.phase === 'playing' && game.state.deadlineMs > 0);
|
||||
|
||||
// requestAnimationFrame rather than an interval: the arc is a continuous
|
||||
// value, and a browser that backgrounds the tab stops the loop, which is
|
||||
// exactly right for something purely visual. The loop runs only while a turn
|
||||
// is being timed — between games it would be 60 invalidations a second of a
|
||||
// number that is not moving.
|
||||
$effect(() => {
|
||||
if (!running) return;
|
||||
let frame = requestAnimationFrame(function tick() {
|
||||
now = serverNow();
|
||||
frame = requestAnimationFrame(tick);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
});
|
||||
|
||||
const remaining = $derived(running ? remainingMs(game.state.deadlineMs, now) : 0);
|
||||
const left = $derived(seconds(remaining));
|
||||
const filled = $derived(running ? fraction(remaining, game.state.turnLimitMs) : 1);
|
||||
const urgent = $derived(running && left <= URGENT_SECONDS);
|
||||
</script>
|
||||
|
||||
<div class="ring" class:urgent class:idle={!running} role="timer" aria-label={fill(t.secondsLeft, { n: left })}>
|
||||
<svg viewBox="0 0 80 80" aria-hidden="true">
|
||||
<circle class="track" cx="40" cy="40" r={RADIUS} />
|
||||
<circle
|
||||
class="arc"
|
||||
cx="40"
|
||||
cy="40"
|
||||
r={RADIUS}
|
||||
stroke-dasharray={CIRCUMFERENCE}
|
||||
stroke-dashoffset={CIRCUMFERENCE * (1 - filled)}
|
||||
/>
|
||||
</svg>
|
||||
<span class="value">{running ? left : '–'}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ring {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ring.urgent {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Between turns there is no clock to show, so the ring reads as waiting
|
||||
rather than as a timer that has run out. */
|
||||
.ring.idle {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Start the arc at twelve o'clock and run it clockwise. */
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
circle {
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.track {
|
||||
stroke: var(--border);
|
||||
}
|
||||
|
||||
.arc {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.value {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ring.idle .value {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
import { difficultyLabels, difficultyOrder, t } from '$lib/i18n/vi.js';
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
|
||||
/** @type {{ value: number, onselect?: (difficulty: number) => void }} */
|
||||
let { value = $bindable(), onselect } = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Real radio inputs rather than buttons wearing role="radio". Arrow-key
|
||||
movement, the single tab stop and the grouping all come from the platform,
|
||||
which is a lot of behaviour to reimplement and get subtly wrong.
|
||||
-->
|
||||
<fieldset class="picker">
|
||||
<legend>{t.difficultyLabel}</legend>
|
||||
<div class="options">
|
||||
{#each difficultyOrder as difficulty (difficulty)}
|
||||
{@const best = settings.bestScore(difficulty)}
|
||||
<label class="option" class:selected={value === difficulty}>
|
||||
<input
|
||||
type="radio"
|
||||
name="difficulty"
|
||||
class="sr-only"
|
||||
checked={value === difficulty}
|
||||
onchange={() => {
|
||||
value = difficulty;
|
||||
onselect?.(difficulty);
|
||||
}}
|
||||
/>
|
||||
<span class="name">{difficultyLabels[difficulty]}</span>
|
||||
<span class="best">{t.bestScore}: {best > 0 ? best : t.noBestScore}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 0 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.option.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* The input is visually hidden, so its focus ring has to land on the label
|
||||
the player can actually see. */
|
||||
.option:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.best {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script>
|
||||
import { endReasonMessages, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/** @type {{ isRecord: boolean, onrematch: () => void, onhome: () => void }} */
|
||||
let { isRecord, onrematch, onhome } = $props();
|
||||
|
||||
/** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null} */
|
||||
const result = $derived(game.state.result);
|
||||
</script>
|
||||
|
||||
{#if result}
|
||||
<div class="panel" role="group" aria-label={result.iWon ? t.won : t.lost}>
|
||||
<h2 class:won={result.iWon}>{result.iWon ? t.won : t.lost}</h2>
|
||||
|
||||
{#if endReasonMessages[result.reason]}
|
||||
<p class="reason">{endReasonMessages[result.reason]}</p>
|
||||
{/if}
|
||||
|
||||
<dl class="stats">
|
||||
<div>
|
||||
<dt>{t.finalScore}</dt>
|
||||
<dd>{result.myScore}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t.chainLength}</dt>
|
||||
<dd>{result.chainLength}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if isRecord}
|
||||
<p class="record">{t.newRecord}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" onclick={onrematch}>{t.rematch}</button>
|
||||
<button type="button" onclick={onhome}>{t.home}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
h2.won {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.reason {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions .primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { MAX_NICKNAME_LENGTH, settings } from '$lib/stores/settings.svelte.js';
|
||||
|
||||
const id = 'nickname-input';
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<label for={id}>{t.nicknameLabel}</label>
|
||||
<!--
|
||||
The value is never written back while the player types. A Telex or VNI
|
||||
IME composes a diacritic across several keystrokes, and reassigning the
|
||||
field mid-composition drops the accent being formed. The cap is applied
|
||||
in the store, which is also where the same rule is unit-tested.
|
||||
-->
|
||||
<input
|
||||
{id}
|
||||
type="text"
|
||||
autocomplete="nickname"
|
||||
maxlength={MAX_NICKNAME_LENGTH}
|
||||
placeholder={t.nicknamePlaceholder}
|
||||
value={settings.state.nickname}
|
||||
oninput={(event) => settings.setNickname(event.currentTarget.value)}
|
||||
/>
|
||||
<p class="hint">{t.nicknameHint}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/** @type {{ opponentLabel?: string }} */
|
||||
let { opponentLabel = t.opponent } = $props();
|
||||
|
||||
const me = $derived(game.state.nickname || t.you);
|
||||
</script>
|
||||
|
||||
<div class="board">
|
||||
<div class="side" class:active={game.state.myTurn}>
|
||||
<span class="who">{me}</span>
|
||||
<span class="score">{game.state.myScore}</span>
|
||||
</div>
|
||||
<span class="sep" aria-hidden="true">–</span>
|
||||
<div class="side" class:active={game.state.phase === 'playing' && !game.state.myTurn}>
|
||||
<span class="who">{opponentLabel}</span>
|
||||
<span class="score">{game.state.opponentScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.board {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* The active side is whose turn it is, so the board doubles as the turn
|
||||
indicator rather than needing a second one. */
|
||||
.side.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.who {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
|
||||
const dark = $derived(settings.state.theme === 'dark');
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
aria-pressed={dark}
|
||||
title={dark ? t.themeLight : t.themeDark}
|
||||
onclick={() => settings.toggleTheme()}
|
||||
>
|
||||
<span aria-hidden="true">{dark ? '☀' : '☾'}</span>
|
||||
<span class="sr-only">{t.theme}: {dark ? t.themeDark : t.themeLight}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { Status, connection } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
/** @type {{ onsubmit: (word: string) => boolean }} */
|
||||
let { onsubmit } = $props();
|
||||
|
||||
/** @type {HTMLInputElement | undefined} */
|
||||
let field = $state();
|
||||
let composing = $state(false);
|
||||
|
||||
// The connection is part of the condition, not just the turn. A word typed
|
||||
// during a reconnect would be dropped by a closed socket and the player
|
||||
// would watch their turn expire with no idea why.
|
||||
const enabled = $derived(
|
||||
game.state.phase === 'playing' && game.state.myTurn && connection.status === Status.OPEN
|
||||
);
|
||||
|
||||
// Focus when the turn arrives, so a player on a phone can type without
|
||||
// reaching for the field. Reading myTurn is what subscribes the effect.
|
||||
$effect(() => {
|
||||
if (game.state.myTurn && game.state.phase === 'playing') field?.focus();
|
||||
});
|
||||
|
||||
/** @param {SubmitEvent} event */
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
// Enter can commit an IME candidate rather than the form. Submitting
|
||||
// mid-composition would send a half-typed word and swallow the
|
||||
// keystroke that was choosing the accent.
|
||||
if (composing || !enabled || !field) return;
|
||||
|
||||
const word = field.value.trim();
|
||||
if (!word) return;
|
||||
|
||||
// Only clear a word that actually went out. Clearing regardless would
|
||||
// erase what the player typed and leave nothing to resend.
|
||||
if (!onsubmit(word)) return;
|
||||
// Safe here and only here: composition has ended by the time a submit
|
||||
// is delivered, so clearing cannot destroy an accent being formed.
|
||||
field.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="input-row" onsubmit={handleSubmit}>
|
||||
<!--
|
||||
Deliberately uncontrolled. Vietnamese diacritics are typed 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. The
|
||||
server normalizes the text anyway, so the client has no reason to touch
|
||||
it.
|
||||
-->
|
||||
<input
|
||||
bind:this={field}
|
||||
type="text"
|
||||
name="word"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
enterkeyhint="send"
|
||||
disabled={!enabled}
|
||||
placeholder={t.wordInputPlaceholder}
|
||||
aria-label={t.wordInputPlaceholder}
|
||||
oncompositionstart={() => (composing = true)}
|
||||
oncompositionend={() => (composing = false)}
|
||||
/>
|
||||
<button type="submit" disabled={!enabled}>{t.submit}</button>
|
||||
</form>
|
||||
|
||||
{#if game.state.rejection}
|
||||
<p class="rejection" role="alert">{game.state.rejection.message}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
/* 16px or larger stops iOS Safari zooming the page on focus, which on a
|
||||
phone hides half the board behind the keyboard. */
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 14px 20px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rejection {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<number, string>}
|
||||
*/
|
||||
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<number, string>}
|
||||
*/
|
||||
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<number, string>}
|
||||
*/
|
||||
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<string, string>}
|
||||
*/
|
||||
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<string, string | number>} [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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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<string, number>}
|
||||
*/
|
||||
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<string, number>} */
|
||||
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<string, number>} */
|
||||
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();
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<typeof createClient> | 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 };
|
||||
@@ -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)) })
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,55 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import AttributionFooter from '$lib/components/AttributionFooter.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<header>
|
||||
<a class="brand" href="/">{t.appName}</a>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<AttributionFooter />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import DifficultyPicker from '$lib/components/DifficultyPicker.svelte';
|
||||
import NicknameInput from '$lib/components/NicknameInput.svelte';
|
||||
import { Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
|
||||
let difficulty = $state(Difficulty.MEDIUM);
|
||||
|
||||
function playBot() {
|
||||
goto(`/play?difficulty=${difficulty}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="home">
|
||||
<p class="tagline">{t.tagline}</p>
|
||||
|
||||
<NicknameInput />
|
||||
<DifficultyPicker bind:value={difficulty} />
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" onclick={playBot}>{t.playBot}</button>
|
||||
<a class="secondary" href="/online">{t.playOnline}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.actions .primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
The online 1v1 flows land in phase 7. This route exists now so the home
|
||||
screen has somewhere real to point and the deep link is already covered by
|
||||
the server's single-page fallback.
|
||||
-->
|
||||
<section class="online">
|
||||
<h1>{t.onlineTitle}</h1>
|
||||
<p>{t.onlineComingSoon}</p>
|
||||
<a href="/">{t.back}</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.online {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
a {
|
||||
align-self: flex-start;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ChainHistory from '$lib/components/ChainHistory.svelte';
|
||||
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
||||
import CountdownRing from '$lib/components/CountdownRing.svelte';
|
||||
import GameOverPanel from '$lib/components/GameOverPanel.svelte';
|
||||
import ScoreBoard from '$lib/components/ScoreBoard.svelte';
|
||||
import WordInput from '$lib/components/WordInput.svelte';
|
||||
import { difficultyLabels, t } from '$lib/i18n/vi.js';
|
||||
import { Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
|
||||
import { createBotSession } from '$lib/stores/bot-session.svelte.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
import { resign, startBotGame, submitWord } from '$lib/ws/messages.js';
|
||||
import { Status, connect, connection, disconnect, send } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
/**
|
||||
* The difficulty travels in the URL so a reload resumes the same ladder rung
|
||||
* and the record comparison knows which one to compare against.
|
||||
*/
|
||||
const difficulty = $derived.by(() => {
|
||||
const raw = Number(page.url.searchParams.get('difficulty'));
|
||||
return difficultyLabels[raw] ? raw : Difficulty.MEDIUM;
|
||||
});
|
||||
|
||||
const session = createBotSession({ start: (d) => send(startBotGame(d)) });
|
||||
|
||||
let isRecord = $state(false);
|
||||
|
||||
// Owns the socket and the game for as long as this screen is on. Entering
|
||||
// asks for a game; leaving gives the current one up rather than abandoning a
|
||||
// room that would keep its turn timer running and time the player out
|
||||
// somewhere they can no longer see.
|
||||
//
|
||||
// Reading `difficulty` makes a change of rung a teardown and a fresh game,
|
||||
// which is what changing the URL means.
|
||||
$effect(() => {
|
||||
const rung = difficulty;
|
||||
untrack(() => startGame(rung));
|
||||
|
||||
return () => {
|
||||
if (game.state.phase === 'playing') send(resign());
|
||||
session.cancel();
|
||||
disconnect();
|
||||
game.reset();
|
||||
};
|
||||
});
|
||||
|
||||
// The request is sent as soon as the socket can carry it. On a fresh load
|
||||
// that is after the handshake; on a rematch it is immediate.
|
||||
$effect(() => {
|
||||
const open = connection.status === Status.OPEN;
|
||||
untrack(() => session.flush(open));
|
||||
});
|
||||
|
||||
// A finished game counts once. The write is untracked so the effect does not
|
||||
// depend on the record it just stored.
|
||||
$effect(() => {
|
||||
const result = game.state.result;
|
||||
untrack(() => {
|
||||
if (result) isRecord = session.score(result, difficulty, settings);
|
||||
});
|
||||
});
|
||||
|
||||
/** @param {number} rung */
|
||||
function startGame(rung) {
|
||||
game.reset();
|
||||
session.reset();
|
||||
isRecord = false;
|
||||
session.request(rung);
|
||||
connect();
|
||||
session.flush(connection.status === Status.OPEN);
|
||||
}
|
||||
|
||||
function rematch() {
|
||||
startGame(difficulty);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} word
|
||||
* @returns {boolean} whether the word reached the server
|
||||
*/
|
||||
function play(word) {
|
||||
return send(submitWord(word, game.state.turnSeq));
|
||||
}
|
||||
|
||||
function giveUp() {
|
||||
if (confirm(t.resignConfirm)) send(resign());
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="play">
|
||||
<div class="top">
|
||||
<ConnectionBadge />
|
||||
<span class="mode">{difficultyLabels[difficulty]}</span>
|
||||
</div>
|
||||
|
||||
<ScoreBoard opponentLabel={t.opponent} />
|
||||
|
||||
{#if game.state.error}
|
||||
<p class="error" role="alert">
|
||||
{game.state.error}
|
||||
<button type="button" onclick={() => game.clearError()} aria-label={t.dismiss}>×</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if game.state.phase === 'over'}
|
||||
<GameOverPanel {isRecord} onrematch={rematch} onhome={goHome} />
|
||||
{:else}
|
||||
<div class="turn">
|
||||
<CountdownRing />
|
||||
<div class="prompt">
|
||||
<p class="who">{game.state.myTurn ? t.yourTurn : t.opponentTurn}</p>
|
||||
<p class="syllable">
|
||||
<span class="label">{t.currentSyllable}</span>
|
||||
<strong>{game.state.currentSyllable || '…'}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WordInput onsubmit={play} />
|
||||
{/if}
|
||||
|
||||
<ChainHistory />
|
||||
|
||||
{#if game.state.phase === 'playing'}
|
||||
<button type="button" class="resign" onclick={giveUp}>{t.resign}</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.play {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.turn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.who {
|
||||
margin: 0 0 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.syllable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.syllable .label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.syllable strong {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error button {
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.resign {
|
||||
align-self: center;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#2f6f4f"/>
|
||||
<text x="32" y="44" font-family="system-ui, sans-serif" font-size="34" font-weight="700"
|
||||
text-anchor="middle" fill="#ffffff">NT</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
@@ -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' })
|
||||
}
|
||||
};
|
||||
@@ -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<string, number>} */
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user