diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3fc240e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Build artifacts and caches. Everything the image needs is built inside it, +# so anything copied from the host is a chance for a stale file to ship. +.git +.github +node_modules +web/node_modules +web/build +web/.svelte-kit +web/test-results +web/playwright-report +data/*.db +data/*.db-journal +data/*.db-wal +data/*.db-shm +noitu-server +noitu-server.exe +build-dictionary +build-dictionary.exe +plans +docs +*.md +!data/ATTRIBUTION.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e72cb1b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,158 @@ +# Tests and packaging. +# +# Nothing here downloads the 179 MB upstream dictionary except the release +# image build. Everything else plays against the small database derived from +# the checked-in word sample, which goes through the same builder the real one +# does. +# +# The wire contract has its own workflow: see proto.yml. +name: ci + +on: + push: + branches: [main] + pull_request: + release: + types: [published] + +permissions: + contents: read + +jobs: + go: + name: Go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + + - name: Vet + run: go vet ./... + working-directory: server + + # -race because the whole transport layer is goroutines and timers, and a + # data race there is exactly the kind of defect that passes without it. + - name: Test + run: go test ./... -race + working-directory: server + + web: + name: Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + working-directory: web + + - name: Type check + run: npm run check + working-directory: web + + # npm test builds first, so this also proves the bundle compiles and + # carries no wordlist. + - name: Test + run: npm test + working-directory: web + + e2e: + name: End to end + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Build the fixture dictionary + run: go run ./cmd/build-dictionary --words ../testdata/fixture-words.txt --out ../data/fixture.db --min-words 150 + working-directory: server + + - run: npm ci + working-directory: web + + - name: Install the browser + run: npx playwright install --with-deps chromium + working-directory: web + + - name: Run the suite + run: npm run test:e2e + working-directory: web + + # test-results holds the traces a failure leaves behind, which is what is + # actually worth downloading; the HTML report is not generated here. + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-traces + path: web/test-results/ + retention-days: 7 + + image: + name: Container image + runs-on: ubuntu-latest + # e2e too: the moment this job gains a publish step, an image built past a + # red browser suite is an image nobody meant to ship. + needs: [go, web, e2e] + steps: + - uses: actions/checkout@v4 + + # On a release the image is built from the real upstream release, which + # is the only job in this file that downloads it. Every other run builds + # the same Dockerfile against the fixture word list, so a broken image is + # caught on the pull request rather than at release time. + - name: Build + run: | + if [ "${{ github.event_name }}" = "release" ]; then + docker build -t noitu:ci . + else + docker build --build-arg FIXTURE_DICT=1 -t noitu:ci . + fi + + # CC BY-SA 4.0 applies to the derived wordlist wherever it is + # distributed, and an image is distribution. This is the assertion that + # the obligation actually shipped. + - name: The licence travels with the data + run: | + set -eu + docker create --name check noitu:ci + docker export check | tar -t > files.txt + docker rm check + + for required in app/data/LICENSE app/data/ATTRIBUTION.md app/NOTICE app/data/noitu.db; do + grep -qx "$required" files.txt || { echo "missing from the image: $required"; exit 1; } + done + + # The 179 MB source must never reach the final image. + if grep -q 'dictionary\.db$' files.txt; then + echo "the upstream dictionary leaked into the image" + exit 1 + fi + + - name: It serves a game + run: | + set -eu + docker run -d --name noitu -p 8080:8080 noitu:ci + for _ in $(seq 1 30); do + if curl -fsS http://localhost:8080/healthz >/dev/null 2>&1; then break; fi + sleep 1 + done + curl -fsS http://localhost:8080/healthz + # A deep link is a client route, so the binary has to answer it with + # the app shell rather than a 404. + curl -fsS -o /dev/null -w '%{http_code}\n' 'http://localhost:8080/play?difficulty=2' | grep -qx 200 + docker rm -f noitu diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index e62aac9..b15308b 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -1,8 +1,8 @@ -# Guards the WebSocket wire contract. +# Guards the WebSocket wire contract: the schema is well-formed, it has not +# broken compatibility, and the committed generated code matches it. # -# Nothing here downloads the 179 MB upstream dictionary: the wire types and -# their fixtures are independent of the wordlist, so this workflow stays fast -# and runs on every push. +# The suites that decode the cross-language fixtures run in ci.yml, where the +# rest of the tests are. Nothing here downloads the 179 MB upstream dictionary. name: proto on: @@ -72,13 +72,3 @@ jobs: buf generate git add --intent-to-add -- server/gen web/src/lib/proto git diff --exit-code -- server/gen web/src/lib/proto - - # Both suites read the same fixtures in proto/testdata, so this is what - # actually proves Go and JavaScript agree on the bytes. - - name: Go wire tests - run: go test ./internal/wsapi/... -race - working-directory: server - - - name: JavaScript wire tests - run: npm test - working-directory: web diff --git a/.gitignore b/.gitignore index 1d0073f..65630fa 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ node_modules/ # Playwright /test-results/ /playwright-report/ +web/test-results/ +web/playwright-report/ # Editor / OS .DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7224f80 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# One image: the binary, the built frontend, and the derived dictionary. +# +# The 179 MB upstream release is downloaded in a builder stage and never +# reaches the final image — only the ~3 MB database derived from it does. That +# derived database is CC BY-SA 4.0 while the code is Apache-2.0, so it is +# copied in as its own layer alongside its licence and attribution rather than +# being embedded in the binary. + +# --- the frontend ----------------------------------------------------------- +FROM node:24-alpine AS web + +WORKDIR /src/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# --- the binary ------------------------------------------------------------- +FROM golang:1.25-alpine AS build + +WORKDIR /src/server +COPY server/go.mod server/go.sum ./ +RUN go mod download +COPY server/ ./ +# CGO_ENABLED=0 is what makes a distroless static image possible, and it works +# because the SQLite driver is pure Go. +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/noitu-server ./cmd/noitu-server +RUN CGO_ENABLED=0 go build -trimpath -o /out/build-dictionary ./cmd/build-dictionary + +# --- the dictionary --------------------------------------------------------- +FROM alpine:3.22 AS dict + +# Pinned by tag and checked by digest: the derived wordlist is the one thing in +# this image that cannot be rebuilt from the repository alone. The Makefile +# pins the same release for local builds, and a test asserts the two agree. +ARG DICT_URL=https://github.com/minhqnd/dictionary/releases/download/v2.0.0/dictionary.db +ARG DICT_SHA256=9259403f0675b2991a1bd0ef6d0dbc5933afdb135632af095a60662f09bbf1d3 + +# Set to 1 to build from the checked-in word sample instead of downloading the +# upstream release. That produces a playable but tiny dictionary, and exists so +# the image itself can be smoke-tested without a 179 MB download. +ARG FIXTURE_DICT=0 + +RUN apk add --no-cache curl +WORKDIR /work +COPY --from=build /out/build-dictionary /usr/local/bin/build-dictionary +COPY testdata/fixture-words.txt ./fixture-words.txt + +RUN set -eu; \ + mkdir -p /out; \ + if [ "$FIXTURE_DICT" = "1" ]; then \ + build-dictionary --words ./fixture-words.txt --out /out/noitu.db --min-words 150; \ + else \ + curl -fsSL -o dictionary.db "$DICT_URL"; \ + echo "$DICT_SHA256 dictionary.db" | sha256sum -c -; \ + build-dictionary --in ./dictionary.db --out /out/noitu.db; \ + fi + +# --- the image -------------------------------------------------------------- +FROM gcr.io/distroless/static-debian12:nonroot + +WORKDIR /app +COPY --from=build /out/noitu-server /app/noitu-server +COPY --from=web /src/web/build /app/web + +# The share-alike half of the image. data/LICENSE and data/ATTRIBUTION.md ship +# with the derived wordlist because CC BY-SA 4.0 applies to it wherever it is +# distributed, and a container image is distribution. +COPY --from=dict /out/noitu.db /app/data/noitu.db +COPY data/LICENSE /app/data/LICENSE +COPY data/ATTRIBUTION.md /app/data/ATTRIBUTION.md +COPY NOTICE /app/NOTICE + +ENV NOITU_ADDR=:8080 \ + NOITU_DB_PATH=/app/data/noitu.db \ + NOITU_WEB_DIR=/app/web + +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/app/noitu-server"] diff --git a/README.md b/README.md index 3c57ce7..0b44337 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ ngôn ngữ → ngữ pháp → pháp luật → luật lệ → ... ## Status -In development. See [`plans/260904-1125-noi-tu-web-game/plan.md`](./plans/260904-1125-noi-tu-web-game/plan.md) +Playable: vs bot at three difficulties, and online 1v1 by room code. See +[`plans/260904-1125-noi-tu-web-game/plan.md`](./plans/260904-1125-noi-tu-web-game/plan.md) for the implementation plan and phase breakdown. ## Architecture @@ -43,6 +44,19 @@ 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. +### Online play + +A player creates a room and gets a six-character code and an invite link. The +alphabet omits `0`/`O` and `1`/`I`/`L`, because these codes get read aloud. The +other player types the code or opens the link, which joins on arrival. + +Both players see the other's server-sanitized nickname, never the raw input. A +disconnect holds the seat for a grace window and shows the opponent a countdown; +a return inside it resumes the same position, rebuilt from the engine rather +than from a recorded stream. When a game ends, either player may ask for a +rematch and the room restarts with a new opening word once both agree. Leaving +is how a rematch is declined — there is no separate message for it. + ### The frontend `web/` is a SvelteKit single-page app in JavaScript, built by `adapter-static` and served by @@ -135,7 +149,9 @@ dev-only URL to get wrong. | `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 | +| `fixture-dict` | Build the small test dictionary, no download needed | | `test` | Run Go and JavaScript tests | +| `test-e2e` | Run the Playwright suite against the fixture dictionary | | `run` | Build and run the server locally | | `verify-dict` | Re-check the downloaded dictionary against its pinned SHA-256 | @@ -162,11 +178,40 @@ 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 +# fixture-dict +cd server && go run ./cmd/build-dictionary --words ../testdata/fixture-words.txt --out ../data/fixture.db --min-words 150 + +# test-e2e (needs the fixture dictionary above) +cd web && npm run test:e2e + # proto (only when proto/noitu/v1/game.proto changes) cd web && npm ci buf generate && buf lint ``` +## Testing + +| Suite | What it covers | +|---|---| +| `cd server && go test ./... -race` | The rules engine, the bot, the dictionary, and the whole transport layer | +| `cd web && npm test` | The store, the socket client, the Vietnamese copy, and the built bundle | +| `cd web && npm run test:e2e` | Real browsers against the real binary: a bot game, an online game across two browser contexts, and reconnect | + +The end-to-end suite plays against a small dictionary derived from +[`testdata/fixture-words.txt`](./testdata/fixture-words.txt) through the same +builder the real one uses, so CI never downloads the 179 MB upstream release. + +## Deployment + +One container image carries the binary, the built frontend and the derived +dictionary. See [`docs/deployment.md`](./docs/deployment.md) for configuration, +reverse-proxy requirements, and what a restart costs. + +```sh +docker build -t noitu:latest . +docker run -p 8080:8080 noitu:latest +``` + ## License This project is distributed under **two licenses**, applying to different artifacts. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..1ba83eb --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,143 @@ +# Deployment + +The whole game is one binary. It serves the WebSocket API, the built frontend, +and a health check, and it reads a single database file at startup. The +supported shape is the container image behind a reverse proxy that terminates +TLS. + +## Configuration + +Every setting is an environment variable and every one has a working default, +so the image runs with nothing set. + +| Variable | Default | Meaning | +|---|---|---| +| `NOITU_ADDR` | `:8080` | Listen address | +| `NOITU_DB_PATH` | `data/noitu.db` | Derived dictionary, opened read-only at startup | +| `NOITU_TURN_LIMIT` | `20s` | Turn deadline, identical for bot and online games | +| `NOITU_GRACE` | `30s` | How long a disconnected player's seat is held for a reconnect | +| `NOITU_ALLOWED_ORIGINS` | *(unset)* | Comma-separated origin allowlist. Unset means same-origin only | +| `NOITU_WEB_DIR` | *(unset)* | Built frontend to serve. Unset serves the API alone | + +An invalid duration is logged and ignored rather than silently changing the +rules of the game. + +One timing is not configurable: a finished online room waits 30 seconds for +both players to ask for a rematch, then closes. It is a fixed constant because +nothing about a deployment should change how long two people have to agree. + +The image sets `NOITU_ADDR`, `NOITU_DB_PATH` and `NOITU_WEB_DIR` for you. + +### Origins + +Leave `NOITU_ALLOWED_ORIGINS` unset when the binary serves the frontend, which +is the normal case: the page and the socket share an origin and the browser's +own check is enough. Set it only when the frontend is served from somewhere +else, and then list exactly those origins. An allowlist that is wrong in the +permissive direction lets any page open a socket as one of your players. + +## The image + +```sh +docker build -t noitu:latest . +docker run -p 8080:8080 noitu:latest +``` + +The build downloads the 179 MB upstream dictionary in a builder stage and +derives the ~3 MB database the game uses. Only the derived file is copied into +the final image, so the upstream release never ships. The result is a +distroless image of about 25 MB running as a non-root user. + +Passing `--build-arg FIXTURE_DICT=1` builds the same image against the +checked-in word sample instead. It produces a playable but tiny dictionary and +exists so the image can be tested without the download; do not ship it. + +### What travels with the data + +The derived wordlist is CC BY-SA 4.0 while the code is Apache-2.0, so the image +carries `data/LICENSE`, `data/ATTRIBUTION.md` and `NOTICE` alongside it. CI +asserts all three are present, and that the upstream file is not. Removing them +would put the image out of compliance. + +## Behind a reverse proxy + +The socket is a normal HTTP upgrade, but three settings are easy to get wrong +and each one breaks the game in a way that looks like something else. + +**Forward the upgrade.** Without `Upgrade` and `Connection` the handshake +returns 400 or 502 and the page sits on "Đang kết nối…" forever. + +**Set a read timeout longer than the keepalive.** The server pings every 20 +seconds. A proxy that closes idle connections sooner will cut players off mid +game, and it will look like a client bug because the server logs a clean close. + +**Turn response buffering off.** A proxy that buffers will hold frames until it +has enough to flush, which turns a 20-second turn into a guess. + +nginx: + +```nginx +location /ws { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_buffering off; +} + +location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; +} +``` + +Caddy needs none of this: it forwards upgrades and streams by default. + +```caddy +noitu.example { + reverse_proxy 127.0.0.1:8080 +} +``` + +### The client's own address + +Rate limiting counts against `RemoteAddr` and deliberately ignores +`X-Forwarded-For`, because that header is attacker-controlled unless the proxy +overwrites it. Behind a proxy every player therefore shares one bucket. If that +becomes a problem, the fix is to make the proxy the only source of the header +and teach the server to trust it — not to trust it as things stand. + +## Health check + +`GET /healthz` returns 200 once the dictionary has loaded. It does not report +on live games, so it is a liveness check rather than a readiness one. + +```sh +curl -fsS https://noitu.example/healthz +``` + +A post-deploy check worth having is a real socket open, because the health +check passes whether or not the proxy forwards upgrades. Opening the site and +starting a game against the bot is the shortest version of that. + +## What a restart costs + +Rooms are in memory. A restart ends every live game, and players are told the +server is restarting rather than being left waiting. Deploy when the game is +quiet, or accept that the games in flight are lost — there is no session +persistence, by design, in this version. + +## Updating the dictionary + +The wordlist is a build artifact, not runtime state. A new upstream release +means a new image: + +1. Update `DICT_URL` and `DICT_SHA256` in the `Dockerfile`, and the matching + values in the `Makefile`. +2. Record what changed in `data/ATTRIBUTION.md`. +3. Rebuild and redeploy. + +Nothing migrates, because nothing persists. diff --git a/plans/260904-1125-noi-tu-web-game/phase-07-online-1v1-and-release.md b/plans/260904-1125-noi-tu-web-game/phase-07-online-1v1-and-release.md index 1e0455c..0ec887a 100644 --- a/plans/260904-1125-noi-tu-web-game/phase-07-online-1v1-and-release.md +++ b/plans/260904-1125-noi-tu-web-game/phase-07-online-1v1-and-release.md @@ -1,6 +1,6 @@ --- title: "Phase 7: Online 1v1 and Release" -status: todo +status: done phase: 7 priority: P1 effort: "4d" @@ -18,19 +18,19 @@ and ship it: CI, container image, deployment, and documentation. ## Requirements **Functional** -- [ ] Create a room → shareable 6-character code and a copyable invite link -- [ ] Join by code, or by opening an invite link with the code prefilled -- [ ] Waiting room until the opponent arrives; leaving cleans the room up -- [ ] Both players' nicknames shown in the waiting room and scoreboard (server-sanitized values only) -- [ ] Live opponent state: their turn, their timer, their disconnect and reconnect -- [ ] Resign, and rematch in the same room after a game ends -- [ ] Clear Vietnamese error states: room not found, room full, game already started +- [x] Create a room → shareable 6-character code and a copyable invite link +- [x] Join by code, or by opening an invite link with the code prefilled +- [x] Waiting room until the opponent arrives; leaving cleans the room up +- [x] Both players' nicknames shown in the waiting room and scoreboard (server-sanitized values only) +- [x] Live opponent state: their turn, their timer, their disconnect and reconnect +- [x] Resign, and rematch in the same room after a game ends +- [x] Clear Vietnamese error states: room not found, room full — a room whose game has started is full, so there is no third state **Non-functional** -- [ ] E2E coverage of both modes with two real browser contexts +- [x] E2E coverage of both modes with two real browser contexts - [ ] CI: Go tests + race, JS tests, `buf lint`/`breaking`, generated-code drift check, builds -- [ ] Deployable artifact: container image with the binary, the static frontend, and `noitu.db` -- [ ] README documents setup, the license split, and deployment +- [x] Deployable artifact: container image with the binary, the static frontend, and `noitu.db` +- [x] README documents setup, the license split, and deployment ## Architecture @@ -120,16 +120,16 @@ layer, keeping the CC BY-SA 4.0 artifact physically distinct from the Apache-2.0 ## Success Criteria -- [ ] Two people on different machines play a full game via a shared room code -- [ ] Invite link opens straight into the room -- [ ] All three join errors show correct Vietnamese messages -- [ ] Disconnect shows the opponent a grace countdown; return inside it resumes with identical boards; expiry awards the win -- [ ] Rematch restarts in the same room with a new opening word -- [ ] Resign ends the game immediately with the right winner -- [ ] Playwright suite green: bot game, PvP game, reconnect +- [x] Two independent browser contexts play a full game via a shared room code; two physical machines untested +- [x] Invite link opens straight into the room +- [x] Both join errors the protocol has show correct Vietnamese messages, plus a malformed code caught before it is sent +- [x] Disconnect shows the opponent a grace countdown; return inside it resumes with identical boards; expiry awards the win +- [x] Rematch restarts in the same room with a new opening word +- [x] Resign ends the game immediately with the right winner +- [x] Playwright suite green: bot game, PvP game, reconnect - [ ] CI green on all steps including the generated-code drift check, without ever downloading the 179 MB upstream DB -- [ ] `docker run` with `NOITU_DB_PATH` serves a playable game; the image contains `data/LICENSE` and `data/ATTRIBUTION.md` but not the 179 MB upstream file -- [ ] README license section and in-app attribution agree with `data/ATTRIBUTION.md` +- [x] `docker run` serves a playable game; the image contains `data/LICENSE`, `data/ATTRIBUTION.md` and `NOTICE` but no upstream file — verified against a fixture-dictionary build, not an upstream one +- [x] README license section and in-app attribution agree with `data/ATTRIBUTION.md` - [ ] Full pass over `plan.md` success criteria — every box checkable ## Risk Assessment @@ -142,3 +142,136 @@ layer, keeping the CC BY-SA 4.0 artifact physically distinct from the Apache-2.0 | Room codes guessable enough to join a stranger's game | Reports of uninvited joins | `crypto/rand` codes plus per-IP join rate limiting from phase 5; 32^6 space with rate limiting makes scanning impractical | | CC BY-SA obligations lost during packaging | Image ships without `data/LICENSE` | The Docker copy step includes the whole `data/` directory, and a CI assertion checks `data/LICENSE` and `data/ATTRIBUTION.md` exist in the built image | | Scope creep into accounts/leaderboards at the finish line | New requests during release work | Explicit non-goals in `plan.md`; log them as post-v1 items instead | + +## Phase 7 Outcome (2026-09-05) + +Online 1v1 is playable end to end, the whole game is covered by a browser suite, and the +result ships as one 24 MB container image. + +### What was verified, and how + +| Claim | Evidence | +|---|---| +| Two players join by code and alternate turns | Two browser contexts against the real binary; each side sees the other's sanitized nickname and the other's words | +| An invite link opens straight into the room | The second context only opens a URL — no code typed, no button pressed | +| A rematch restarts the same room | One acceptance shows the other player a prompt; the second starts a new game with a fresh opening word and the room code unchanged | +| A disconnect is announced and survivable | Taking a player's page away raises the opponent's banner; returning inside the window restores the same position and clears it | +| An opponent who never returns forfeits | The grace window expires and the win is awarded, with the reason shown | +| The image runs and carries its obligations | `docker run` serves the app and a deep link; the image contains `data/LICENSE`, `data/ATTRIBUTION.md` and `NOTICE`, and no upstream database | +| The difficulty ladder is real | The simulation suite from phase 3 reports Hard beating Easy 94 games to 6, Medium beating Easy 93 to 7, and Hard beating Medium 78 to 22 | + +21 browser tests, 139 JavaScript unit tests, and the Go suite under `-race` all pass. + +### Deliberate design points + +**The room now outlives its game, but only when there is someone to ask.** A finished game +opens a rematch offer when both seats still hold live connections. A bot room closes exactly +as before, because there is nothing to negotiate with a bot and keeping it alive would leak a +goroutine and an engine per finished game. + +**There is no decline message.** Leaving is the decline, and the server already learns about +that from the socket closing. One message and one timeout cover every way a rematch does not +happen, which is a smaller protocol and one less state to get wrong. + +**`turn_seq` no longer restarts at one.** A rematch reuses the same connections, so a +submission still in flight from the previous game could otherwise match a turn in the new one +and be applied to it. + +**The fixture dictionary goes through the real builder.** `build-dictionary --words` reads a +checked-in list and runs it through the same filter, alias and write path production uses, so +a test database cannot drift into being shaped differently from what the server loads. Its +graph has exactly one syllable productive enough to open on, which is what makes a scripted +game deterministic without pinning the bot's replies. + +### Defects found by building the browser suite + +**A player who refreshed mid-game could not get back in.** The online screen waited for the +player to ask for a room before connecting — correct for someone who has just arrived and is +still typing their name, wrong for a tab that already holds a session. It now reconnects +immediately when a resume token is present. + +**The opponent's disconnect banner never cleared.** The server restored the seat but told +nobody, so the other player watched "đối thủ mất kết nối" for someone already playing again. +A resume now announces itself to the opponent. + +**A dead socket was invisible to the client.** Nothing noticed a connection that failed +without closing, so the page kept showing a live connection and a running countdown over a +socket nothing could reach. The client now treats silence longer than three ping intervals as +a dead socket and reconnects. + +**The nickname typed on the online screen never reached the server.** The socket opened on +arrival and `Hello` carries the name once, so both players were introduced under whatever was +stored before they got there. Connecting when the player actually asks for a room fixed it. + +### Two tools that did not work, and why + +**Offline emulation does not cut a loopback socket.** A test that "went offline" kept playing +happily against the local server. Routing the WebSocket cuts it for real. + +**A routed socket's close does not reliably reach the server.** The page observes it, but the +grace window never starts. That makes socket routing the right tool for what the client does +about a dead connection and the wrong one for what the server does about a missing player; the +latter tests take the page away instead. Both limitations are recorded in `e2e/socket-cut.js` +so the next person does not rediscover them. + +### Deviations from the plan + +- Playwright lives under `web/` rather than the repository root, so there is still one npm + package rather than two. +- There are two join errors, not three: a room whose game has started is full, and the server + has no separate code for it. A malformed code is caught in the client before it is sent. +- CI is split across two workflows. `proto.yml` keeps the wire contract, `ci.yml` owns the + tests and the image, and the duplicated test steps were removed from the former. +- The image is built on every push against the fixture word list, not only on release. An + image built only at release time is an image that breaks at release time, and the build + argument that makes this cheap already existed for local testing. + +### Defects found by review + +Review reproduced three defects, two of them consequences of the new state this phase +introduced: a room that outlives its game. + +**A game that ended on the turn clock never offered a rematch, and the button was still +there.** The offer was opened from the message arm of the room loop, so the most common +natural ending — running out of time — closed the room with nothing to accept. The player +pressed "chơi lại" and got an error. The end-of-game decision now sits after the whole select, +so every way a game can end reaches it, and the online game-over panel no longer shows a +rematch button at all: the prompt appears only while an offer is actually open, so it is the +only thing that can ask. + +**A second connection presenting the same resume token disconnected the player who was still +there.** `resumeFrom` retired the old connection as soon as the room had *accepted the +message*, but the room decides asynchronously and refuses a resume into a finished game — which +is exactly the state during a rematch offer. A duplicated tab therefore ended the room. The +room now retires the old connection only once it has agreed to the swap. Before this phase the +window did not exist, so a refused resume was unreachable. + +**Rooms leaked when one connection created several.** `attach` overwrote the session's room +pointer and nothing told the old room, which then parked in `select` forever holding a +goroutine and a room code. Rate limiting bounds the rate, not the total, and connections are +free, so this was unbounded on a public endpoint. `attach` now releases the room it is leaving. +This one predates the phase; it is fixed here because this is the phase that puts the server on +the internet. + +Also fixed: `RequestRematch` had no rate limit despite being the only client message that fans +out to both players, so a burst could fill the opponent's outbox until the server closed their +session. A stale disconnect notice was being treated as a rematch decline, which needed +`handleDisconnect` to stop conflating "this notice applied" with "a game is still running". +The client's liveness check could fire on a throttled background tab and close a healthy +socket, so it now ignores a tick that was itself late. And a stale resume token opened the +lobby with an error the player did nothing to cause. + +Each of the three defects has a regression test, and the two that were reachable through the +room loop were negative-tested by reverting the fix and watching them fail. + +### Not verified + +CI has not run on GitHub. Every step passes locally, including the image build and its licence +assertions, but the workflows themselves are unexercised. + +The image was built from the fixture word list, not the 179 MB upstream release. The +downloading branch of the `Dockerfile` is the same shape, pinned by the same checksum the +`Makefile` uses, but it has not been run here. + +Two players on two physical machines have not played. Two independent browser contexts have, +which exercises everything except the network between them. diff --git a/plans/260904-1125-noi-tu-web-game/plan.md b/plans/260904-1125-noi-tu-web-game/plan.md index 00438e2..7007d0a 100644 --- a/plans/260904-1125-noi-tu-web-game/plan.md +++ b/plans/260904-1125-noi-tu-web-game/plan.md @@ -1,7 +1,7 @@ --- title: "Noi Tu Web Game" description: "Vietnamese nối từ web game — SvelteKit frontend, Go backend, WebSocket + Protobuf, server-authoritative dictionary over SQLite. Vs-bot and online 1v1." -status: in-progress +status: complete priority: P1 effort: "~3-4w" tags: [game, sveltekit, go, websocket, protobuf, sqlite, vietnamese] @@ -135,7 +135,7 @@ added, all non-`vi` languages and all definitions/translations dropped). | 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) | Complete | 4, 5 | -| 7 | [Online 1v1 and Release](./phase-07-online-1v1-and-release.md) | Pending | 5, 6 | +| 7 | [Online 1v1 and Release](./phase-07-online-1v1-and-release.md) | Complete | 5, 6 | Phases 2-3 and 4 are independent after phase 1 and can run in parallel if desired. @@ -172,17 +172,17 @@ web/ ## Success Criteria -- [ ] `server/cmd/build-dictionary` reproducibly turns the upstream `dictionary.db` into `data/noitu.db`; entry count and ≥2-syllable purity asserted -- [ ] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section, and in-app credit all present and consistent -- [ ] Go engine unit tests cover: wrong link, unknown word, reuse, single-syllable input, timeout, no-legal-move, and the tone-variant cases `hoà/hòa`, `thuý/thúy`, `quí/quý` -- [ ] Words of 2, 3, and 4 syllables all accepted and chain correctly on first↔last syllable +- [x] `server/cmd/build-dictionary` reproducibly turns the upstream `dictionary.db` into `data/noitu.db`; entry count and ≥2-syllable purity asserted +- [x] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section, and in-app credit all present and consistent +- [x] Go engine unit tests cover: wrong link, unknown word, reuse, single-syllable input, timeout, no-legal-move, and the tone-variant cases `hoà/hòa`, `thuý/thúy`, `quí/quý` +- [x] Words of 2, 3, and 4 syllables all accepted and chain correctly on first↔last syllable - [x] One `.proto` generates working Go and JS clients; no hand-written message types -- [ ] 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 +- [x] Vs-bot playable end to end at all 3 difficulties; Hard bot wins measurably more than Easy over 100 simulated games +- [x] 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 +- [x] Vietnamese UI throughout; nickname and high score persist, checked in a real browser. The dark-mode toggle persists by the same mechanism but has only been unit-tested - [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 +- [ ] CI runs green without ever downloading the 179 MB upstream DB — the workflows are written and every step passes locally, but they have not run on GitHub ## Risk Assessment @@ -338,6 +338,32 @@ tab into a reconnect loop, an opening turn counted against the device clock, and 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. +## Phase 7 Outcome (2026-09-05) + +Online 1v1 is playable end to end and the game ships as one 24 MB container image. Detail in +[`phase-07`](./phase-07-online-1v1-and-release.md#phase-7-outcome-2026-09-05). + +The rematch needed the one thing phase 5 deliberately did not do: let a room outlive its game. +It now does, but only while both seats hold live connections, so a bot room still closes the +moment its game ends. There is no decline message — leaving is the decline, which the server +already learns from the socket closing. + +Building the browser suite found four defects the unit tests could not: a player who refreshed +mid-game could not rejoin, a restored opponent never cleared the other player's disconnect +banner, a socket that failed without closing was invisible to the client, and a nickname typed +on the online screen never reached the server because the handshake had already gone. + +Two testing tools turned out not to work here and are documented where the next person will +look: offline emulation does not cut a loopback socket, and a routed socket's close does not +reliably reach the server, so the grace window never starts. + +Review then reproduced three more. Two follow from the new state this phase introduced — a room +that outlives its game — and one predates it: a game ending on the turn clock never offered a +rematch although the button was there, a second connection presenting the same resume token +disconnected the player who had not left, and rooms leaked whenever one connection created +several. All three now have regression tests, and the two reachable through the room loop were +negative-tested by reverting the fix. + ## 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.