mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
build: package the game as a container image and wire CI
One distroless image of about 25 MB carries 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; the derived wordlist is copied in as its own layer alongside its licence, attribution and notice, because CC BY-SA 4.0 applies wherever that data is distributed and an image is distribution. FIXTURE_DICT=1 builds the same Dockerfile against the checked-in word sample, so the image is built and smoke-tested on every push rather than only at release. An image built only at release time is an image that breaks at release time. CI runs the Go suite under race detection, the frontend type check and tests, the browser suite, and the image with its licence assertions. The wire contract keeps its own workflow; the test steps it duplicated were removed from it. docs/deployment.md covers configuration, the reverse-proxy settings that each break the game in a way that looks like something else, and what a restart costs.
This commit is contained in:
1 parent
e162574556
commit
b2cad42b0c
9 files changed
+644
-45
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -23,6 +23,8 @@ node_modules/
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
|
||||
+80
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user