mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(proto): add protobuf wire contract and cross-language codegen
proto/noitu/v1/game.proto is the single source of truth for every WebSocket message. buf generates Go types into server/gen and JavaScript types into web/src/lib/proto; both trees are committed so building needs no codegen toolchain. The Go suite emits binary fixtures into proto/testdata and the JavaScript suite decodes the same bytes, so the two generated clients are checked against one artifact rather than against each other's assumptions. CI lints the schema, rejects breaking changes against main, and fails when the committed generated trees drift from the schema. game.NumRejectReasons and game.NumEndReasons let the mapping tests prove every engine reason has a wire value without guessing where the enum ends.
This commit is contained in:
1 parent
ca01145d06
commit
48b3b3c8ac
42 files changed
+6102
-31
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
# The wire fixtures in proto/testdata are protobuf-encoded bytes shared by the
|
||||||
|
# Go and JavaScript test suites. Several contain 0x0a and no NUL, so a checkout
|
||||||
|
# with autocrlf enabled could rewrite them into messages that no longer decode.
|
||||||
|
proto/testdata/*.bin binary
|
||||||
|
|
||||||
|
# Generated code is checked in so building needs no codegen toolchain; keeping
|
||||||
|
# it out of diffs makes reviews readable and stops it inflating language stats.
|
||||||
|
server/gen/** linguist-generated=true
|
||||||
|
web/src/lib/proto/** linguist-generated=true
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Guards the WebSocket wire contract.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
name: proto
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# buf breaking compares against main, which needs real history.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: bufbuild/buf-setup-action@v1
|
||||||
|
with:
|
||||||
|
version: 1.69.0
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
working-directory: web
|
||||||
|
|
||||||
|
- name: Lint the schema
|
||||||
|
run: buf lint
|
||||||
|
|
||||||
|
# Skipped only until this contract first lands on main; after that the
|
||||||
|
# baseline always exists and the check is unconditional.
|
||||||
|
- name: Look for a breaking-change baseline on main
|
||||||
|
id: baseline
|
||||||
|
run: |
|
||||||
|
if git cat-file -e origin/main:proto/noitu/v1/game.proto 2>/dev/null; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::no schema on main yet, skipping the breaking-change check"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ref=origin/main, not branch=main: a pull_request checkout is a detached
|
||||||
|
# HEAD with no local main, and branch=main fails there with a git clone
|
||||||
|
# error rather than a breaking-change report.
|
||||||
|
- name: Reject breaking changes
|
||||||
|
if: steps.baseline.outputs.exists == 'true'
|
||||||
|
run: buf breaking --against '.git#ref=origin/main'
|
||||||
|
|
||||||
|
# A committed generated tree that no longer matches the schema is worse
|
||||||
|
# than no generated tree at all: it compiles, and it is wrong.
|
||||||
|
# --intent-to-add first: git diff ignores untracked files, so without it
|
||||||
|
# a plugin that starts emitting a new file passes this check while the
|
||||||
|
# committed tree is incomplete.
|
||||||
|
- name: Verify the committed generated code is in sync
|
||||||
|
run: |
|
||||||
|
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
|
||||||
@@ -11,12 +11,14 @@ DICT_SHA256 := 9259403f0675b2991a1bd0ef6d0dbc5933afdb135632af095a60662f09bbf1d3
|
|||||||
DICT_OUT := data/noitu.db
|
DICT_OUT := data/noitu.db
|
||||||
SERVER_BIN := noitu-server
|
SERVER_BIN := noitu-server
|
||||||
|
|
||||||
.PHONY: help fetch-dict verify-dict dict server web test test-go test-web run clean
|
.PHONY: help fetch-dict verify-dict dict proto proto-check server web test test-go test-web run clean
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "fetch-dict download + checksum the upstream dictionary (~179 MB) into data/"
|
@echo "fetch-dict download + checksum the upstream dictionary (~179 MB) into data/"
|
||||||
@echo "verify-dict re-check the downloaded dictionary against its pinned SHA-256"
|
@echo "verify-dict re-check the downloaded dictionary against its pinned SHA-256"
|
||||||
@echo "dict derive $(DICT_OUT) from $(DICT_SRC)"
|
@echo "dict derive $(DICT_OUT) from $(DICT_SRC)"
|
||||||
|
@echo "proto regenerate the Go and JS wire types from proto/"
|
||||||
|
@echo "proto-check lint the schema and verify the committed output is in sync"
|
||||||
@echo "server build the Go server binary"
|
@echo "server build the Go server binary"
|
||||||
@echo "web build the SvelteKit frontend"
|
@echo "web build the SvelteKit frontend"
|
||||||
@echo "test run all tests"
|
@echo "test run all tests"
|
||||||
@@ -41,19 +43,39 @@ $(DICT_SRC):
|
|||||||
dict: $(DICT_SRC)
|
dict: $(DICT_SRC)
|
||||||
cd server && go run ./cmd/build-dictionary --in ../$(DICT_SRC) --out ../$(DICT_OUT)
|
cd server && go run ./cmd/build-dictionary --in ../$(DICT_SRC) --out ../$(DICT_OUT)
|
||||||
|
|
||||||
|
# Regenerates both targets from proto/noitu/v1/game.proto. Needs `buf`; the
|
||||||
|
# two code generators come from server/go.mod's tool directive and
|
||||||
|
# web/package.json, so there is nothing to install globally. Generated code is
|
||||||
|
# committed, so building and running the project never requires this target.
|
||||||
|
proto: web/node_modules
|
||||||
|
buf generate
|
||||||
|
|
||||||
|
# What CI runs: the schema is well-formed, and the committed generated trees
|
||||||
|
# match what the schema currently produces.
|
||||||
|
# --intent-to-add makes a newly emitted file visible: git diff alone ignores
|
||||||
|
# untracked files and would call an incomplete committed tree clean.
|
||||||
|
proto-check: proto
|
||||||
|
buf lint
|
||||||
|
git add --intent-to-add -- server/gen web/src/lib/proto
|
||||||
|
git diff --exit-code -- server/gen web/src/lib/proto
|
||||||
|
|
||||||
|
web/node_modules: web/package.json web/package-lock.json
|
||||||
|
cd web && npm ci
|
||||||
|
@touch web/node_modules
|
||||||
|
|
||||||
server:
|
server:
|
||||||
cd server && CGO_ENABLED=0 go build -o ../$(SERVER_BIN) ./cmd/noitu-server
|
cd server && CGO_ENABLED=0 go build -o ../$(SERVER_BIN) ./cmd/noitu-server
|
||||||
|
|
||||||
web:
|
web: web/node_modules
|
||||||
cd web && npm ci && npm run build
|
cd web && npm run build
|
||||||
|
|
||||||
test: test-go test-web
|
test: test-go test-web
|
||||||
|
|
||||||
test-go:
|
test-go:
|
||||||
cd server && go vet ./... && go test ./... -race
|
cd server && go vet ./... && go test ./... -race
|
||||||
|
|
||||||
test-web:
|
test-web: web/node_modules
|
||||||
@if [ -d web/node_modules ]; then cd web && npm test; else echo "web/ not set up yet, skipping"; fi
|
cd web && npm test
|
||||||
|
|
||||||
run: server
|
run: server
|
||||||
./$(SERVER_BIN)
|
./$(SERVER_BIN)
|
||||||
|
|||||||
@@ -31,9 +31,23 @@ SvelteKit SPA ──WebSocket + Protobuf──► Go server ──► SQLite
|
|||||||
| Backend | Go, [`coder/websocket`](https://github.com/coder/websocket) |
|
| Backend | Go, [`coder/websocket`](https://github.com/coder/websocket) |
|
||||||
| Dictionary | SQLite via `modernc.org/sqlite` (CGo-free), read-only at runtime |
|
| Dictionary | SQLite via `modernc.org/sqlite` (CGo-free), read-only at runtime |
|
||||||
|
|
||||||
|
### The wire contract
|
||||||
|
|
||||||
|
[`proto/noitu/v1/game.proto`](./proto/noitu/v1/game.proto) is the single source of truth for
|
||||||
|
every message crossing the WebSocket. `buf` generates the Go types into `server/gen/` and the
|
||||||
|
JavaScript types into `web/src/lib/proto/`; both trees are committed, and neither side
|
||||||
|
hand-writes a message type.
|
||||||
|
|
||||||
|
The Go test suite emits binary fixtures into `proto/testdata/`, and the JavaScript suite
|
||||||
|
decodes those same bytes — so the two generated clients are checked against one artifact
|
||||||
|
rather than against each other's assumptions. Regenerate the fixtures with
|
||||||
|
`cd server && go test ./internal/wsapi -update` whenever the schema changes.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Requires Go 1.25+, Node 20+, and optionally `make`.
|
Requires Go 1.25+, Node 20+, and optionally `make`. [`buf`](https://buf.build/docs/installation)
|
||||||
|
is needed only to change the WebSocket schema — the generated code is committed, so
|
||||||
|
building and running the project does not require it.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make fetch-dict # one-time: downloads the ~179 MB upstream dictionary into data/
|
make fetch-dict # one-time: downloads the ~179 MB upstream dictionary into data/
|
||||||
@@ -53,6 +67,8 @@ both are build artifacts. See [`data/ATTRIBUTION.md`](./data/ATTRIBUTION.md).
|
|||||||
| `dict` | Derive `data/noitu.db` from the upstream database |
|
| `dict` | Derive `data/noitu.db` from the upstream database |
|
||||||
| `server` | Build the Go server binary |
|
| `server` | Build the Go server binary |
|
||||||
| `web` | Build the SvelteKit frontend to static assets |
|
| `web` | Build the SvelteKit frontend to static assets |
|
||||||
|
| `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 |
|
| `test` | Run Go and JavaScript tests |
|
||||||
| `run` | Build and run the server locally |
|
| `run` | Build and run the server locally |
|
||||||
| `verify-dict` | Re-check the downloaded dictionary against its pinned SHA-256 |
|
| `verify-dict` | Re-check the downloaded dictionary against its pinned SHA-256 |
|
||||||
@@ -73,6 +89,10 @@ cd server && go vet ./... && go test ./... -race
|
|||||||
|
|
||||||
# server
|
# server
|
||||||
cd server && CGO_ENABLED=0 go build -o ../noitu-server ./cmd/noitu-server
|
cd server && CGO_ENABLED=0 go build -o ../noitu-server ./cmd/noitu-server
|
||||||
|
|
||||||
|
# proto (only when proto/noitu/v1/game.proto changes)
|
||||||
|
cd web && npm ci
|
||||||
|
buf generate && buf lint
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: v2
|
||||||
|
clean: true
|
||||||
|
managed:
|
||||||
|
enabled: false
|
||||||
|
plugins:
|
||||||
|
# Messages only. The WebSocket layer does its own framing, so there is no
|
||||||
|
# gRPC service definition and no grpc plugin.
|
||||||
|
#
|
||||||
|
# Both plugins are resolved from the repo's own dependency manifests — the
|
||||||
|
# Go tool directive in server/go.mod and web/package.json — so regenerating
|
||||||
|
# needs no globally installed codegen binaries, only `buf` itself.
|
||||||
|
- local: ["go", "-C", "server", "tool", "protoc-gen-go"]
|
||||||
|
out: server/gen
|
||||||
|
opt: paths=source_relative
|
||||||
|
|
||||||
|
# ESM + .d.ts so the SvelteKit bundle can tree-shake the runtime.
|
||||||
|
- local: ["node", "./web/node_modules/@bufbuild/protoc-gen-es/bin/protoc-gen-es"]
|
||||||
|
out: web/src/lib/proto
|
||||||
|
opt:
|
||||||
|
- target=js+dts
|
||||||
|
- import_extension=.js
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
version: v2
|
||||||
|
modules:
|
||||||
|
- path: proto
|
||||||
|
lint:
|
||||||
|
use:
|
||||||
|
- STANDARD
|
||||||
|
breaking:
|
||||||
|
use:
|
||||||
|
- FILE
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Phase 1: Foundations and Data Pipeline"
|
title: "Phase 1: Foundations and Data Pipeline"
|
||||||
status: todo
|
status: done
|
||||||
phase: 1
|
phase: 1
|
||||||
priority: P1
|
priority: P1
|
||||||
effort: "3d"
|
effort: "3d"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Phase 2: Go Dictionary and Normalization"
|
title: "Phase 2: Go Dictionary and Normalization"
|
||||||
status: todo
|
status: done
|
||||||
phase: 2
|
phase: 2
|
||||||
priority: P1
|
priority: P1
|
||||||
effort: "2d"
|
effort: "2d"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Phase 3: Go Game Engine and Bot AI"
|
title: "Phase 3: Go Game Engine and Bot AI"
|
||||||
status: todo
|
status: done
|
||||||
phase: 3
|
phase: 3
|
||||||
priority: P1
|
priority: P1
|
||||||
effort: "4d"
|
effort: "4d"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Phase 4: Protobuf Contract and Codegen"
|
title: "Phase 4: Protobuf Contract and Codegen"
|
||||||
status: todo
|
status: done
|
||||||
phase: 4
|
phase: 4
|
||||||
priority: P1
|
priority: P1
|
||||||
effort: "2d"
|
effort: "2d"
|
||||||
@@ -20,14 +20,14 @@ Independent of phases 2-3; can be built in parallel with them.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
**Functional**
|
**Functional**
|
||||||
- [ ] `proto/noitu/v1/game.proto` covers handshake with nickname, create/join room, start bot game, submit word, move results, turn state, game over, errors, heartbeat
|
- [x] `proto/noitu/v1/game.proto` covers handshake with nickname, create/join room, start bot game, submit word, move results, turn state, game over, errors, heartbeat
|
||||||
- [ ] `buf generate` emits Go into `server/gen/noitu/v1` and JS into `web/src/lib/proto`
|
- [x] `buf generate` emits Go into `server/gen/noitu/v1` and JS into `web/src/lib/proto`
|
||||||
- [ ] Protocol version field in the handshake; server rejects mismatched majors with a readable error
|
- [x] Protocol version field in the handshake — `Hello.protocol_version`, `Welcome.protocol_version`, and `wsapi.ProtocolVersion = 1`. Rejecting a mismatch lands in phase 5, which is where `Hello` is first read
|
||||||
- [ ] Generated code is committed (reviewable diffs, no toolchain needed to build)
|
- [x] Generated code is committed (reviewable diffs, no toolchain needed to build)
|
||||||
|
|
||||||
**Non-functional**
|
**Non-functional**
|
||||||
- [ ] JS bundle cost of the protobuf runtime kept small — ESM, tree-shakeable
|
- [x] JS bundle cost of the protobuf runtime kept small — ESM, tree-shakeable. Measured against a budget in phase 6, where there is a bundle to measure
|
||||||
- [ ] `buf lint` and `buf breaking` (against `main`) run in CI
|
- [x] `buf lint` and `buf breaking` (against `main`) run in CI
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -39,9 +39,9 @@ Independent of phases 2-3; can be built in parallel with them.
|
|||||||
conformance-compliant JS implementation, ships ESM for tree-shaking, and produces a much
|
conformance-compliant JS implementation, ships ESM for tree-shaking, and produces a much
|
||||||
smaller browser bundle.
|
smaller browser bundle.
|
||||||
|
|
||||||
> `buf` is **not installed** in the current environment (`buf: command not found`). This does
|
> `buf` **is** installed (v1.69.0), retiring the plan-time note that it was missing. It stays
|
||||||
> not block building or running: generated code is committed, so `buf` is required only when
|
> optional for contributors either way: generated code is committed, so `buf` is required only
|
||||||
> the schema changes. Install it before step 1.
|
> when the schema changes.
|
||||||
|
|
||||||
**Framing:** every WS frame is a binary message — `ClientMessage` client→server,
|
**Framing:** every WS frame is a binary message — `ClientMessage` client→server,
|
||||||
`ServerMessage` server→client, each a single `oneof`. No JSON fallback, no text frames.
|
`ServerMessage` server→client, each a single `oneof`. No JSON fallback, no text frames.
|
||||||
@@ -153,12 +153,12 @@ message ServerMessage {
|
|||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- [ ] `make proto` regenerates both targets with zero diff on a clean tree
|
- [x] `make proto` regenerates both targets with zero diff on a clean tree
|
||||||
- [ ] `buf lint` clean; `buf breaking` wired into CI
|
- [x] `buf lint` clean; `buf breaking` wired into CI
|
||||||
- [ ] Go round-trip test covers every `oneof` variant in both directions
|
- [x] Go round-trip test covers every `oneof` variant in both directions
|
||||||
- [ ] JS decodes a Go-produced binary fixture and reads correct values
|
- [x] JS decodes a Go-produced binary fixture and reads correct values
|
||||||
- [ ] `convert.go` mapping is exhaustive; a test fails if an engine reason gains a value with no proto counterpart
|
- [x] `convert.go` mapping is exhaustive; a test fails if an engine reason gains a value with no proto counterpart
|
||||||
- [ ] No hand-written message struct exists in `server/` or `web/`
|
- [x] No hand-written message struct exists in `server/` or `web/`
|
||||||
|
|
||||||
## Risk Assessment
|
## Risk Assessment
|
||||||
|
|
||||||
@@ -168,3 +168,59 @@ message ServerMessage {
|
|||||||
| `buf` unavailable in a contributor's environment | `make proto` fails locally | Generated code is committed, so building and running never requires `buf` — only changing the schema does |
|
| `buf` unavailable in a contributor's environment | `make proto` fails locally | Generated code is committed, so building and running never requires `buf` — only changing the schema does |
|
||||||
| Protobuf runtime inflates the JS bundle | Bundle budget exceeded in phase 6 | `protobuf-es` is ESM/tree-shakeable and only the generated messages are imported; measure in phase 6 and drop to hand-rolled binary framing only if it genuinely fails the budget |
|
| Protobuf runtime inflates the JS bundle | Bundle budget exceeded in phase 6 | `protobuf-es` is ESM/tree-shakeable and only the generated messages are imported; measure in phase 6 and drop to hand-rolled binary framing only if it genuinely fails the budget |
|
||||||
| Enum drift between engine and wire | A new reject reason silently arrives as UNSPECIFIED | Exhaustive-switch test plus a logged default in `convert.go` |
|
| Enum drift between engine and wire | A new reject reason silently arrives as UNSPECIFIED | Exhaustive-switch test plus a logged default in `convert.go` |
|
||||||
|
|
||||||
|
|
||||||
|
## Phase 4 Outcome (2026-09-04)
|
||||||
|
|
||||||
|
Delivered and verified end to end. `buf lint` clean under the `STANDARD` category with no
|
||||||
|
carve-outs, regeneration byte-identical, `go test ./... -race` green across every package,
|
||||||
|
and 23 JavaScript assertions decoding bytes the Go suite produced.
|
||||||
|
|
||||||
|
**Two deviations from the sketch above, both accepted before implementation**
|
||||||
|
|
||||||
|
| Sketch | Shipped | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `TOO_FEW_SYLLABLES`, `EASY`, `END_TIMEOUT` | `REJECT_REASON_TOO_FEW_SYLLABLES`, `DIFFICULTY_EASY`, `GAME_END_REASON_TIMEOUT` | buf's `STANDARD` lint mandates the enum-name prefix. Keeping the short names would have meant an `ENUM_VALUE_PREFIX` exception, weakening "buf lint clean" to "clean with a carve-out". `protobuf-es` strips the prefix, so the JS side still reads `RejectReason.WRONG_LINK` |
|
||||||
|
| `web/` untouched until phase 6 | `web/` bootstrapped as a bare npm package | The phase's own JS codegen and Vitest cross-language test have nowhere to live otherwise. Protobuf + Vitest only — no SvelteKit, no routes, no config |
|
||||||
|
|
||||||
|
**Additions beyond the sketch**
|
||||||
|
|
||||||
|
- `PlayedWord.typed` (field 5). `game.Move` carries both the canonical spelling and the raw
|
||||||
|
input, and the UI has to show that a correction happened rather than appearing to rewrite
|
||||||
|
the player's text.
|
||||||
|
- `REJECT_REASON_GAME_OVER` (7). `game.ReasonGameOver` exists in the engine; without a wire
|
||||||
|
counterpart the exhaustiveness test fails, which is the test working.
|
||||||
|
- `wsapi.ProtocolVersion = 1`, giving `protocol_version` a defined value rather than a field
|
||||||
|
nobody sets.
|
||||||
|
|
||||||
|
**Toolchain**
|
||||||
|
|
||||||
|
Both code generators resolve from the repo's own manifests — a `tool` directive in
|
||||||
|
`server/go.mod` and `web/package.json` — so regenerating needs only `buf`, with no globally
|
||||||
|
installed plugin binaries. `buf.gen.yaml` sets `clean: true`, so the JavaScript wire test
|
||||||
|
lives in `web/tests/`, outside the generated tree that gets wiped on each run.
|
||||||
|
|
||||||
|
**Cross-language checking**
|
||||||
|
|
||||||
|
`server/internal/wsapi/wire_test.go` writes 17 binary fixtures to `proto/testdata/` under
|
||||||
|
`-update`; both suites then read those same bytes. A JS-only round trip would only have
|
||||||
|
proven that runtime self-consistent, which is exactly the failure that ships a broken client.
|
||||||
|
`.gitattributes` marks the fixtures `binary`: nine of them contain `0x0a` and no NUL, so a
|
||||||
|
checkout with `autocrlf` enabled could otherwise rewrite them into messages that no longer
|
||||||
|
decode.
|
||||||
|
|
||||||
|
**Review found four guards that reported green while protecting nothing.** All fixed and
|
||||||
|
then negative-tested by deliberately breaking each one:
|
||||||
|
|
||||||
|
| Defect | Fix |
|
||||||
|
|---|---|
|
||||||
|
| `buf breaking --against '.git#branch=main'` fails on every PR — a `pull_request` checkout is a detached HEAD with no local `main` | `.git#ref=origin/main` |
|
||||||
|
| `git diff --exit-code` never reports untracked files, so a plugin emitting a *new* file would pass the sync check | `git add --intent-to-add` first, in both CI and `make proto-check` |
|
||||||
|
| The oneof-coverage test compared the schema against a hand-written list of arm names, which drifts in lockstep with the table it duplicates | Coverage now derived from what each sample message actually sets, via `WhichOneof` |
|
||||||
|
| The exhaustiveness walk ended at `String() == "unknown"`, so a reason added without a `String` case shrank the range under test and still passed | `game.NumRejectReasons` / `game.NumEndReasons` sentinels, plus a test that every reason is named |
|
||||||
|
|
||||||
|
`WireDifficulty` was also removed: it had no caller and no message in the schema carries a
|
||||||
|
`Difficulty`, so its doc comment described a capability the contract cannot express.
|
||||||
|
|
||||||
|
**Deferred to phase 5**, where `Hello` is first read: rejecting a mismatched
|
||||||
|
`protocol_version`, and nickname sanitization behind `Welcome.accepted_nickname`.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Noi Tu Web Game"
|
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."
|
description: "Vietnamese nối từ web game — SvelteKit frontend, Go backend, WebSocket + Protobuf, server-authoritative dictionary over SQLite. Vs-bot and online 1v1."
|
||||||
status: pending
|
status: in-progress
|
||||||
priority: P1
|
priority: P1
|
||||||
effort: "~3-4w"
|
effort: "~3-4w"
|
||||||
tags: [game, sveltekit, go, websocket, protobuf, sqlite, vietnamese]
|
tags: [game, sveltekit, go, websocket, protobuf, sqlite, vietnamese]
|
||||||
@@ -129,10 +129,10 @@ added, all non-`vi` languages and all definitions/translations dropped).
|
|||||||
|
|
||||||
| # | Phase | Status | Depends on |
|
| # | Phase | Status | Depends on |
|
||||||
|---|-------|--------|-----------|
|
|---|-------|--------|-----------|
|
||||||
| 1 | [Foundations and Data Pipeline](./phase-01-foundations-and-data-pipeline.md) | Pending | — |
|
| 1 | [Foundations and Data Pipeline](./phase-01-foundations-and-data-pipeline.md) | Complete | — |
|
||||||
| 2 | [Go Dictionary and Normalization](./phase-02-go-dictionary-and-normalization.md) | Pending | 1 |
|
| 2 | [Go Dictionary and Normalization](./phase-02-go-dictionary-and-normalization.md) | Complete | 1 |
|
||||||
| 3 | [Go Game Engine and Bot AI](./phase-03-go-game-engine-and-bot-ai.md) | Pending | 2 |
|
| 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) | Pending | 1 |
|
| 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) | Pending | 3, 4 |
|
| 5 | [Go WebSocket Server and Rooms](./phase-05-go-websocket-server-and-rooms.md) | Pending | 3, 4 |
|
||||||
| 6 | [SvelteKit Frontend](./phase-06-sveltekit-frontend.md) | Pending | 4, 5 |
|
| 6 | [SvelteKit Frontend](./phase-06-sveltekit-frontend.md) | Pending | 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) | Pending | 5, 6 |
|
||||||
@@ -176,7 +176,7 @@ web/
|
|||||||
- [ ] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section, and in-app credit all present and consistent
|
- [ ] `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ý`
|
- [ ] 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
|
- [ ] Words of 2, 3, and 4 syllables all accepted and chain correctly on first↔last syllable
|
||||||
- [ ] One `.proto` generates working Go and JS clients; no hand-written message types
|
- [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
|
- [ ] 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
|
- [ ] 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
|
- [ ] Vietnamese UI throughout; dark mode toggle persists; nickname and high score persist in localStorage
|
||||||
@@ -274,6 +274,28 @@ loanwords the multilingual source tags as Vietnamese — `credit card`, `world c
|
|||||||
`come out`. Its first version wrongly rejected the entire `gì`/`gỉ`/`gìn` family by
|
`come out`. Its first version wrongly rejected the entire `gì`/`gỉ`/`gìn` family by
|
||||||
greedily matching the `gi` digraph; it backtracks over onset candidates now.
|
greedily matching the `gi` digraph; it backtracks over onset candidates now.
|
||||||
|
|
||||||
|
## Phase 4 Outcome (2026-09-04)
|
||||||
|
|
||||||
|
The wire contract is fixed and generated into both languages. Detail in
|
||||||
|
[`phase-04`](./phase-04-protobuf-contract-and-codegen.md#phase-4-outcome-2026-09-04).
|
||||||
|
|
||||||
|
Enum values carry buf's mandated enum-name prefix (`REJECT_REASON_TOO_FEW_SYLLABLES`, not
|
||||||
|
`TOO_FEW_SYLLABLES`), so `buf lint` passes with no carve-out; `protobuf-es` strips the prefix,
|
||||||
|
leaving the JS side reading `RejectReason.WRONG_LINK`. Two fields were added that the plan
|
||||||
|
sketch lacked: `PlayedWord.typed`, because the engine keeps the player's raw input alongside
|
||||||
|
the canonical spelling and the UI has to show a correction happened, and
|
||||||
|
`REJECT_REASON_GAME_OVER`, because `game.ReasonGameOver` exists and the exhaustiveness test
|
||||||
|
correctly refused a wire contract without it.
|
||||||
|
|
||||||
|
`web/` now exists as a bare npm package — protobuf and Vitest only — because phase 4's own
|
||||||
|
JS codegen and cross-language test need somewhere to live. Phase 6 layers SvelteKit on top.
|
||||||
|
|
||||||
|
Both suites read the same 17 binary fixtures in `proto/testdata/`, emitted by the Go tests.
|
||||||
|
Review caught four guards that were reporting green while protecting nothing — a `buf
|
||||||
|
breaking` baseline that cannot resolve on a PR checkout, a sync check blind to untracked
|
||||||
|
files, and two tests whose oracles could drift with the code they checked. All four were
|
||||||
|
fixed and then negative-tested by deliberately breaking each one.
|
||||||
|
|
||||||
## Open Questions
|
## 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.
|
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,335 @@
|
|||||||
|
---
|
||||||
|
title: "Code Review — Phase 4: Protobuf Contract and Codegen"
|
||||||
|
date: 2026-09-04
|
||||||
|
reviewer: code-reviewer
|
||||||
|
scope: phase-04
|
||||||
|
status: DONE_WITH_CONCERNS
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code Review — Phase 4: Protobuf Contract and Codegen
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Hand-written: `buf.yaml`, `buf.gen.yaml`, `proto/noitu/v1/game.proto` (209),
|
||||||
|
`server/internal/wsapi/convert.go` (113), `convert_test.go` (168), `wire_test.go` (289),
|
||||||
|
`web/tests/game-wire.test.js` (94), `web/package.json`, `.github/workflows/proto.yml` (77),
|
||||||
|
`Makefile`, `README.md`, `server/go.mod`/`go.sum`. ~980 LOC.
|
||||||
|
- Generated + committed: `server/gen/noitu/v1/game.pb.go` (1874),
|
||||||
|
`web/src/lib/proto/noitu/v1/game_pb.{js,d.ts}` (969). Config reviewed, style not.
|
||||||
|
- Fixtures: 17 files in `proto/testdata/`.
|
||||||
|
- Checks actually run: `go vet ./...`, `go test ./... -race`, `npm test` (23 pass),
|
||||||
|
`buf lint` (clean), `buf generate` + byte-for-byte hash comparison, plus two
|
||||||
|
reproduction experiments in a scratch git repo (see Critical #1 and Edge Case #1).
|
||||||
|
|
||||||
|
## Overall Assessment
|
||||||
|
|
||||||
|
The contract itself is good work: one schema, both targets generated from it, no
|
||||||
|
hand-written message type anywhere, an explicit engine↔wire mapping instead of a cast, and
|
||||||
|
a genuine cross-language fixture test rather than two self-consistent round trips. Comments
|
||||||
|
explain invariants and carry no plan/phase IDs, matching `game/state.go` and
|
||||||
|
`dictionary/store.go`. Phases 1-3 are untouched.
|
||||||
|
|
||||||
|
Two defects are blocking, both in the guard rails rather than the contract: `buf breaking`
|
||||||
|
cannot resolve its baseline on any pull request, and the regen-diff check cannot see a
|
||||||
|
missing generated file. Both mean a check reported as passing is not actually protecting
|
||||||
|
anything. Beyond those, one exported helper has no wire field to feed and no caller, and
|
||||||
|
`ServerError` carries two fields with identical documented meaning — cheap to fix now,
|
||||||
|
breaking to fix after a client ships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
| # | Criterion | Verdict | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| a | `make proto` regenerates both targets with zero diff | **MET** | Hashed all 3 generated files, ran `buf generate`, re-hashed: identical. |
|
||||||
|
| b | `buf lint` clean; `buf breaking` wired into CI | **PARTIAL** | `buf lint` exits 0 with no output. `buf breaking` is wired but cannot run — see Critical #1. |
|
||||||
|
| c | Go round trip covers every oneof variant, both directions | **MET** | `wire_test.go:126` `TestRoundTripEveryVariant` over 7 client + 10 server arms; `TestVariantTablesCoverEveryOneofArm` asserts arm coverage reflectively (with a gap — High #3). |
|
||||||
|
| d | JS decodes a Go-produced binary fixture, reads correct values | **MET** | `web/tests/game-wire.test.js`; 23 tests pass. Asserts UTF-8 diacritics, int64→bigint, enum numbering, empty-arm tag survival. |
|
||||||
|
| e | Mapping exhaustive; test fails on a new engine reason | **PARTIAL** | `convert_test.go:41,77` walk engine constants and also assert every wire value is reachable — good, both directions. But the walk terminates on `String() == "unknown"`, so a new constant added without a `String()` case is invisible. See High #4. |
|
||||||
|
| f | No hand-written message struct in `server/` or `web/` | **MET** | `grep "^type .* struct" server/internal/wsapi/` → none; `web/src` contains only the generated tree. |
|
||||||
|
| g | Protocol version field in the handshake | **MET (underspecified)** | `game.proto:62` `Hello.protocol_version`, `:112` `Welcome.protocol_version`. Semantics undefined — see Medium #6. |
|
||||||
|
| h | Generated code is committed | **MET** | `server/gen/`, `web/src/lib/proto/` present in the change; `.gitignore` does not exclude them. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical / MUST-FIX
|
||||||
|
|
||||||
|
### 1. `buf breaking` fails on every pull request — the check protects nothing
|
||||||
|
|
||||||
|
`.github/workflows/proto.yml:60`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
run: buf breaking --against '.git#branch=main'
|
||||||
|
```
|
||||||
|
|
||||||
|
`actions/checkout` on a `pull_request` event leaves a detached HEAD with **no local branch
|
||||||
|
`main`** — only `refs/remotes/origin/main`. buf's git input clones the local `.git` as a
|
||||||
|
remote and asks for the ref by name, so `branch=main` is not found.
|
||||||
|
|
||||||
|
Reproduced in a scratch clone (detached HEAD, local `main` deleted, `origin/main` present):
|
||||||
|
|
||||||
|
```
|
||||||
|
Failure: could not clone file://...\.git: exit status 128
|
||||||
|
fatal: couldn't find remote ref main
|
||||||
|
```
|
||||||
|
|
||||||
|
Meanwhile the guard immediately above (`:51`) probes `origin/main:proto/...`, which *does*
|
||||||
|
resolve — so the two steps disagree on how to name the baseline. Once the schema lands on
|
||||||
|
main the guard says `exists=true` and the breaking step then hard-fails every PR.
|
||||||
|
|
||||||
|
The `push` case is the mirror image: checkout creates a local `main` at the commit being
|
||||||
|
built, so `--against branch=main` compares the schema against itself and passes
|
||||||
|
unconditionally. Net effect: the check either errors or is a no-op; it never catches a break.
|
||||||
|
|
||||||
|
Fix — both forms verified working in the same scratch clone (exit 0):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
run: buf breaking --against '.git#ref=origin/main'
|
||||||
|
```
|
||||||
|
|
||||||
|
and align the guard on the same ref so the two steps cannot diverge again.
|
||||||
|
|
||||||
|
### 2. The regen-diff check cannot see a missing generated file
|
||||||
|
|
||||||
|
`.github/workflows/proto.yml:67` and `Makefile:57`
|
||||||
|
|
||||||
|
```
|
||||||
|
git diff --exit-code -- server/gen web/src/lib/proto
|
||||||
|
```
|
||||||
|
|
||||||
|
`git diff` never reports untracked files. Verified: `touch server/internal/game/zzz_fake.go`
|
||||||
|
inside a fully tracked directory, then `git diff --exit-code -- server/internal/game` → exit 0.
|
||||||
|
|
||||||
|
So the failure mode the step's own comment names ("a committed generated tree that no longer
|
||||||
|
matches the schema is worse than no generated tree at all") is only half covered. Modified
|
||||||
|
and deleted files are caught; a *new* output file is not. That is not hypothetical here — a
|
||||||
|
second `.proto`, a plugin option change that splits output, or a protoc-gen-es version that
|
||||||
|
emits an extra file all produce new untracked files and a green CI with an incomplete
|
||||||
|
committed tree, which then fails at build time in phase 6.
|
||||||
|
|
||||||
|
Fix (catches all three states):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
buf generate
|
||||||
|
git add --intent-to-add -- server/gen web/src/lib/proto
|
||||||
|
git diff --exit-code -- server/gen web/src/lib/proto
|
||||||
|
```
|
||||||
|
|
||||||
|
or `test -z "$(git status --porcelain -- server/gen web/src/lib/proto)"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High Priority
|
||||||
|
|
||||||
|
### 3. `clientNames()`/`serverNames()` are a hand-maintained duplicate of the variant tables
|
||||||
|
|
||||||
|
`wire_test.go:261,268` feed `assertCoversOneof` (`:246`), which only asserts
|
||||||
|
`proto arms ⊆ covered`. `covered` is a second hardcoded literal list, unconnected to
|
||||||
|
`clientVariants()`/`serverVariants()`. Drift path: delete an arm from the variants table,
|
||||||
|
run `-update` (which also deletes the orphan fixture), and both the fixture-count check
|
||||||
|
(`:161`) and `assertCoversOneof` still pass — that arm is now untested in both languages
|
||||||
|
while the test's own doc comment claims otherwise.
|
||||||
|
|
||||||
|
Fix: derive the covered set from the tables instead of restating it —
|
||||||
|
`m.ProtoReflect().WhichOneof(oneof).Name()` per variant. Removes 12 lines and closes the hole.
|
||||||
|
|
||||||
|
### 4. The exhaustiveness guard depends on `String()`, not on the constant set
|
||||||
|
|
||||||
|
`convert_test.go:15-37` enumerate engine reasons by incrementing until
|
||||||
|
`String() == "unknown"`. If a contributor adds `ReasonFoo` to the `iota` block but forgets
|
||||||
|
the `String()` case — the same edit, one file, easy to half-do — the walk stops *before*
|
||||||
|
`ReasonFoo`, both exhaustiveness tests pass, and `convert.go` silently returns
|
||||||
|
`REJECT_REASON_UNSPECIFIED` at runtime. This is exactly the failure criterion (e) exists to
|
||||||
|
prevent, guarded by a proxy rather than by the thing itself.
|
||||||
|
|
||||||
|
Fix: add a sentinel to the engine (a trailing `reasonCount` in the `iota` block, and
|
||||||
|
`endReasonCount` likewise) and assert `len(engineRejectReasons()) == int(game.ReasonCount)`.
|
||||||
|
Then a missing `String()` case fails loudly instead of shrinking the test's domain.
|
||||||
|
|
||||||
|
### 5. `WireDifficulty` has no caller and no wire field to carry its result
|
||||||
|
|
||||||
|
`convert.go:85-99`. Its comment says it exists "so a server that picked or clamped a
|
||||||
|
difficulty can report what it actually used" — but no server→client message has a
|
||||||
|
`Difficulty` field. `grep WireDifficulty` outside `internal/wsapi` → no hits; its only
|
||||||
|
consumer is `convert_test.go:110`. Exported API that exists to satisfy its own test.
|
||||||
|
|
||||||
|
Pick one: add `Difficulty difficulty = 7;` to `GameStarted` (additive, free now, and the
|
||||||
|
client genuinely needs it to label a resumed bot game), or delete `WireDifficulty` and its
|
||||||
|
test. Do not leave it as-is — the comment documents a capability the schema cannot express.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium Priority
|
||||||
|
|
||||||
|
### 6. `protocol_version` has no defined semantics and no constant
|
||||||
|
|
||||||
|
`game.proto:62,112`. A bare `uint32` with no statement of the current value or whether it is
|
||||||
|
a major, a major.minor packed integer, or a monotonic counter. The phase requirement is
|
||||||
|
"server rejects mismatched **majors**", which a single field cannot express without a stated
|
||||||
|
convention. Right now `1` appears only as a literal in `wire_test.go:31,59`.
|
||||||
|
|
||||||
|
Since a version field's meaning is part of the contract, pin it in the schema comment now
|
||||||
|
("incompatible-change counter; bump only when an old client can no longer be served; current
|
||||||
|
value 1"). Phase 5 can then define `const ProtocolVersion = 1` against a written rule instead
|
||||||
|
of inventing one.
|
||||||
|
|
||||||
|
### 7. `ServerError.code` and `ServerError.message` are documented as the same thing
|
||||||
|
|
||||||
|
`game.proto:184-189`. The comment says `message` is "a UI key such as `room_not_found`, never
|
||||||
|
prose", and `code` is a code — two fields, one semantic, and the fixture sets both to
|
||||||
|
`"room_not_found"` (`wire_test.go:110`). The field *named* `message` will attract prose in
|
||||||
|
phase 5 (that is what the name means everywhere else), and prose on this path is the classic
|
||||||
|
internal-detail leak to an untrusted consumer.
|
||||||
|
|
||||||
|
Removing a field after a client ships is the breaking change this whole phase exists to
|
||||||
|
avoid, so resolve it now: either drop `message` and keep `code` alone, or give the two
|
||||||
|
distinct roles (`code` = machine-stable identifier; `message` = optional structured detail,
|
||||||
|
explicitly never rendered) and say so in the comment.
|
||||||
|
|
||||||
|
### 8. No message can restore a resumed game
|
||||||
|
|
||||||
|
`Hello.resume_token` exists, and the plan's success criteria require "reconnect within grace
|
||||||
|
window restores the game". Nothing in `ServerMessage` carries the word history, the scores,
|
||||||
|
or the current position as a snapshot: `GameStarted` has only the opening word and
|
||||||
|
`TurnUpdate` only the last move. A reconnecting client can decode everything it receives and
|
||||||
|
still render an empty board. Also missing: an `OpponentReconnected` counterpart to
|
||||||
|
`OpponentLeft`.
|
||||||
|
|
||||||
|
Both are purely additive, so this is not a schema defect today — but phase 5/7 will need
|
||||||
|
them, and adding them while the fixture tables are already open is cheaper than a second pass.
|
||||||
|
|
||||||
|
### 9. `log.Printf` from an internal package is a new convention
|
||||||
|
|
||||||
|
`convert.go:44,64,96`. Before this change, `log` appears only in `cmd/build-dictionary`
|
||||||
|
(a `main` package); `internal/{game,bot,dictionary,vietnamese}` return errors and never log.
|
||||||
|
The global logger in a request-path library is unsilenceable, has no request context, and is
|
||||||
|
awkward to assert on in a test. The intent (an unmapped value is a bug, so make noise) is
|
||||||
|
right; the mechanism should be `slog` or an injected logger, decided in phase 5 when the
|
||||||
|
server acquires a logging story. Worth deciding before four more packages copy this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low Priority
|
||||||
|
|
||||||
|
10. **`-update` deletes before it writes.** `wire_test.go:204` removes every `*.bin`, then
|
||||||
|
`:210` writes them. A `t.Fatalf` on marshal or write leaves the fixture directory empty
|
||||||
|
or half-populated *and* the test failing — recoverable via git, but noisy. Write to a
|
||||||
|
temp dir and rename, or delete only names not in `allVariants()`.
|
||||||
|
11. **`go test ./... -update` does not work.** The flag is defined only in the `wsapi` test
|
||||||
|
binary; every other package's binary rejects it. README correctly documents
|
||||||
|
`go test ./internal/wsapi -update`; worth a one-line note on the flag itself.
|
||||||
|
12. **`make web` is broken.** `Makefile:66` runs `npm run build` and `web/package.json` has
|
||||||
|
no `build` script, while `README.md` advertises the target. Pre-existing, and phase 6
|
||||||
|
fixes it, but the diff touched this target without noticing.
|
||||||
|
13. **`clean: true` in `buf.gen.yaml` wipes whole output directories.** Fine today; in phase 6
|
||||||
|
a hand-written `web/src/lib/proto/index.js` barrel would be silently deleted by
|
||||||
|
`make proto`. Worth a comment next to the `out:` lines.
|
||||||
|
14. **No `gofmt` check in CI**, and `proto.yml` runs only `go test ./internal/wsapi/...` —
|
||||||
|
the repo now has exactly one workflow, so phases 1-3 have no CI coverage at all. The
|
||||||
|
dictionary tests do not need `data/noitu.db` (verified: `data/` holds only the licence
|
||||||
|
files, and the full suite passes locally without it), so broadening to
|
||||||
|
`go vet ./... && go test ./... -race` is free.
|
||||||
|
15. **No `buf.lock`.** The phase file listed one under "Create". Correct to omit — `buf.yaml`
|
||||||
|
declares no `deps`, so buf generates no lock. No action; noted so it is not read as missing.
|
||||||
|
16. **No `concurrency:` group** in the workflow; superseded PR runs keep burning minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases Scouted
|
||||||
|
|
||||||
|
1. **CRLF mangling of the binary fixtures — checked, not a defect.** `core.autocrlf=true` is
|
||||||
|
set both globally and in this repo, there is no `.gitattributes`, and 15 of the 17
|
||||||
|
fixtures contain no NUL byte while 9 contain `0x0a`. That is the shape of a file git
|
||||||
|
converts. Empirically it does not: committed all 17 into a scratch repo with
|
||||||
|
`autocrlf=true` and re-cloned — `git ls-files --eol` reports `i/-text w/-text` for all 17
|
||||||
|
(a control `plain.txt` in the same repo came back CRLF), and every SHA-256 matched. Git's
|
||||||
|
heuristic counts the UTF-8 continuation bytes and protobuf tag bytes as non-printable and
|
||||||
|
classifies the files binary. **NICE-TO-HAVE:** add `proto/testdata/*.bin binary` to a
|
||||||
|
`.gitattributes` anyway — the classification is content-dependent, so a future all-ASCII
|
||||||
|
fixture could flip to text and be corrupted silently on a Windows checkout.
|
||||||
|
2. **Will the workflow pass on the PR that first introduces `proto/`?** Yes. The baseline
|
||||||
|
guard (`:51`) finds no schema on `origin/main` and skips the breaking step; `buf lint` is
|
||||||
|
clean; the regen check has the generated trees tracked within that same PR so it compares
|
||||||
|
correctly; both test suites pass. The failure begins on the *second* PR — see Critical #1.
|
||||||
|
3. **Fixture drift in the other direction.** The scheme is sound for value and field changes:
|
||||||
|
a stale fixture fails `proto.Equal` in Go, and the JS assertions are independent hardcoded
|
||||||
|
values, so regenerating fixtures without updating the JS test fails the JS suite. The only
|
||||||
|
silent path is the one in High #3.
|
||||||
|
4. **`Difficulty()` fails safe on hostile input.** Returns `(0, false)`; `bot.Difficulty(0)`
|
||||||
|
is not a valid strategy and `bot.New` rejects it, so even a caller that ignores `ok` cannot
|
||||||
|
be handed a default opponent. Correct, and correctly justified in the comment.
|
||||||
|
5. **Enum renumbering resistance.** Every mapping in `convert.go` switches on symbols, so
|
||||||
|
reordering the engine's `iota` blocks cannot change a wire value. This is the property the
|
||||||
|
phase set out to buy, and it holds.
|
||||||
|
6. **int64 as bigint in JS.** `deadline_unix_ms` / `client_time_ms` surface as `bigint`, which
|
||||||
|
the test asserts deliberately. Phase 6 will need `Number(...)` at every countdown site;
|
||||||
|
`[jstype = JS_NUMBER]` on those fields is an option if that becomes noisy. Informational.
|
||||||
|
|
||||||
|
## Enum Mapping Correctness (explicit check)
|
||||||
|
|
||||||
|
All 8 `game.RejectReason` and all 4 `game.EndReason` constants are mapped; no wire value is
|
||||||
|
produced by two engine values; no non-transport wire value is unreachable; `ReasonNone` and
|
||||||
|
`EndNone` land on UNSPECIFIED by design and are excluded from the reachability assertion.
|
||||||
|
`GAME_END_REASON_OPPONENT_LEFT` is correctly allow-listed as transport-only. Nothing is
|
||||||
|
mis-mapped and nothing collapses to UNSPECIFIED where it would be wrong at runtime. The one
|
||||||
|
soft spot is the *guard*, not the mapping — High #4.
|
||||||
|
|
||||||
|
## Regression Check (phases 1-3)
|
||||||
|
|
||||||
|
- `git status --porcelain`: only `Makefile`, `README.md`, `server/go.mod`, `server/go.sum`
|
||||||
|
modified. No file under `server/internal/{game,bot,dictionary,vietnamese}` or
|
||||||
|
`server/cmd/build-dictionary` changed. No public contract touched.
|
||||||
|
- `go.mod`: `+ google.golang.org/protobuf v1.36.12` (direct) and
|
||||||
|
`tool google.golang.org/protobuf/cmd/protoc-gen-go`. No existing require changed, nothing
|
||||||
|
moved between direct and indirect. `go 1.25.0` satisfies the 1.24+ `tool` requirement.
|
||||||
|
- `go.sum`: 4 added lines only (`protobuf`, and `go-cmp` as its test dependency). No existing
|
||||||
|
hash altered. `tool` directives are main-module-only, so no dependent inherits protoc-gen-go.
|
||||||
|
- `go vet ./...` clean; `go test ./... -race` all packages ok.
|
||||||
|
- Accepted deviations (buf STANDARD enum prefixes; minimal npm `web/`) introduce no
|
||||||
|
inconsistency elsewhere: the JS `tsEnum` strips the prefix, so `RejectReason.WRONG_LINK`
|
||||||
|
reads the same on both sides, and the plan/phase files' old unprefixed names appear only in
|
||||||
|
historical plan prose, not in code.
|
||||||
|
|
||||||
|
## Recommended Actions
|
||||||
|
|
||||||
|
1. Fix the `buf breaking` baseline (`.git#ref=origin/main`) and align the guard step on the
|
||||||
|
same ref. **Blocking** — currently the check is inert or red on every PR.
|
||||||
|
2. Make the regen-diff check see untracked files (`git add --intent-to-add`, or
|
||||||
|
`git status --porcelain`), in both `proto.yml:67` and `Makefile:57`. **Blocking.**
|
||||||
|
3. Derive `clientNames()`/`serverNames()` from the variant tables via `WhichOneof`.
|
||||||
|
4. Add reason-count sentinels to the engine and assert against them, so the exhaustiveness
|
||||||
|
guard stops depending on `String()`.
|
||||||
|
5. Decide `WireDifficulty`: add `GameStarted.difficulty`, or delete the helper.
|
||||||
|
6. Resolve `ServerError.code` vs `.message` before anything ships against this schema.
|
||||||
|
7. Document what `protocol_version` counts and what its current value is, in the schema.
|
||||||
|
8. Decide whether the resume snapshot / `OpponentReconnected` messages land now or in phase 5.
|
||||||
|
9. Low-priority sweep: `.gitattributes` for `*.bin`, broaden CI to `go vet ./... && go test ./...`,
|
||||||
|
add a `concurrency:` group, note the `make web` gap for phase 6.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
- `buf lint`: clean, zero carve-outs, `STANDARD` category, no `//buf:lint:ignore` anywhere.
|
||||||
|
- `go vet ./...`: clean. `go test ./... -race`: all packages pass.
|
||||||
|
- `internal/wsapi` statement coverage: **80.0%** (the uncovered statements are the three
|
||||||
|
unreachable-by-design `log.Printf` default branches and their returns).
|
||||||
|
- `npm test`: 23/23 pass (1 file). No JS lint or typecheck configured yet (phase 6).
|
||||||
|
- `buf generate` reproducibility: byte-identical on all 3 generated files.
|
||||||
|
- Hand-written message structs: 0.
|
||||||
|
|
||||||
|
## Plan Status
|
||||||
|
|
||||||
|
Phase 4 implementation steps 1-7 are all present. Success criteria: 5 met, 3 partial (b, e,
|
||||||
|
and the CI half of step 7). I have not edited the plan file — recommend the lead mark phase 4
|
||||||
|
complete only after Critical #1 and #2, since both success criteria that reference CI are
|
||||||
|
currently unenforced.
|
||||||
|
|
||||||
|
## Unresolved Questions
|
||||||
|
|
||||||
|
1. `ServerError.message` — is it meant to be a second UI key, structured telemetry detail, or
|
||||||
|
was it left over from the sketch? The answer decides whether the field survives, and it
|
||||||
|
has to be answered before a client ships against this schema.
|
||||||
|
2. Does the resume flow (phase 5/7) get a state-snapshot message, or will `GameStarted` be
|
||||||
|
overloaded with history and scores? Both are additive; picking now avoids a second fixture pass.
|
||||||
|
3. Should `proto.yml` become the repo's general CI (covering the phase 1-3 suites), or will a
|
||||||
|
separate workflow land later? Right now nothing else in the repo is tested in CI.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// The nối từ wire contract.
|
||||||
|
//
|
||||||
|
// This file is the single source of truth for everything crossing the
|
||||||
|
// WebSocket. Both the Go server and the JavaScript client are generated from
|
||||||
|
// it; neither side hand-writes a message type. Every frame is a binary
|
||||||
|
// protobuf message — there is no JSON fallback and no text frame.
|
||||||
|
//
|
||||||
|
// Compatibility rules, because a deployed client outlives a deploy:
|
||||||
|
// - changes are additive only;
|
||||||
|
// - a removed field's tag goes to `reserved`, never to another field;
|
||||||
|
// - Hello.protocol_version lets the server refuse an incompatible client
|
||||||
|
// with a readable error instead of failing to decode.
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package noitu.v1;
|
||||||
|
|
||||||
|
option go_package = "github.com/tiennm99dev/noitu/server/gen/noitu/v1;noituv1";
|
||||||
|
|
||||||
|
// Difficulty selects the bot's strategy in a vs-bot game.
|
||||||
|
enum Difficulty {
|
||||||
|
DIFFICULTY_UNSPECIFIED = 0;
|
||||||
|
DIFFICULTY_EASY = 1;
|
||||||
|
DIFFICULTY_MEDIUM = 2;
|
||||||
|
DIFFICULTY_HARD = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectReason says why a submitted word was not accepted. It is deliberately
|
||||||
|
// a separate type from the engine's internal game.RejectReason: the wire
|
||||||
|
// contract must not change every time the engine is refactored. The mapping
|
||||||
|
// lives in server/internal/wsapi/convert.go.
|
||||||
|
enum RejectReason {
|
||||||
|
REJECT_REASON_UNSPECIFIED = 0;
|
||||||
|
// Fewer than two syllables.
|
||||||
|
REJECT_REASON_TOO_FEW_SYLLABLES = 1;
|
||||||
|
// First syllable does not match the required syllable.
|
||||||
|
REJECT_REASON_WRONG_LINK = 2;
|
||||||
|
REJECT_REASON_NOT_IN_DICTIONARY = 3;
|
||||||
|
REJECT_REASON_ALREADY_USED = 4;
|
||||||
|
REJECT_REASON_NOT_YOUR_TURN = 5;
|
||||||
|
// The turn deadline passed before the word arrived.
|
||||||
|
REJECT_REASON_TIMEOUT = 6;
|
||||||
|
// The game had already finished.
|
||||||
|
REJECT_REASON_GAME_OVER = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameEndReason says how a finished game ended.
|
||||||
|
enum GameEndReason {
|
||||||
|
GAME_END_REASON_UNSPECIFIED = 0;
|
||||||
|
GAME_END_REASON_TIMEOUT = 1;
|
||||||
|
GAME_END_REASON_NO_LEGAL_MOVE = 2;
|
||||||
|
GAME_END_REASON_OPPONENT_LEFT = 3;
|
||||||
|
GAME_END_REASON_RESIGNED = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Client -> server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Hello opens a session. nickname is a *request*: the server sanitizes it and
|
||||||
|
// reports what it actually stored in Welcome.accepted_nickname.
|
||||||
|
message Hello {
|
||||||
|
uint32 protocol_version = 1;
|
||||||
|
// Empty on a fresh session; a token from a previous Welcome to resume one.
|
||||||
|
string resume_token = 2;
|
||||||
|
string nickname = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StartBotGame {
|
||||||
|
Difficulty difficulty = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateRoom {}
|
||||||
|
|
||||||
|
message JoinRoom {
|
||||||
|
string room_code = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitWord carries the turn it was typed for. The server rejects a
|
||||||
|
// turn_seq that is not the current one, which makes a double-submit or a
|
||||||
|
// submission racing the timeout detectable rather than silently applied.
|
||||||
|
message SubmitWord {
|
||||||
|
string word = 1;
|
||||||
|
uint32 turn_seq = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Resign {}
|
||||||
|
|
||||||
|
// Ping echoes the client clock so Pong can expose the offset between the two.
|
||||||
|
message Ping {
|
||||||
|
int64 client_time_ms = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClientMessage {
|
||||||
|
oneof payload {
|
||||||
|
Hello hello = 1;
|
||||||
|
StartBotGame start_bot_game = 2;
|
||||||
|
CreateRoom create_room = 3;
|
||||||
|
JoinRoom join_room = 4;
|
||||||
|
SubmitWord submit_word = 5;
|
||||||
|
Resign resign = 6;
|
||||||
|
Ping ping = 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server -> client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
message Welcome {
|
||||||
|
string session_id = 1;
|
||||||
|
string resume_token = 2;
|
||||||
|
uint32 protocol_version = 3;
|
||||||
|
// What the server stored after sanitizing Hello.nickname. The client must
|
||||||
|
// display this, not the string it sent.
|
||||||
|
string accepted_nickname = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RoomCreated {
|
||||||
|
string room_code = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RoomJoined {
|
||||||
|
string room_code = 1;
|
||||||
|
// Always server-sanitized. A client must never render another player's raw
|
||||||
|
// input.
|
||||||
|
string opponent_name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayedWord is one accepted move. word is the canonical spelling, which can
|
||||||
|
// differ from what the player typed; typed preserves the raw input so the UI
|
||||||
|
// can show that a correction happened instead of silently rewriting the text.
|
||||||
|
message PlayedWord {
|
||||||
|
string word = 1;
|
||||||
|
bool by_me = 2;
|
||||||
|
uint32 points = 3;
|
||||||
|
uint32 syllables = 4;
|
||||||
|
string typed = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameStarted is rendered per recipient: my_turn is true for exactly one of
|
||||||
|
// the two players.
|
||||||
|
message GameStarted {
|
||||||
|
string opening_word = 1;
|
||||||
|
string current_syllable = 2;
|
||||||
|
bool my_turn = 3;
|
||||||
|
// Absolute server timestamp. The client counts down to it and never trusts
|
||||||
|
// its own clock for authority.
|
||||||
|
int64 deadline_unix_ms = 4;
|
||||||
|
uint32 turn_seq = 5;
|
||||||
|
uint32 turn_limit_ms = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TurnUpdate follows every accepted move and goes to both players, serialized
|
||||||
|
// once per recipient so by_me and my_turn are correct for each.
|
||||||
|
message TurnUpdate {
|
||||||
|
PlayedWord played = 1;
|
||||||
|
string current_syllable = 2;
|
||||||
|
bool my_turn = 3;
|
||||||
|
int64 deadline_unix_ms = 4;
|
||||||
|
uint32 turn_seq = 5;
|
||||||
|
uint32 my_score = 6;
|
||||||
|
uint32 opponent_score = 7;
|
||||||
|
uint32 chain_length = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MoveRejected {
|
||||||
|
RejectReason reason = 1;
|
||||||
|
string word = 2;
|
||||||
|
uint32 turn_seq = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GameOver {
|
||||||
|
bool i_won = 1;
|
||||||
|
GameEndReason reason = 2;
|
||||||
|
uint32 my_score = 3;
|
||||||
|
uint32 chain_length = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpponentLeft {
|
||||||
|
bool can_reconnect = 1;
|
||||||
|
uint32 grace_ms = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerError.message is a UI key such as "room_not_found", never prose: all
|
||||||
|
// Vietnamese copy lives in the frontend so it stays in one place.
|
||||||
|
message ServerError {
|
||||||
|
string code = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Pong {
|
||||||
|
int64 client_time_ms = 1;
|
||||||
|
int64 server_time_ms = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ServerMessage {
|
||||||
|
oneof payload {
|
||||||
|
Welcome welcome = 1;
|
||||||
|
RoomCreated room_created = 2;
|
||||||
|
RoomJoined room_joined = 3;
|
||||||
|
GameStarted game_started = 4;
|
||||||
|
TurnUpdate turn_update = 5;
|
||||||
|
MoveRejected move_rejected = 6;
|
||||||
|
GameOver game_over = 7;
|
||||||
|
OpponentLeft opponent_left = 8;
|
||||||
|
ServerError error = 9;
|
||||||
|
Pong pong = 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
$r-8f2cNgười chơi ẩn danh
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
"
|
||||||
|
K7QX
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
:Ш╩В╘▒3
|
||||||
Vendored
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
|
||||||
|
hoĂ bình’!
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
J
|
||||||
|
room_not_foundroom_not_found
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
:
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
""
|
||||||
|
|
||||||
|
hòa bìnhbình ×ø©‘3(0 œ
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
2cà phê
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
B國
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
RШ╩В╘▒3х╬В╘▒3
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
K7QX
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
K7QXThuý
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
*4
|
||||||
|
|
||||||
|
|
||||||
|
bình yên * binh yênyên Àóù©‘3(08@
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
.
|
||||||
|
s-1a2b3cr-8f2c"Người chơi ẩn danh
|
||||||
Generated
+1874
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/text v0.41.0
|
golang.org/x/text v0.41.0
|
||||||
|
google.golang.org/protobuf v1.36.12
|
||||||
modernc.org/sqlite v1.58.0
|
modernc.org/sqlite v1.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,3 +19,5 @@ require (
|
|||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.1 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tool google.golang.org/protobuf/cmd/protoc-gen-go
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -22,6 +24,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
|||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ const (
|
|||||||
ReasonAlreadyUsed
|
ReasonAlreadyUsed
|
||||||
ReasonTimeout
|
ReasonTimeout
|
||||||
ReasonGameOver
|
ReasonGameOver
|
||||||
|
|
||||||
|
// NumRejectReasons is one past the last defined reason.
|
||||||
|
//
|
||||||
|
// It exists so a transport layer can prove it maps every reason without
|
||||||
|
// guessing where the list ends. Deriving that from String returning
|
||||||
|
// "unknown" would be circular: a reason added without a String case would
|
||||||
|
// shrink the range being checked and the check would still pass.
|
||||||
|
NumRejectReasons
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r RejectReason) String() string {
|
func (r RejectReason) String() string {
|
||||||
@@ -89,6 +97,10 @@ const (
|
|||||||
EndTimeout
|
EndTimeout
|
||||||
EndNoLegalMove
|
EndNoLegalMove
|
||||||
EndResigned
|
EndResigned
|
||||||
|
|
||||||
|
// NumEndReasons is one past the last defined end reason. See
|
||||||
|
// NumRejectReasons for why the count is a constant rather than a walk.
|
||||||
|
NumEndReasons
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r EndReason) String() string {
|
func (r EndReason) String() string {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Package wsapi is the transport layer: it translates between the game engine
|
||||||
|
// and the generated protobuf wire types, and (from phase 5 on) owns the
|
||||||
|
// WebSocket sessions and rooms built on top of them.
|
||||||
|
//
|
||||||
|
// The engine's reason enums and the wire enums are deliberately distinct
|
||||||
|
// types. Renumbering an internal constant must never silently change what a
|
||||||
|
// deployed client decodes, so every crossing goes through an explicit switch
|
||||||
|
// here rather than a cast.
|
||||||
|
package wsapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||||
|
"github.com/tiennm99dev/noitu/server/internal/bot"
|
||||||
|
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProtocolVersion is the version this build speaks, sent in Welcome and
|
||||||
|
// expected in Hello.
|
||||||
|
//
|
||||||
|
// It is a whole-protocol number, not a per-message one: the wire contract only
|
||||||
|
// ever grows by addition, so a client and server that agree on this integer
|
||||||
|
// agree on everything they both know how to decode. Bump it when a change
|
||||||
|
// would make an older client misread a frame rather than merely ignore an
|
||||||
|
// unknown field.
|
||||||
|
const ProtocolVersion uint32 = 1
|
||||||
|
|
||||||
|
// RejectReason maps an engine rejection onto the wire enum.
|
||||||
|
//
|
||||||
|
// game.ReasonNone means the move was accepted and so has no wire counterpart;
|
||||||
|
// it maps to UNSPECIFIED, which callers must not put in a MoveRejected. Any
|
||||||
|
// other unmapped value is a bug — a reason was added to the engine without
|
||||||
|
// extending the contract — so it is logged rather than quietly flattened.
|
||||||
|
func RejectReason(r game.RejectReason) noituv1.RejectReason {
|
||||||
|
switch r {
|
||||||
|
case game.ReasonNone:
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_UNSPECIFIED
|
||||||
|
case game.ReasonNotYourTurn:
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN
|
||||||
|
case game.ReasonTooFewSyllables:
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_TOO_FEW_SYLLABLES
|
||||||
|
case game.ReasonNotInDictionary:
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY
|
||||||
|
case game.ReasonWrongLink:
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_WRONG_LINK
|
||||||
|
case game.ReasonAlreadyUsed:
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_ALREADY_USED
|
||||||
|
case game.ReasonTimeout:
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_TIMEOUT
|
||||||
|
case game.ReasonGameOver:
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_GAME_OVER
|
||||||
|
}
|
||||||
|
log.Printf("wsapi: no wire mapping for game.RejectReason(%d) %q", int(r), r)
|
||||||
|
return noituv1.RejectReason_REJECT_REASON_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndReason maps an engine end condition onto the wire enum.
|
||||||
|
//
|
||||||
|
// GAME_END_REASON_OPPONENT_LEFT has no engine counterpart on purpose: a player
|
||||||
|
// disconnecting is a transport event, not a rule, so the room emits that value
|
||||||
|
// directly and the engine never learns about it.
|
||||||
|
func EndReason(r game.EndReason) noituv1.GameEndReason {
|
||||||
|
switch r {
|
||||||
|
case game.EndNone:
|
||||||
|
return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
|
||||||
|
case game.EndTimeout:
|
||||||
|
return noituv1.GameEndReason_GAME_END_REASON_TIMEOUT
|
||||||
|
case game.EndNoLegalMove:
|
||||||
|
return noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE
|
||||||
|
case game.EndResigned:
|
||||||
|
return noituv1.GameEndReason_GAME_END_REASON_RESIGNED
|
||||||
|
}
|
||||||
|
log.Printf("wsapi: no wire mapping for game.EndReason(%d) %q", int(r), r)
|
||||||
|
return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difficulty maps a wire difficulty onto a bot strategy selector.
|
||||||
|
//
|
||||||
|
// This one runs client to server, so an unrecognized value is untrusted input
|
||||||
|
// rather than a bug: it is not logged, and the caller decides what to do with
|
||||||
|
// the false.
|
||||||
|
func Difficulty(d noituv1.Difficulty) (bot.Difficulty, bool) {
|
||||||
|
switch d {
|
||||||
|
case noituv1.Difficulty_DIFFICULTY_EASY:
|
||||||
|
return bot.Easy, true
|
||||||
|
case noituv1.Difficulty_DIFFICULTY_MEDIUM:
|
||||||
|
return bot.Medium, true
|
||||||
|
case noituv1.Difficulty_DIFFICULTY_HARD:
|
||||||
|
return bot.Hard, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// There is deliberately no server-to-client difficulty mapping: no message in
|
||||||
|
// the schema carries a Difficulty, so a client learns the difficulty only from
|
||||||
|
// the one it asked for.
|
||||||
|
|
||||||
|
// PlayedWord renders an accepted move for one recipient.
|
||||||
|
//
|
||||||
|
// byMe is the caller's business: the same move is sent to both players and
|
||||||
|
// only this flag differs, so the room serializes one message per player rather
|
||||||
|
// than broadcasting a single shared frame.
|
||||||
|
func PlayedWord(m game.Move, byMe bool) *noituv1.PlayedWord {
|
||||||
|
return &noituv1.PlayedWord{
|
||||||
|
Word: m.Word,
|
||||||
|
Typed: m.Typed,
|
||||||
|
ByMe: byMe,
|
||||||
|
Points: uint32(m.Points),
|
||||||
|
Syllables: uint32(m.Syllables),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package wsapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||||
|
"github.com/tiennm99dev/noitu/server/internal/bot"
|
||||||
|
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||||
|
"google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// engineRejectReasons enumerates the engine's constants from its own count
|
||||||
|
// sentinel.
|
||||||
|
//
|
||||||
|
// The sentinel is what makes this honest. Walking until String returns
|
||||||
|
// "unknown" would be circular — a reason added without a String case would
|
||||||
|
// shorten the range under test and every assertion below would still pass on
|
||||||
|
// a value that has no mapping.
|
||||||
|
func engineRejectReasons() []game.RejectReason {
|
||||||
|
out := make([]game.RejectReason, 0, game.NumRejectReasons)
|
||||||
|
for i := game.RejectReason(0); i < game.NumRejectReasons; i++ {
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func engineEndReasons() []game.EndReason {
|
||||||
|
out := make([]game.EndReason, 0, game.NumEndReasons)
|
||||||
|
for i := game.EndReason(0); i < game.NumEndReasons; i++ {
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEveryEngineReasonIsNamed catches the other half of the same problem: a
|
||||||
|
// constant added inside the block but not to String. Such a value maps and
|
||||||
|
// logs as "unknown", which is unreadable in exactly the situation the log
|
||||||
|
// exists for.
|
||||||
|
func TestEveryEngineReasonIsNamed(t *testing.T) {
|
||||||
|
for _, r := range engineRejectReasons() {
|
||||||
|
if r.String() == "unknown" {
|
||||||
|
t.Errorf("game.RejectReason(%d) has no String case", int(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range engineEndReasons() {
|
||||||
|
if r.String() == "unknown" {
|
||||||
|
t.Errorf("game.EndReason(%d) has no String case", int(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRejectReasonMappingIsExhaustive fails if the engine gains a rejection
|
||||||
|
// with no wire counterpart. Only ReasonNone is allowed to land on UNSPECIFIED:
|
||||||
|
// it means "accepted", so it never travels inside a MoveRejected.
|
||||||
|
func TestRejectReasonMappingIsExhaustive(t *testing.T) {
|
||||||
|
reasons := engineRejectReasons()
|
||||||
|
if len(reasons) < 2 {
|
||||||
|
t.Fatalf("enumerated %d engine reject reasons, expected the full set", len(reasons))
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[noituv1.RejectReason]game.RejectReason, len(reasons))
|
||||||
|
for _, r := range reasons {
|
||||||
|
got := RejectReason(r)
|
||||||
|
if r == game.ReasonNone {
|
||||||
|
if got != noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
|
||||||
|
t.Errorf("ReasonNone should map to UNSPECIFIED, got %v", got)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got == noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
|
||||||
|
t.Errorf("game.RejectReason(%d) %q has no wire mapping", int(r), r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prev, dup := seen[got]; dup {
|
||||||
|
t.Errorf("%v is produced by both %q and %q; a wire value must identify one reason", got, prev, r)
|
||||||
|
}
|
||||||
|
seen[got] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every wire value must also be reachable. An unreachable one is a contract
|
||||||
|
// the server can never honour, which a client would be right to handle.
|
||||||
|
for _, v := range enumValues(noituv1.RejectReason_REJECT_REASON_UNSPECIFIED.Descriptor()) {
|
||||||
|
w := noituv1.RejectReason(v)
|
||||||
|
if w == noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[w]; !ok {
|
||||||
|
t.Errorf("wire value %v is unreachable: no engine reason maps to it", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEndReasonMappingIsExhaustive mirrors the reject-reason walk.
|
||||||
|
// GAME_END_REASON_OPPONENT_LEFT is the one deliberate gap: a disconnect is a
|
||||||
|
// transport event the engine has no concept of, so the room emits it directly.
|
||||||
|
func TestEndReasonMappingIsExhaustive(t *testing.T) {
|
||||||
|
seen := make(map[noituv1.GameEndReason]game.EndReason)
|
||||||
|
for _, r := range engineEndReasons() {
|
||||||
|
got := EndReason(r)
|
||||||
|
if r == game.EndNone {
|
||||||
|
if got != noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED {
|
||||||
|
t.Errorf("EndNone should map to UNSPECIFIED, got %v", got)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got == noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED {
|
||||||
|
t.Errorf("game.EndReason(%d) %q has no wire mapping", int(r), r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prev, dup := seen[got]; dup {
|
||||||
|
t.Errorf("%v is produced by both %q and %q", got, prev, r)
|
||||||
|
}
|
||||||
|
seen[got] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
transportOnly := map[noituv1.GameEndReason]bool{
|
||||||
|
noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT: true,
|
||||||
|
}
|
||||||
|
for _, v := range enumValues(noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED.Descriptor()) {
|
||||||
|
w := noituv1.GameEndReason(v)
|
||||||
|
if w == noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED || transportOnly[w] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[w]; !ok {
|
||||||
|
t.Errorf("wire value %v is unreachable: no engine reason maps to it", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDifficultyMapping checks each level and, more importantly, that an
|
||||||
|
// unknown wire difficulty is refused rather than defaulted. Difficulty arrives
|
||||||
|
// from the client, so silently treating garbage as Easy would let a malformed
|
||||||
|
// frame choose the opponent.
|
||||||
|
func TestDifficultyMapping(t *testing.T) {
|
||||||
|
want := map[noituv1.Difficulty]bot.Difficulty{
|
||||||
|
noituv1.Difficulty_DIFFICULTY_EASY: bot.Easy,
|
||||||
|
noituv1.Difficulty_DIFFICULTY_MEDIUM: bot.Medium,
|
||||||
|
noituv1.Difficulty_DIFFICULTY_HARD: bot.Hard,
|
||||||
|
}
|
||||||
|
for wire, expect := range want {
|
||||||
|
got, ok := Difficulty(wire)
|
||||||
|
if !ok || got != expect {
|
||||||
|
t.Errorf("Difficulty(%v) = (%v, %v), want (%v, true)", wire, got, ok, expect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every declared level must be reachable, so adding one to the schema
|
||||||
|
// without teaching the server about it fails here rather than at runtime.
|
||||||
|
for _, v := range enumValues(noituv1.Difficulty_DIFFICULTY_UNSPECIFIED.Descriptor()) {
|
||||||
|
d := noituv1.Difficulty(v)
|
||||||
|
if d == noituv1.Difficulty_DIFFICULTY_UNSPECIFIED {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := want[d]; !ok {
|
||||||
|
t.Errorf("wire difficulty %v has no bot strategy", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []noituv1.Difficulty{
|
||||||
|
noituv1.Difficulty_DIFFICULTY_UNSPECIFIED,
|
||||||
|
noituv1.Difficulty(99),
|
||||||
|
} {
|
||||||
|
if _, ok := Difficulty(bad); ok {
|
||||||
|
t.Errorf("Difficulty(%v) accepted an unknown wire value", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlayedWordKeepsTypedInput guards the correction-visibility rule: the UI
|
||||||
|
// has to be able to show that canonicalization changed the player's text, so
|
||||||
|
// the raw input must survive onto the wire.
|
||||||
|
func TestPlayedWordKeepsTypedInput(t *testing.T) {
|
||||||
|
m := game.Move{Word: "hòa bình", Typed: "hoà bình", Syllables: 2, Points: 2}
|
||||||
|
|
||||||
|
mine := PlayedWord(m, true)
|
||||||
|
if mine.GetWord() != "hòa bình" || mine.GetTyped() != "hoà bình" {
|
||||||
|
t.Errorf("canonical/typed pair lost: word=%q typed=%q", mine.GetWord(), mine.GetTyped())
|
||||||
|
}
|
||||||
|
if !mine.GetByMe() || mine.GetSyllables() != 2 || mine.GetPoints() != 2 {
|
||||||
|
t.Errorf("unexpected rendering: %+v", mine)
|
||||||
|
}
|
||||||
|
if theirs := PlayedWord(m, false); theirs.GetByMe() {
|
||||||
|
t.Error("by_me must be false for the opponent's copy of the same move")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumValues(d protoreflect.EnumDescriptor) []protoreflect.EnumNumber {
|
||||||
|
vals := d.Values()
|
||||||
|
out := make([]protoreflect.EnumNumber, 0, vals.Len())
|
||||||
|
for i := 0; i < vals.Len(); i++ {
|
||||||
|
out = append(out, vals.Get(i).Number())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package wsapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -update rewrites the committed cross-language fixtures. The JS suite decodes
|
||||||
|
// the same bytes, so regenerating them is the deliberate act of changing the
|
||||||
|
// wire contract, not a side effect of running the Go tests.
|
||||||
|
var update = flag.Bool("update", false, "rewrite the cross-language fixtures in proto/testdata")
|
||||||
|
|
||||||
|
const fixtureDir = "../../../proto/testdata"
|
||||||
|
|
||||||
|
// clientVariants covers every arm of ClientMessage.payload.
|
||||||
|
//
|
||||||
|
// The values are not minimal on purpose: Vietnamese text with diacritics, a
|
||||||
|
// large turn_seq and a real millisecond timestamp all exercise encodings a
|
||||||
|
// zero value would skip — UTF-8, varint width, and int64, which the JavaScript
|
||||||
|
// runtime surfaces as a bigint.
|
||||||
|
func clientVariants() map[string]*noituv1.ClientMessage {
|
||||||
|
return map[string]*noituv1.ClientMessage{
|
||||||
|
"client_hello": {Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
|
||||||
|
ProtocolVersion: 1,
|
||||||
|
ResumeToken: "r-8f2c",
|
||||||
|
Nickname: "Người chơi ẩn danh",
|
||||||
|
}}},
|
||||||
|
"client_start_bot_game": {Payload: &noituv1.ClientMessage_StartBotGame{StartBotGame: &noituv1.StartBotGame{
|
||||||
|
Difficulty: noituv1.Difficulty_DIFFICULTY_HARD,
|
||||||
|
}}},
|
||||||
|
"client_create_room": {Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}},
|
||||||
|
"client_join_room": {Payload: &noituv1.ClientMessage_JoinRoom{JoinRoom: &noituv1.JoinRoom{
|
||||||
|
RoomCode: "K7QX",
|
||||||
|
}}},
|
||||||
|
"client_submit_word": {Payload: &noituv1.ClientMessage_SubmitWord{SubmitWord: &noituv1.SubmitWord{
|
||||||
|
Word: "hoà bình",
|
||||||
|
TurnSeq: 4242,
|
||||||
|
}}},
|
||||||
|
"client_resign": {Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}},
|
||||||
|
"client_ping": {Payload: &noituv1.ClientMessage_Ping{Ping: &noituv1.Ping{
|
||||||
|
ClientTimeMs: 1756998000123,
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverVariants covers every arm of ServerMessage.payload.
|
||||||
|
func serverVariants() map[string]*noituv1.ServerMessage {
|
||||||
|
return map[string]*noituv1.ServerMessage{
|
||||||
|
"server_welcome": {Payload: &noituv1.ServerMessage_Welcome{Welcome: &noituv1.Welcome{
|
||||||
|
SessionId: "s-1a2b3c",
|
||||||
|
ResumeToken: "r-8f2c",
|
||||||
|
ProtocolVersion: 1,
|
||||||
|
AcceptedNickname: "Người chơi ẩn danh",
|
||||||
|
}}},
|
||||||
|
"server_room_created": {Payload: &noituv1.ServerMessage_RoomCreated{RoomCreated: &noituv1.RoomCreated{
|
||||||
|
RoomCode: "K7QX",
|
||||||
|
}}},
|
||||||
|
"server_room_joined": {Payload: &noituv1.ServerMessage_RoomJoined{RoomJoined: &noituv1.RoomJoined{
|
||||||
|
RoomCode: "K7QX",
|
||||||
|
OpponentName: "Thuý",
|
||||||
|
}}},
|
||||||
|
"server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{
|
||||||
|
OpeningWord: "hòa bình",
|
||||||
|
CurrentSyllable: "bình",
|
||||||
|
MyTurn: true,
|
||||||
|
DeadlineUnixMs: 1756998020000,
|
||||||
|
TurnSeq: 1,
|
||||||
|
TurnLimitMs: 20000,
|
||||||
|
}}},
|
||||||
|
"server_turn_update": {Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: &noituv1.TurnUpdate{
|
||||||
|
Played: &noituv1.PlayedWord{
|
||||||
|
Word: "bình yên",
|
||||||
|
Typed: "binh yên",
|
||||||
|
ByMe: false,
|
||||||
|
Points: 2,
|
||||||
|
Syllables: 2,
|
||||||
|
},
|
||||||
|
CurrentSyllable: "yên",
|
||||||
|
MyTurn: true,
|
||||||
|
DeadlineUnixMs: 1756998040000,
|
||||||
|
TurnSeq: 2,
|
||||||
|
MyScore: 3,
|
||||||
|
OpponentScore: 5,
|
||||||
|
ChainLength: 2,
|
||||||
|
}}},
|
||||||
|
"server_move_rejected": {Payload: &noituv1.ServerMessage_MoveRejected{MoveRejected: &noituv1.MoveRejected{
|
||||||
|
Reason: noituv1.RejectReason_REJECT_REASON_WRONG_LINK,
|
||||||
|
Word: "cà phê",
|
||||||
|
TurnSeq: 2,
|
||||||
|
}}},
|
||||||
|
"server_game_over": {Payload: &noituv1.ServerMessage_GameOver{GameOver: &noituv1.GameOver{
|
||||||
|
IWon: false,
|
||||||
|
Reason: noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE,
|
||||||
|
MyScore: 7,
|
||||||
|
ChainLength: 11,
|
||||||
|
}}},
|
||||||
|
"server_opponent_left": {Payload: &noituv1.ServerMessage_OpponentLeft{OpponentLeft: &noituv1.OpponentLeft{
|
||||||
|
CanReconnect: true,
|
||||||
|
GraceMs: 30000,
|
||||||
|
}}},
|
||||||
|
"server_error": {Payload: &noituv1.ServerMessage_Error{Error: &noituv1.ServerError{
|
||||||
|
Code: "room_not_found",
|
||||||
|
Message: "room_not_found",
|
||||||
|
}}},
|
||||||
|
"server_pong": {Payload: &noituv1.ServerMessage_Pong{Pong: &noituv1.Pong{
|
||||||
|
ClientTimeMs: 1756998000123,
|
||||||
|
ServerTimeMs: 1756998000456,
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRoundTripEveryVariant marshals and unmarshals each oneof arm. Equality
|
||||||
|
// alone is not enough: an empty arm such as CreateRoom encodes to a payload of
|
||||||
|
// zero bytes, so checking that the case survived is what proves the arm is
|
||||||
|
// distinguishable on the wire at all.
|
||||||
|
func TestRoundTripEveryVariant(t *testing.T) {
|
||||||
|
for name, msg := range clientVariants() {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
var got noituv1.ClientMessage
|
||||||
|
roundTrip(t, msg, &got)
|
||||||
|
if got.GetPayload() == nil {
|
||||||
|
t.Fatal("payload case lost in round trip")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for name, msg := range serverVariants() {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
var got noituv1.ServerMessage
|
||||||
|
roundTrip(t, msg, &got)
|
||||||
|
if got.GetPayload() == nil {
|
||||||
|
t.Fatal("payload case lost in round trip")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVariantTablesCoverEveryOneofArm keeps the tables above honest: adding a
|
||||||
|
// message to either oneof without adding a case here fails the tests rather
|
||||||
|
// than shipping an untested arm.
|
||||||
|
func TestVariantTablesCoverEveryOneofArm(t *testing.T) {
|
||||||
|
client := make([]proto.Message, 0, len(clientVariants()))
|
||||||
|
for _, m := range clientVariants() {
|
||||||
|
client = append(client, m)
|
||||||
|
}
|
||||||
|
server := make([]proto.Message, 0, len(serverVariants()))
|
||||||
|
for _, m := range serverVariants() {
|
||||||
|
server = append(server, m)
|
||||||
|
}
|
||||||
|
assertCoversOneof(t, "ClientMessage", client)
|
||||||
|
assertCoversOneof(t, "ServerMessage", server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCrossLanguageFixtures asserts the committed bytes still decode to the
|
||||||
|
// messages above. The JavaScript suite reads the same files, so the two
|
||||||
|
// languages are checked against one artifact rather than against each other's
|
||||||
|
// assumptions.
|
||||||
|
func TestCrossLanguageFixtures(t *testing.T) {
|
||||||
|
if *update {
|
||||||
|
writeFixtures(t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
want := allVariants()
|
||||||
|
files := mustGlob(t)
|
||||||
|
if len(files) != len(want) {
|
||||||
|
t.Errorf("fixture count %d does not match variant count %d; run: go test ./internal/wsapi -update", len(files), len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
name := stem(f)
|
||||||
|
expect, ok := want[name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("stale fixture %s has no matching variant", f)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(want, name)
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read %s: %v", f, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got := expect.ProtoReflect().New().Interface()
|
||||||
|
if err := proto.Unmarshal(raw, got); err != nil {
|
||||||
|
t.Errorf("decode %s: %v", f, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !proto.Equal(got, expect) {
|
||||||
|
t.Errorf("%s decoded to %v, want %v", f, got, expect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range want {
|
||||||
|
t.Errorf("no fixture for variant %q; run: go test ./internal/wsapi -update", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFixtures(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("create fixture dir: %v", err)
|
||||||
|
}
|
||||||
|
// Remove first, so a renamed variant leaves no orphan behind for the JS
|
||||||
|
// suite to decode against a schema that no longer describes it.
|
||||||
|
for _, f := range mustGlob(t) {
|
||||||
|
if err := os.Remove(f); err != nil {
|
||||||
|
t.Fatalf("remove %s: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all := allVariants()
|
||||||
|
for name, m := range all {
|
||||||
|
raw, err := proto.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(fixtureDir, name+".bin"), raw, 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("wrote %d fixtures to %s", len(all), fixtureDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allVariants() map[string]proto.Message {
|
||||||
|
all := map[string]proto.Message{}
|
||||||
|
for name, m := range clientVariants() {
|
||||||
|
all[name] = m
|
||||||
|
}
|
||||||
|
for name, m := range serverVariants() {
|
||||||
|
all[name] = m
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTrip(t *testing.T, in, out proto.Message) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := proto.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if err := proto.Unmarshal(raw, out); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if !proto.Equal(out, in) {
|
||||||
|
t.Fatalf("round trip changed the message:\n got %v\nwant %v", out, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertCoversOneof reads which arm each sample actually set, rather than
|
||||||
|
// trusting a list of names written alongside the table. A hardcoded list is
|
||||||
|
// the thing most likely to be updated in lockstep with the table and so to
|
||||||
|
// agree with it while both drift away from the schema.
|
||||||
|
func assertCoversOneof(t *testing.T, msg string, samples []proto.Message) {
|
||||||
|
t.Helper()
|
||||||
|
if len(samples) == 0 {
|
||||||
|
t.Fatalf("%s has no samples", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
covered := map[protoreflect.Name]bool{}
|
||||||
|
for _, s := range samples {
|
||||||
|
m := s.ProtoReflect()
|
||||||
|
oneof := m.Descriptor().Oneofs().ByName("payload")
|
||||||
|
if oneof == nil {
|
||||||
|
t.Fatalf("%s has no payload oneof", msg)
|
||||||
|
}
|
||||||
|
set := m.WhichOneof(oneof)
|
||||||
|
if set == nil {
|
||||||
|
t.Errorf("%s sample %v sets no payload arm", msg, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
covered[set.Name()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := samples[0].ProtoReflect().Descriptor().Oneofs().ByName("payload").Fields()
|
||||||
|
for i := 0; i < fields.Len(); i++ {
|
||||||
|
if f := fields.Get(i).Name(); !covered[f] {
|
||||||
|
t.Errorf("%s.payload arm %q is not in the variant table", msg, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustGlob(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
files, err := filepath.Glob(filepath.Join(fixtureDir, "*.bin"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("glob fixtures: %v", err)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func stem(path string) string {
|
||||||
|
base := filepath.Base(path)
|
||||||
|
return base[:len(base)-len(filepath.Ext(base))]
|
||||||
|
}
|
||||||
Generated
+1677
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "noitu-web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.14.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@bufbuild/protoc-gen-es": "^2.14.1",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
+759
@@ -0,0 +1,759 @@
|
|||||||
|
// The nối từ wire contract.
|
||||||
|
//
|
||||||
|
// This file is the single source of truth for everything crossing the
|
||||||
|
// WebSocket. Both the Go server and the JavaScript client are generated from
|
||||||
|
// it; neither side hand-writes a message type. Every frame is a binary
|
||||||
|
// protobuf message — there is no JSON fallback and no text frame.
|
||||||
|
//
|
||||||
|
// Compatibility rules, because a deployed client outlives a deploy:
|
||||||
|
// - changes are additive only;
|
||||||
|
// - a removed field's tag goes to `reserved`, never to another field;
|
||||||
|
// - Hello.protocol_version lets the server refuse an incompatible client
|
||||||
|
// with a readable error instead of failing to decode.
|
||||||
|
|
||||||
|
// @generated by protoc-gen-es v2.14.1 with parameter "target=js+dts,import_extension=.js"
|
||||||
|
// @generated from file noitu/v1/game.proto (package noitu.v1, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import type { Message } from "@bufbuild/protobuf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the file noitu/v1/game.proto.
|
||||||
|
*/
|
||||||
|
export declare const file_noitu_v1_game: GenFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hello opens a session. nickname is a *request*: the server sanitizes it and
|
||||||
|
* reports what it actually stored in Welcome.accepted_nickname.
|
||||||
|
*
|
||||||
|
* @generated from message noitu.v1.Hello
|
||||||
|
*/
|
||||||
|
export declare type Hello = Message<"noitu.v1.Hello"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 protocol_version = 1;
|
||||||
|
*/
|
||||||
|
protocolVersion: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty on a fresh session; a token from a previous Welcome to resume one.
|
||||||
|
*
|
||||||
|
* @generated from field: string resume_token = 2;
|
||||||
|
*/
|
||||||
|
resumeToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string nickname = 3;
|
||||||
|
*/
|
||||||
|
nickname: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Hello.
|
||||||
|
* Use `create(HelloSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const HelloSchema: GenMessage<Hello>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.StartBotGame
|
||||||
|
*/
|
||||||
|
export declare type StartBotGame = Message<"noitu.v1.StartBotGame"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.Difficulty difficulty = 1;
|
||||||
|
*/
|
||||||
|
difficulty: Difficulty;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.StartBotGame.
|
||||||
|
* Use `create(StartBotGameSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const StartBotGameSchema: GenMessage<StartBotGame>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.CreateRoom
|
||||||
|
*/
|
||||||
|
export declare type CreateRoom = Message<"noitu.v1.CreateRoom"> & {
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.CreateRoom.
|
||||||
|
* Use `create(CreateRoomSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const CreateRoomSchema: GenMessage<CreateRoom>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.JoinRoom
|
||||||
|
*/
|
||||||
|
export declare type JoinRoom = Message<"noitu.v1.JoinRoom"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string room_code = 1;
|
||||||
|
*/
|
||||||
|
roomCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.JoinRoom.
|
||||||
|
* Use `create(JoinRoomSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const JoinRoomSchema: GenMessage<JoinRoom>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubmitWord carries the turn it was typed for. The server rejects a
|
||||||
|
* turn_seq that is not the current one, which makes a double-submit or a
|
||||||
|
* submission racing the timeout detectable rather than silently applied.
|
||||||
|
*
|
||||||
|
* @generated from message noitu.v1.SubmitWord
|
||||||
|
*/
|
||||||
|
export declare type SubmitWord = Message<"noitu.v1.SubmitWord"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string word = 1;
|
||||||
|
*/
|
||||||
|
word: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 turn_seq = 2;
|
||||||
|
*/
|
||||||
|
turnSeq: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.SubmitWord.
|
||||||
|
* Use `create(SubmitWordSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const SubmitWordSchema: GenMessage<SubmitWord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.Resign
|
||||||
|
*/
|
||||||
|
export declare type Resign = Message<"noitu.v1.Resign"> & {
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Resign.
|
||||||
|
* Use `create(ResignSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const ResignSchema: GenMessage<Resign>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping echoes the client clock so Pong can expose the offset between the two.
|
||||||
|
*
|
||||||
|
* @generated from message noitu.v1.Ping
|
||||||
|
*/
|
||||||
|
export declare type Ping = Message<"noitu.v1.Ping"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 client_time_ms = 1;
|
||||||
|
*/
|
||||||
|
clientTimeMs: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Ping.
|
||||||
|
* Use `create(PingSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const PingSchema: GenMessage<Ping>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.ClientMessage
|
||||||
|
*/
|
||||||
|
export declare type ClientMessage = Message<"noitu.v1.ClientMessage"> & {
|
||||||
|
/**
|
||||||
|
* @generated from oneof noitu.v1.ClientMessage.payload
|
||||||
|
*/
|
||||||
|
payload: {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.Hello hello = 1;
|
||||||
|
*/
|
||||||
|
value: Hello;
|
||||||
|
case: "hello";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.StartBotGame start_bot_game = 2;
|
||||||
|
*/
|
||||||
|
value: StartBotGame;
|
||||||
|
case: "startBotGame";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.CreateRoom create_room = 3;
|
||||||
|
*/
|
||||||
|
value: CreateRoom;
|
||||||
|
case: "createRoom";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.JoinRoom join_room = 4;
|
||||||
|
*/
|
||||||
|
value: JoinRoom;
|
||||||
|
case: "joinRoom";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.SubmitWord submit_word = 5;
|
||||||
|
*/
|
||||||
|
value: SubmitWord;
|
||||||
|
case: "submitWord";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.Resign resign = 6;
|
||||||
|
*/
|
||||||
|
value: Resign;
|
||||||
|
case: "resign";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.Ping ping = 7;
|
||||||
|
*/
|
||||||
|
value: Ping;
|
||||||
|
case: "ping";
|
||||||
|
} | { case: undefined; value?: undefined };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.ClientMessage.
|
||||||
|
* Use `create(ClientMessageSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const ClientMessageSchema: GenMessage<ClientMessage>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.Welcome
|
||||||
|
*/
|
||||||
|
export declare type Welcome = Message<"noitu.v1.Welcome"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string session_id = 1;
|
||||||
|
*/
|
||||||
|
sessionId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string resume_token = 2;
|
||||||
|
*/
|
||||||
|
resumeToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 protocol_version = 3;
|
||||||
|
*/
|
||||||
|
protocolVersion: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the server stored after sanitizing Hello.nickname. The client must
|
||||||
|
* display this, not the string it sent.
|
||||||
|
*
|
||||||
|
* @generated from field: string accepted_nickname = 4;
|
||||||
|
*/
|
||||||
|
acceptedNickname: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Welcome.
|
||||||
|
* Use `create(WelcomeSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const WelcomeSchema: GenMessage<Welcome>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.RoomCreated
|
||||||
|
*/
|
||||||
|
export declare type RoomCreated = Message<"noitu.v1.RoomCreated"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string room_code = 1;
|
||||||
|
*/
|
||||||
|
roomCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.RoomCreated.
|
||||||
|
* Use `create(RoomCreatedSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const RoomCreatedSchema: GenMessage<RoomCreated>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.RoomJoined
|
||||||
|
*/
|
||||||
|
export declare type RoomJoined = Message<"noitu.v1.RoomJoined"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string room_code = 1;
|
||||||
|
*/
|
||||||
|
roomCode: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always server-sanitized. A client must never render another player's raw
|
||||||
|
* input.
|
||||||
|
*
|
||||||
|
* @generated from field: string opponent_name = 2;
|
||||||
|
*/
|
||||||
|
opponentName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.RoomJoined.
|
||||||
|
* Use `create(RoomJoinedSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const RoomJoinedSchema: GenMessage<RoomJoined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayedWord is one accepted move. word is the canonical spelling, which can
|
||||||
|
* differ from what the player typed; typed preserves the raw input so the UI
|
||||||
|
* can show that a correction happened instead of silently rewriting the text.
|
||||||
|
*
|
||||||
|
* @generated from message noitu.v1.PlayedWord
|
||||||
|
*/
|
||||||
|
export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string word = 1;
|
||||||
|
*/
|
||||||
|
word: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bool by_me = 2;
|
||||||
|
*/
|
||||||
|
byMe: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 points = 3;
|
||||||
|
*/
|
||||||
|
points: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 syllables = 4;
|
||||||
|
*/
|
||||||
|
syllables: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string typed = 5;
|
||||||
|
*/
|
||||||
|
typed: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.PlayedWord.
|
||||||
|
* Use `create(PlayedWordSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const PlayedWordSchema: GenMessage<PlayedWord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameStarted is rendered per recipient: my_turn is true for exactly one of
|
||||||
|
* the two players.
|
||||||
|
*
|
||||||
|
* @generated from message noitu.v1.GameStarted
|
||||||
|
*/
|
||||||
|
export declare type GameStarted = Message<"noitu.v1.GameStarted"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string opening_word = 1;
|
||||||
|
*/
|
||||||
|
openingWord: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string current_syllable = 2;
|
||||||
|
*/
|
||||||
|
currentSyllable: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bool my_turn = 3;
|
||||||
|
*/
|
||||||
|
myTurn: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute server timestamp. The client counts down to it and never trusts
|
||||||
|
* its own clock for authority.
|
||||||
|
*
|
||||||
|
* @generated from field: int64 deadline_unix_ms = 4;
|
||||||
|
*/
|
||||||
|
deadlineUnixMs: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 turn_seq = 5;
|
||||||
|
*/
|
||||||
|
turnSeq: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 turn_limit_ms = 6;
|
||||||
|
*/
|
||||||
|
turnLimitMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.GameStarted.
|
||||||
|
* Use `create(GameStartedSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const GameStartedSchema: GenMessage<GameStarted>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TurnUpdate follows every accepted move and goes to both players, serialized
|
||||||
|
* once per recipient so by_me and my_turn are correct for each.
|
||||||
|
*
|
||||||
|
* @generated from message noitu.v1.TurnUpdate
|
||||||
|
*/
|
||||||
|
export declare type TurnUpdate = Message<"noitu.v1.TurnUpdate"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.PlayedWord played = 1;
|
||||||
|
*/
|
||||||
|
played?: PlayedWord | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string current_syllable = 2;
|
||||||
|
*/
|
||||||
|
currentSyllable: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bool my_turn = 3;
|
||||||
|
*/
|
||||||
|
myTurn: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 deadline_unix_ms = 4;
|
||||||
|
*/
|
||||||
|
deadlineUnixMs: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 turn_seq = 5;
|
||||||
|
*/
|
||||||
|
turnSeq: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 my_score = 6;
|
||||||
|
*/
|
||||||
|
myScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 opponent_score = 7;
|
||||||
|
*/
|
||||||
|
opponentScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 chain_length = 8;
|
||||||
|
*/
|
||||||
|
chainLength: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.TurnUpdate.
|
||||||
|
* Use `create(TurnUpdateSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const TurnUpdateSchema: GenMessage<TurnUpdate>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.MoveRejected
|
||||||
|
*/
|
||||||
|
export declare type MoveRejected = Message<"noitu.v1.MoveRejected"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.RejectReason reason = 1;
|
||||||
|
*/
|
||||||
|
reason: RejectReason;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string word = 2;
|
||||||
|
*/
|
||||||
|
word: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 turn_seq = 3;
|
||||||
|
*/
|
||||||
|
turnSeq: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.MoveRejected.
|
||||||
|
* Use `create(MoveRejectedSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const MoveRejectedSchema: GenMessage<MoveRejected>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.GameOver
|
||||||
|
*/
|
||||||
|
export declare type GameOver = Message<"noitu.v1.GameOver"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: bool i_won = 1;
|
||||||
|
*/
|
||||||
|
iWon: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.GameEndReason reason = 2;
|
||||||
|
*/
|
||||||
|
reason: GameEndReason;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 my_score = 3;
|
||||||
|
*/
|
||||||
|
myScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 chain_length = 4;
|
||||||
|
*/
|
||||||
|
chainLength: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.GameOver.
|
||||||
|
* Use `create(GameOverSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const GameOverSchema: GenMessage<GameOver>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.OpponentLeft
|
||||||
|
*/
|
||||||
|
export declare type OpponentLeft = Message<"noitu.v1.OpponentLeft"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: bool can_reconnect = 1;
|
||||||
|
*/
|
||||||
|
canReconnect: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 grace_ms = 2;
|
||||||
|
*/
|
||||||
|
graceMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.OpponentLeft.
|
||||||
|
* Use `create(OpponentLeftSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const OpponentLeftSchema: GenMessage<OpponentLeft>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServerError.message is a UI key such as "room_not_found", never prose: all
|
||||||
|
* Vietnamese copy lives in the frontend so it stays in one place.
|
||||||
|
*
|
||||||
|
* @generated from message noitu.v1.ServerError
|
||||||
|
*/
|
||||||
|
export declare type ServerError = Message<"noitu.v1.ServerError"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string code = 1;
|
||||||
|
*/
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string message = 2;
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.ServerError.
|
||||||
|
* Use `create(ServerErrorSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const ServerErrorSchema: GenMessage<ServerError>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.Pong
|
||||||
|
*/
|
||||||
|
export declare type Pong = Message<"noitu.v1.Pong"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 client_time_ms = 1;
|
||||||
|
*/
|
||||||
|
clientTimeMs: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 server_time_ms = 2;
|
||||||
|
*/
|
||||||
|
serverTimeMs: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Pong.
|
||||||
|
* Use `create(PongSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const PongSchema: GenMessage<Pong>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message noitu.v1.ServerMessage
|
||||||
|
*/
|
||||||
|
export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & {
|
||||||
|
/**
|
||||||
|
* @generated from oneof noitu.v1.ServerMessage.payload
|
||||||
|
*/
|
||||||
|
payload: {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.Welcome welcome = 1;
|
||||||
|
*/
|
||||||
|
value: Welcome;
|
||||||
|
case: "welcome";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.RoomCreated room_created = 2;
|
||||||
|
*/
|
||||||
|
value: RoomCreated;
|
||||||
|
case: "roomCreated";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.RoomJoined room_joined = 3;
|
||||||
|
*/
|
||||||
|
value: RoomJoined;
|
||||||
|
case: "roomJoined";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.GameStarted game_started = 4;
|
||||||
|
*/
|
||||||
|
value: GameStarted;
|
||||||
|
case: "gameStarted";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.TurnUpdate turn_update = 5;
|
||||||
|
*/
|
||||||
|
value: TurnUpdate;
|
||||||
|
case: "turnUpdate";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.MoveRejected move_rejected = 6;
|
||||||
|
*/
|
||||||
|
value: MoveRejected;
|
||||||
|
case: "moveRejected";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.GameOver game_over = 7;
|
||||||
|
*/
|
||||||
|
value: GameOver;
|
||||||
|
case: "gameOver";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.OpponentLeft opponent_left = 8;
|
||||||
|
*/
|
||||||
|
value: OpponentLeft;
|
||||||
|
case: "opponentLeft";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.ServerError error = 9;
|
||||||
|
*/
|
||||||
|
value: ServerError;
|
||||||
|
case: "error";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: noitu.v1.Pong pong = 10;
|
||||||
|
*/
|
||||||
|
value: Pong;
|
||||||
|
case: "pong";
|
||||||
|
} | { case: undefined; value?: undefined };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.ServerMessage.
|
||||||
|
* Use `create(ServerMessageSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export declare const ServerMessageSchema: GenMessage<ServerMessage>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Difficulty selects the bot's strategy in a vs-bot game.
|
||||||
|
*
|
||||||
|
* @generated from enum noitu.v1.Difficulty
|
||||||
|
*/
|
||||||
|
export enum Difficulty {
|
||||||
|
/**
|
||||||
|
* @generated from enum value: DIFFICULTY_UNSPECIFIED = 0;
|
||||||
|
*/
|
||||||
|
UNSPECIFIED = 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: DIFFICULTY_EASY = 1;
|
||||||
|
*/
|
||||||
|
EASY = 1,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: DIFFICULTY_MEDIUM = 2;
|
||||||
|
*/
|
||||||
|
MEDIUM = 2,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: DIFFICULTY_HARD = 3;
|
||||||
|
*/
|
||||||
|
HARD = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the enum noitu.v1.Difficulty.
|
||||||
|
*/
|
||||||
|
export declare const DifficultySchema: GenEnum<Difficulty>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RejectReason says why a submitted word was not accepted. It is deliberately
|
||||||
|
* a separate type from the engine's internal game.RejectReason: the wire
|
||||||
|
* contract must not change every time the engine is refactored. The mapping
|
||||||
|
* lives in server/internal/wsapi/convert.go.
|
||||||
|
*
|
||||||
|
* @generated from enum noitu.v1.RejectReason
|
||||||
|
*/
|
||||||
|
export enum RejectReason {
|
||||||
|
/**
|
||||||
|
* @generated from enum value: REJECT_REASON_UNSPECIFIED = 0;
|
||||||
|
*/
|
||||||
|
UNSPECIFIED = 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fewer than two syllables.
|
||||||
|
*
|
||||||
|
* @generated from enum value: REJECT_REASON_TOO_FEW_SYLLABLES = 1;
|
||||||
|
*/
|
||||||
|
TOO_FEW_SYLLABLES = 1,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First syllable does not match the required syllable.
|
||||||
|
*
|
||||||
|
* @generated from enum value: REJECT_REASON_WRONG_LINK = 2;
|
||||||
|
*/
|
||||||
|
WRONG_LINK = 2,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: REJECT_REASON_NOT_IN_DICTIONARY = 3;
|
||||||
|
*/
|
||||||
|
NOT_IN_DICTIONARY = 3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: REJECT_REASON_ALREADY_USED = 4;
|
||||||
|
*/
|
||||||
|
ALREADY_USED = 4,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: REJECT_REASON_NOT_YOUR_TURN = 5;
|
||||||
|
*/
|
||||||
|
NOT_YOUR_TURN = 5,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The turn deadline passed before the word arrived.
|
||||||
|
*
|
||||||
|
* @generated from enum value: REJECT_REASON_TIMEOUT = 6;
|
||||||
|
*/
|
||||||
|
TIMEOUT = 6,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The game had already finished.
|
||||||
|
*
|
||||||
|
* @generated from enum value: REJECT_REASON_GAME_OVER = 7;
|
||||||
|
*/
|
||||||
|
GAME_OVER = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the enum noitu.v1.RejectReason.
|
||||||
|
*/
|
||||||
|
export declare const RejectReasonSchema: GenEnum<RejectReason>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameEndReason says how a finished game ended.
|
||||||
|
*
|
||||||
|
* @generated from enum noitu.v1.GameEndReason
|
||||||
|
*/
|
||||||
|
export enum GameEndReason {
|
||||||
|
/**
|
||||||
|
* @generated from enum value: GAME_END_REASON_UNSPECIFIED = 0;
|
||||||
|
*/
|
||||||
|
UNSPECIFIED = 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: GAME_END_REASON_TIMEOUT = 1;
|
||||||
|
*/
|
||||||
|
TIMEOUT = 1,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: GAME_END_REASON_NO_LEGAL_MOVE = 2;
|
||||||
|
*/
|
||||||
|
NO_LEGAL_MOVE = 2,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: GAME_END_REASON_OPPONENT_LEFT = 3;
|
||||||
|
*/
|
||||||
|
OPPONENT_LEFT = 3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from enum value: GAME_END_REASON_RESIGNED = 4;
|
||||||
|
*/
|
||||||
|
RESIGNED = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the enum noitu.v1.GameEndReason.
|
||||||
|
*/
|
||||||
|
export declare const GameEndReasonSchema: GenEnum<GameEndReason>;
|
||||||
|
|
||||||
Generated
+210
@@ -0,0 +1,210 @@
|
|||||||
|
// The nối từ wire contract.
|
||||||
|
//
|
||||||
|
// This file is the single source of truth for everything crossing the
|
||||||
|
// WebSocket. Both the Go server and the JavaScript client are generated from
|
||||||
|
// it; neither side hand-writes a message type. Every frame is a binary
|
||||||
|
// protobuf message — there is no JSON fallback and no text frame.
|
||||||
|
//
|
||||||
|
// Compatibility rules, because a deployed client outlives a deploy:
|
||||||
|
// - changes are additive only;
|
||||||
|
// - a removed field's tag goes to `reserved`, never to another field;
|
||||||
|
// - Hello.protocol_version lets the server refuse an incompatible client
|
||||||
|
// with a readable error instead of failing to decode.
|
||||||
|
|
||||||
|
// @generated by protoc-gen-es v2.14.1 with parameter "target=js+dts,import_extension=.js"
|
||||||
|
// @generated from file noitu/v1/game.proto (package noitu.v1, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import { enumDesc, fileDesc, messageDesc, tsEnum } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the file noitu/v1/game.proto.
|
||||||
|
*/
|
||||||
|
export const file_noitu_v1_game = /*@__PURE__*/
|
||||||
|
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIrUCCg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIAEIJCgdwYXlsb2FkImgKB1dlbGNvbWUSEgoKc2Vzc2lvbl9pZBgBIAEoCRIUCgxyZXN1bWVfdG9rZW4YAiABKAkSGAoQcHJvdG9jb2xfdmVyc2lvbhgDIAEoDRIZChFhY2NlcHRlZF9uaWNrbmFtZRgEIAEoCSIgCgtSb29tQ3JlYXRlZBIRCglyb29tX2NvZGUYASABKAkiNgoKUm9vbUpvaW5lZBIRCglyb29tX2NvZGUYASABKAkSFQoNb3Bwb25lbnRfbmFtZRgCIAEoCSJbCgpQbGF5ZWRXb3JkEgwKBHdvcmQYASABKAkSDQoFYnlfbWUYAiABKAgSDgoGcG9pbnRzGAMgASgNEhEKCXN5bGxhYmxlcxgEIAEoDRINCgV0eXBlZBgFIAEoCSKRAQoLR2FtZVN0YXJ0ZWQSFAoMb3BlbmluZ193b3JkGAEgASgJEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhUKDXR1cm5fbGltaXRfbXMYBiABKA0iyQEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIQCghteV9zY29yZRgGIAEoDRIWCg5vcHBvbmVudF9zY29yZRgHIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0iVgoMTW92ZVJlamVjdGVkEiYKBnJlYXNvbhgBIAEoDjIWLm5vaXR1LnYxLlJlamVjdFJlYXNvbhIMCgR3b3JkGAIgASgJEhAKCHR1cm5fc2VxGAMgASgNImoKCEdhbWVPdmVyEg0KBWlfd29uGAEgASgIEicKBnJlYXNvbhgCIAEoDjIXLm5vaXR1LnYxLkdhbWVFbmRSZWFzb24SEAoIbXlfc2NvcmUYAyABKA0SFAoMY2hhaW5fbGVuZ3RoGAQgASgNIjcKDE9wcG9uZW50TGVmdBIVCg1jYW5fcmVjb25uZWN0GAEgASgIEhAKCGdyYWNlX21zGAIgASgNIiwKC1NlcnZlckVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSI2CgRQb25nEhYKDmNsaWVudF90aW1lX21zGAEgASgDEhYKDnNlcnZlcl90aW1lX21zGAIgASgDIssDCg1TZXJ2ZXJNZXNzYWdlEiQKB3dlbGNvbWUYASABKAsyES5ub2l0dS52MS5XZWxjb21lSAASLQoMcm9vbV9jcmVhdGVkGAIgASgLMhUubm9pdHUudjEuUm9vbUNyZWF0ZWRIABIrCgtyb29tX2pvaW5lZBgDIAEoCzIULm5vaXR1LnYxLlJvb21Kb2luZWRIABItCgxnYW1lX3N0YXJ0ZWQYBCABKAsyFS5ub2l0dS52MS5HYW1lU3RhcnRlZEgAEisKC3R1cm5fdXBkYXRlGAUgASgLMhQubm9pdHUudjEuVHVyblVwZGF0ZUgAEi8KDW1vdmVfcmVqZWN0ZWQYBiABKAsyFi5ub2l0dS52MS5Nb3ZlUmVqZWN0ZWRIABInCglnYW1lX292ZXIYByABKAsyEi5ub2l0dS52MS5HYW1lT3ZlckgAEi8KDW9wcG9uZW50X2xlZnQYCCABKAsyFi5ub2l0dS52MS5PcHBvbmVudExlZnRIABImCgVlcnJvchgJIAEoCzIVLm5vaXR1LnYxLlNlcnZlckVycm9ySAASHgoEcG9uZxgKIAEoCzIOLm5vaXR1LnYxLlBvbmdIAEIJCgdwYXlsb2FkKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lHTkVEEARCOlo4Z2l0aHViLmNvbS90aWVubm05OWRldi9ub2l0dS9zZXJ2ZXIvZ2VuL25vaXR1L3YxO25vaXR1djFiBnByb3RvMw");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Hello.
|
||||||
|
* Use `create(HelloSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const HelloSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.StartBotGame.
|
||||||
|
* Use `create(StartBotGameSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const StartBotGameSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.CreateRoom.
|
||||||
|
* Use `create(CreateRoomSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const CreateRoomSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.JoinRoom.
|
||||||
|
* Use `create(JoinRoomSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const JoinRoomSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.SubmitWord.
|
||||||
|
* Use `create(SubmitWordSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const SubmitWordSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 4);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Resign.
|
||||||
|
* Use `create(ResignSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ResignSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Ping.
|
||||||
|
* Use `create(PingSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const PingSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 6);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.ClientMessage.
|
||||||
|
* Use `create(ClientMessageSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ClientMessageSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 7);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Welcome.
|
||||||
|
* Use `create(WelcomeSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const WelcomeSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 8);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.RoomCreated.
|
||||||
|
* Use `create(RoomCreatedSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const RoomCreatedSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 9);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.RoomJoined.
|
||||||
|
* Use `create(RoomJoinedSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const RoomJoinedSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.PlayedWord.
|
||||||
|
* Use `create(PlayedWordSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const PlayedWordSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 11);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.GameStarted.
|
||||||
|
* Use `create(GameStartedSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const GameStartedSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 12);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.TurnUpdate.
|
||||||
|
* Use `create(TurnUpdateSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const TurnUpdateSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 13);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.MoveRejected.
|
||||||
|
* Use `create(MoveRejectedSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const MoveRejectedSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 14);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.GameOver.
|
||||||
|
* Use `create(GameOverSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const GameOverSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 15);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.OpponentLeft.
|
||||||
|
* Use `create(OpponentLeftSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const OpponentLeftSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 16);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.ServerError.
|
||||||
|
* Use `create(ServerErrorSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ServerErrorSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 17);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.Pong.
|
||||||
|
* Use `create(PongSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const PongSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 18);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message noitu.v1.ServerMessage.
|
||||||
|
* Use `create(ServerMessageSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ServerMessageSchema = /*@__PURE__*/
|
||||||
|
messageDesc(file_noitu_v1_game, 19);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the enum noitu.v1.Difficulty.
|
||||||
|
*/
|
||||||
|
export const DifficultySchema = /*@__PURE__*/
|
||||||
|
enumDesc(file_noitu_v1_game, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Difficulty selects the bot's strategy in a vs-bot game.
|
||||||
|
*
|
||||||
|
* @generated from enum noitu.v1.Difficulty
|
||||||
|
*/
|
||||||
|
export const Difficulty = /*@__PURE__*/
|
||||||
|
tsEnum(DifficultySchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the enum noitu.v1.RejectReason.
|
||||||
|
*/
|
||||||
|
export const RejectReasonSchema = /*@__PURE__*/
|
||||||
|
enumDesc(file_noitu_v1_game, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RejectReason says why a submitted word was not accepted. It is deliberately
|
||||||
|
* a separate type from the engine's internal game.RejectReason: the wire
|
||||||
|
* contract must not change every time the engine is refactored. The mapping
|
||||||
|
* lives in server/internal/wsapi/convert.go.
|
||||||
|
*
|
||||||
|
* @generated from enum noitu.v1.RejectReason
|
||||||
|
*/
|
||||||
|
export const RejectReason = /*@__PURE__*/
|
||||||
|
tsEnum(RejectReasonSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the enum noitu.v1.GameEndReason.
|
||||||
|
*/
|
||||||
|
export const GameEndReasonSchema = /*@__PURE__*/
|
||||||
|
enumDesc(file_noitu_v1_game, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameEndReason says how a finished game ended.
|
||||||
|
*
|
||||||
|
* @generated from enum noitu.v1.GameEndReason
|
||||||
|
*/
|
||||||
|
export const GameEndReason = /*@__PURE__*/
|
||||||
|
tsEnum(GameEndReasonSchema);
|
||||||
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Cross-language wire check.
|
||||||
|
//
|
||||||
|
// These fixtures are binary messages produced by the Go server's own test
|
||||||
|
// suite (server/internal/wsapi/wire_test.go, regenerated with `-update`).
|
||||||
|
// Decoding them here proves the two generated clients agree on the actual
|
||||||
|
// bytes — a JS-only round trip would only prove this runtime is
|
||||||
|
// self-consistent, which is exactly the failure mode that ships a broken
|
||||||
|
// client.
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fromBinary } from '@bufbuild/protobuf';
|
||||||
|
import {
|
||||||
|
ClientMessageSchema,
|
||||||
|
GameEndReason,
|
||||||
|
RejectReason,
|
||||||
|
ServerMessageSchema
|
||||||
|
} from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||||
|
|
||||||
|
const fixtureDir = fileURLToPath(new URL('../../proto/testdata', import.meta.url));
|
||||||
|
|
||||||
|
const fixtures = readdirSync(fixtureDir).filter((f) => f.endsWith('.bin'));
|
||||||
|
|
||||||
|
/** Decode a fixture by name, choosing the schema from its client_/server_ prefix. */
|
||||||
|
function decode(name) {
|
||||||
|
const bytes = readFileSync(join(fixtureDir, `${name}.bin`));
|
||||||
|
const schema = name.startsWith('client_') ? ClientMessageSchema : ServerMessageSchema;
|
||||||
|
return fromBinary(schema, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generated wire types', () => {
|
||||||
|
it('finds the fixtures the Go suite emits', () => {
|
||||||
|
expect(fixtures.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every arm must decode to a *named* case. An arm whose payload is empty on
|
||||||
|
// the wire (create_room, resign) still has to be distinguishable, so an
|
||||||
|
// undefined case here means the oneof tag was lost, not that the message
|
||||||
|
// was empty.
|
||||||
|
it.each(fixtures)('decodes %s to a known payload case', (file) => {
|
||||||
|
const msg = decode(file.replace(/\.bin$/, ''));
|
||||||
|
expect(msg.payload.case).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves Vietnamese diacritics through UTF-8', () => {
|
||||||
|
const msg = decode('server_welcome');
|
||||||
|
expect(msg.payload.case).toBe('welcome');
|
||||||
|
expect(msg.payload.value.acceptedNickname).toBe('Người chơi ẩn danh');
|
||||||
|
expect(msg.payload.value.protocolVersion).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// int64 is a bigint in this runtime. Reading it as a Number would silently
|
||||||
|
// lose precision on timestamps far smaller than the ones a real deadline
|
||||||
|
// carries, so the type itself is the assertion.
|
||||||
|
it('reads int64 timestamps as bigint', () => {
|
||||||
|
const msg = decode('server_pong');
|
||||||
|
expect(msg.payload.case).toBe('pong');
|
||||||
|
expect(msg.payload.value.clientTimeMs).toBe(1756998000123n);
|
||||||
|
expect(msg.payload.value.serverTimeMs).toBe(1756998000456n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with Go on enum numbering', () => {
|
||||||
|
const rejected = decode('server_move_rejected');
|
||||||
|
expect(rejected.payload.case).toBe('moveRejected');
|
||||||
|
expect(rejected.payload.value.reason).toBe(RejectReason.WRONG_LINK);
|
||||||
|
expect(rejected.payload.value.word).toBe('cà phê');
|
||||||
|
|
||||||
|
const over = decode('server_game_over');
|
||||||
|
expect(over.payload.case).toBe('gameOver');
|
||||||
|
expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE);
|
||||||
|
expect(over.payload.value.iWon).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The canonical/typed pair is what lets the UI show that the server
|
||||||
|
// corrected a spelling instead of appearing to rewrite the player's text.
|
||||||
|
it('carries both the canonical word and the raw input', () => {
|
||||||
|
const msg = decode('server_turn_update');
|
||||||
|
expect(msg.payload.case).toBe('turnUpdate');
|
||||||
|
const turn = msg.payload.value;
|
||||||
|
expect(turn.played.word).toBe('bình yên');
|
||||||
|
expect(turn.played.typed).toBe('binh yên');
|
||||||
|
expect(turn.played.byMe).toBe(false);
|
||||||
|
expect(turn.currentSyllable).toBe('yên');
|
||||||
|
expect(turn.deadlineUnixMs).toBe(1756998040000n);
|
||||||
|
expect(turn.turnSeq).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes an empty client arm without losing its tag', () => {
|
||||||
|
const msg = decode('client_create_room');
|
||||||
|
expect(msg.payload.case).toBe('createRoom');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user