diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2d409d8 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml new file mode 100644 index 0000000..e62aac9 --- /dev/null +++ b/.github/workflows/proto.yml @@ -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 diff --git a/Makefile b/Makefile index d7312c0..1cbd3b7 100644 --- a/Makefile +++ b/Makefile @@ -11,12 +11,14 @@ DICT_SHA256 := 9259403f0675b2991a1bd0ef6d0dbc5933afdb135632af095a60662f09bbf1d3 DICT_OUT := data/noitu.db 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: @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 "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 "web build the SvelteKit frontend" @echo "test run all tests" @@ -41,19 +43,39 @@ $(DICT_SRC): dict: $(DICT_SRC) 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: cd server && CGO_ENABLED=0 go build -o ../$(SERVER_BIN) ./cmd/noitu-server -web: - cd web && npm ci && npm run build +web: web/node_modules + cd web && npm run build test: test-go test-web test-go: cd server && go vet ./... && go test ./... -race -test-web: - @if [ -d web/node_modules ]; then cd web && npm test; else echo "web/ not set up yet, skipping"; fi +test-web: web/node_modules + cd web && npm test run: server ./$(SERVER_BIN) diff --git a/README.md b/README.md index 2a63db4..5026b2d 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,23 @@ SvelteKit SPA ──WebSocket + Protobuf──► Go server ──► SQLite | Backend | Go, [`coder/websocket`](https://github.com/coder/websocket) | | 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 -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 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 | | `server` | Build the Go server binary | | `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 | | `run` | Build and run the server locally | | `verify-dict` | Re-check the downloaded dictionary against its pinned SHA-256 | @@ -73,6 +89,10 @@ cd server && go vet ./... && go test ./... -race # 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 diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..323aa20 --- /dev/null +++ b/buf.gen.yaml @@ -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 diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..c7e30e3 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/plans/260904-1125-noi-tu-web-game/phase-01-foundations-and-data-pipeline.md b/plans/260904-1125-noi-tu-web-game/phase-01-foundations-and-data-pipeline.md index 68e90da..4f51c7b 100644 --- a/plans/260904-1125-noi-tu-web-game/phase-01-foundations-and-data-pipeline.md +++ b/plans/260904-1125-noi-tu-web-game/phase-01-foundations-and-data-pipeline.md @@ -1,6 +1,6 @@ --- title: "Phase 1: Foundations and Data Pipeline" -status: todo +status: done phase: 1 priority: P1 effort: "3d" diff --git a/plans/260904-1125-noi-tu-web-game/phase-02-go-dictionary-and-normalization.md b/plans/260904-1125-noi-tu-web-game/phase-02-go-dictionary-and-normalization.md index fd80b67..829fc5d 100644 --- a/plans/260904-1125-noi-tu-web-game/phase-02-go-dictionary-and-normalization.md +++ b/plans/260904-1125-noi-tu-web-game/phase-02-go-dictionary-and-normalization.md @@ -1,6 +1,6 @@ --- title: "Phase 2: Go Dictionary and Normalization" -status: todo +status: done phase: 2 priority: P1 effort: "2d" diff --git a/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md b/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md index 54d230b..6896ffa 100644 --- a/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md +++ b/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md @@ -1,6 +1,6 @@ --- title: "Phase 3: Go Game Engine and Bot AI" -status: todo +status: done phase: 3 priority: P1 effort: "4d" diff --git a/plans/260904-1125-noi-tu-web-game/phase-04-protobuf-contract-and-codegen.md b/plans/260904-1125-noi-tu-web-game/phase-04-protobuf-contract-and-codegen.md index ef35d70..bc79ec0 100644 --- a/plans/260904-1125-noi-tu-web-game/phase-04-protobuf-contract-and-codegen.md +++ b/plans/260904-1125-noi-tu-web-game/phase-04-protobuf-contract-and-codegen.md @@ -1,6 +1,6 @@ --- title: "Phase 4: Protobuf Contract and Codegen" -status: todo +status: done phase: 4 priority: P1 effort: "2d" @@ -20,14 +20,14 @@ Independent of phases 2-3; can be built in parallel with them. ## Requirements **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 -- [ ] `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 -- [ ] Generated code is committed (reviewable diffs, no toolchain needed to build) +- [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 +- [x] `buf generate` emits Go into `server/gen/noitu/v1` and JS into `web/src/lib/proto` +- [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 +- [x] Generated code is committed (reviewable diffs, no toolchain needed to build) **Non-functional** -- [ ] JS bundle cost of the protobuf runtime kept small — ESM, tree-shakeable -- [ ] `buf lint` and `buf breaking` (against `main`) run in CI +- [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 +- [x] `buf lint` and `buf breaking` (against `main`) run in CI ## 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 smaller browser bundle. -> `buf` is **not installed** in the current environment (`buf: command not found`). This does -> not block building or running: generated code is committed, so `buf` is required only when -> the schema changes. Install it before step 1. +> `buf` **is** installed (v1.69.0), retiring the plan-time note that it was missing. It stays +> optional for contributors either way: generated code is committed, so `buf` is required only +> when the schema changes. **Framing:** every WS frame is a binary message — `ClientMessage` client→server, `ServerMessage` server→client, each a single `oneof`. No JSON fallback, no text frames. @@ -153,12 +153,12 @@ message ServerMessage { ## Success Criteria -- [ ] `make proto` regenerates both targets with zero diff on a clean tree -- [ ] `buf lint` clean; `buf breaking` wired into CI -- [ ] Go round-trip test covers every `oneof` variant in both directions -- [ ] 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 -- [ ] No hand-written message struct exists in `server/` or `web/` +- [x] `make proto` regenerates both targets with zero diff on a clean tree +- [x] `buf lint` clean; `buf breaking` wired into CI +- [x] Go round-trip test covers every `oneof` variant in both directions +- [x] JS decodes a Go-produced binary fixture and reads correct values +- [x] `convert.go` mapping is exhaustive; a test fails if an engine reason gains a value with no proto counterpart +- [x] No hand-written message struct exists in `server/` or `web/` ## 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 | | 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` | + + +## 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`. diff --git a/plans/260904-1125-noi-tu-web-game/plan.md b/plans/260904-1125-noi-tu-web-game/plan.md index b3a2253..eaef838 100644 --- a/plans/260904-1125-noi-tu-web-game/plan.md +++ b/plans/260904-1125-noi-tu-web-game/plan.md @@ -1,7 +1,7 @@ --- title: "Noi Tu Web Game" description: "Vietnamese nối từ web game — SvelteKit frontend, Go backend, WebSocket + Protobuf, server-authoritative dictionary over SQLite. Vs-bot and online 1v1." -status: pending +status: in-progress priority: P1 effort: "~3-4w" 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 | |---|-------|--------|-----------| -| 1 | [Foundations and Data Pipeline](./phase-01-foundations-and-data-pipeline.md) | Pending | — | -| 2 | [Go Dictionary and Normalization](./phase-02-go-dictionary-and-normalization.md) | Pending | 1 | -| 3 | [Go Game Engine and Bot AI](./phase-03-go-game-engine-and-bot-ai.md) | Pending | 2 | -| 4 | [Protobuf Contract and Codegen](./phase-04-protobuf-contract-and-codegen.md) | Pending | 1 | +| 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) | Complete | 1 | +| 3 | [Go Game Engine and Bot AI](./phase-03-go-game-engine-and-bot-ai.md) | Complete | 2 | +| 4 | [Protobuf Contract and Codegen](./phase-04-protobuf-contract-and-codegen.md) | Complete | 1 | | 5 | [Go WebSocket Server and Rooms](./phase-05-go-websocket-server-and-rooms.md) | Pending | 3, 4 | | 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 | @@ -176,7 +176,7 @@ web/ - [ ] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section, and in-app credit all present and consistent - [ ] Go engine unit tests cover: wrong link, unknown word, reuse, single-syllable input, timeout, no-legal-move, and the tone-variant cases `hoà/hòa`, `thuý/thúy`, `quí/quý` - [ ] Words of 2, 3, and 4 syllables all accepted and chain correctly on first↔last syllable -- [ ] 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 - [ ] 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 @@ -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 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 1. Does the losing player see the words the bot *could* have played (a teaching feature), or just the result? Plan currently assumes just the result. diff --git a/plans/reports/code-reviewer-260904-2329-phase-04-protobuf-contract.md b/plans/reports/code-reviewer-260904-2329-phase-04-protobuf-contract.md new file mode 100644 index 0000000..bc570c6 --- /dev/null +++ b/plans/reports/code-reviewer-260904-2329-phase-04-protobuf-contract.md @@ -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. diff --git a/proto/noitu/v1/game.proto b/proto/noitu/v1/game.proto new file mode 100644 index 0000000..efcdc4c --- /dev/null +++ b/proto/noitu/v1/game.proto @@ -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; + } +} diff --git a/proto/testdata/client_create_room.bin b/proto/testdata/client_create_room.bin new file mode 100644 index 0000000..d0e81b8 Binary files /dev/null and b/proto/testdata/client_create_room.bin differ diff --git a/proto/testdata/client_hello.bin b/proto/testdata/client_hello.bin new file mode 100644 index 0000000..2d84153 --- /dev/null +++ b/proto/testdata/client_hello.bin @@ -0,0 +1,2 @@ + +$r-8f2cNgười chơi ẩn danh \ No newline at end of file diff --git a/proto/testdata/client_join_room.bin b/proto/testdata/client_join_room.bin new file mode 100644 index 0000000..552d647 --- /dev/null +++ b/proto/testdata/client_join_room.bin @@ -0,0 +1,2 @@ +" +K7QX \ No newline at end of file diff --git a/proto/testdata/client_ping.bin b/proto/testdata/client_ping.bin new file mode 100644 index 0000000..21717ac --- /dev/null +++ b/proto/testdata/client_ping.bin @@ -0,0 +1 @@ +:û»÷©‘3 \ No newline at end of file diff --git a/proto/testdata/client_resign.bin b/proto/testdata/client_resign.bin new file mode 100644 index 0000000..5ed660d Binary files /dev/null and b/proto/testdata/client_resign.bin differ diff --git a/proto/testdata/client_start_bot_game.bin b/proto/testdata/client_start_bot_game.bin new file mode 100644 index 0000000..27770a3 --- /dev/null +++ b/proto/testdata/client_start_bot_game.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/proto/testdata/client_submit_word.bin b/proto/testdata/client_submit_word.bin new file mode 100644 index 0000000..2436239 --- /dev/null +++ b/proto/testdata/client_submit_word.bin @@ -0,0 +1,3 @@ +* + +hoà bình’! \ No newline at end of file diff --git a/proto/testdata/server_error.bin b/proto/testdata/server_error.bin new file mode 100644 index 0000000..0330aba --- /dev/null +++ b/proto/testdata/server_error.bin @@ -0,0 +1,2 @@ +J +room_not_foundroom_not_found \ No newline at end of file diff --git a/proto/testdata/server_game_over.bin b/proto/testdata/server_game_over.bin new file mode 100644 index 0000000..1eabc21 --- /dev/null +++ b/proto/testdata/server_game_over.bin @@ -0,0 +1 @@ +: \ No newline at end of file diff --git a/proto/testdata/server_game_started.bin b/proto/testdata/server_game_started.bin new file mode 100644 index 0000000..31892b3 --- /dev/null +++ b/proto/testdata/server_game_started.bin @@ -0,0 +1,3 @@ +"" + +hòa bìnhbình  ×ø©‘3(0 œ \ No newline at end of file diff --git a/proto/testdata/server_move_rejected.bin b/proto/testdata/server_move_rejected.bin new file mode 100644 index 0000000..3674d66 --- /dev/null +++ b/proto/testdata/server_move_rejected.bin @@ -0,0 +1 @@ +2cà phê \ No newline at end of file diff --git a/proto/testdata/server_opponent_left.bin b/proto/testdata/server_opponent_left.bin new file mode 100644 index 0000000..a1d1433 --- /dev/null +++ b/proto/testdata/server_opponent_left.bin @@ -0,0 +1 @@ +B°ê \ No newline at end of file diff --git a/proto/testdata/server_pong.bin b/proto/testdata/server_pong.bin new file mode 100644 index 0000000..24061a1 --- /dev/null +++ b/proto/testdata/server_pong.bin @@ -0,0 +1 @@ +Rû»÷©‘3Ⱦ÷©‘3 \ No newline at end of file diff --git a/proto/testdata/server_room_created.bin b/proto/testdata/server_room_created.bin new file mode 100644 index 0000000..86b194a --- /dev/null +++ b/proto/testdata/server_room_created.bin @@ -0,0 +1,2 @@ + +K7QX \ No newline at end of file diff --git a/proto/testdata/server_room_joined.bin b/proto/testdata/server_room_joined.bin new file mode 100644 index 0000000..4ea0b2c --- /dev/null +++ b/proto/testdata/server_room_joined.bin @@ -0,0 +1,2 @@ + +K7QXThuý \ No newline at end of file diff --git a/proto/testdata/server_turn_update.bin b/proto/testdata/server_turn_update.bin new file mode 100644 index 0000000..2390fae --- /dev/null +++ b/proto/testdata/server_turn_update.bin @@ -0,0 +1,4 @@ +*4 + + +bình yên * binh yênyên Àóù©‘3(08@ \ No newline at end of file diff --git a/proto/testdata/server_welcome.bin b/proto/testdata/server_welcome.bin new file mode 100644 index 0000000..a6af317 --- /dev/null +++ b/proto/testdata/server_welcome.bin @@ -0,0 +1,3 @@ + +. +s-1a2b3cr-8f2c"Người chơi ẩn danh \ No newline at end of file diff --git a/server/gen/noitu/v1/game.pb.go b/server/gen/noitu/v1/game.pb.go new file mode 100644 index 0000000..a966fed --- /dev/null +++ b/server/gen/noitu/v1/game.pb.go @@ -0,0 +1,1874 @@ +// 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. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: noitu/v1/game.proto + +package noituv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Difficulty selects the bot's strategy in a vs-bot game. +type Difficulty int32 + +const ( + Difficulty_DIFFICULTY_UNSPECIFIED Difficulty = 0 + Difficulty_DIFFICULTY_EASY Difficulty = 1 + Difficulty_DIFFICULTY_MEDIUM Difficulty = 2 + Difficulty_DIFFICULTY_HARD Difficulty = 3 +) + +// Enum value maps for Difficulty. +var ( + Difficulty_name = map[int32]string{ + 0: "DIFFICULTY_UNSPECIFIED", + 1: "DIFFICULTY_EASY", + 2: "DIFFICULTY_MEDIUM", + 3: "DIFFICULTY_HARD", + } + Difficulty_value = map[string]int32{ + "DIFFICULTY_UNSPECIFIED": 0, + "DIFFICULTY_EASY": 1, + "DIFFICULTY_MEDIUM": 2, + "DIFFICULTY_HARD": 3, + } +) + +func (x Difficulty) Enum() *Difficulty { + p := new(Difficulty) + *p = x + return p +} + +func (x Difficulty) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Difficulty) Descriptor() protoreflect.EnumDescriptor { + return file_noitu_v1_game_proto_enumTypes[0].Descriptor() +} + +func (Difficulty) Type() protoreflect.EnumType { + return &file_noitu_v1_game_proto_enumTypes[0] +} + +func (x Difficulty) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Difficulty.Descriptor instead. +func (Difficulty) EnumDescriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{0} +} + +// 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. +type RejectReason int32 + +const ( + RejectReason_REJECT_REASON_UNSPECIFIED RejectReason = 0 + // Fewer than two syllables. + RejectReason_REJECT_REASON_TOO_FEW_SYLLABLES RejectReason = 1 + // First syllable does not match the required syllable. + RejectReason_REJECT_REASON_WRONG_LINK RejectReason = 2 + RejectReason_REJECT_REASON_NOT_IN_DICTIONARY RejectReason = 3 + RejectReason_REJECT_REASON_ALREADY_USED RejectReason = 4 + RejectReason_REJECT_REASON_NOT_YOUR_TURN RejectReason = 5 + // The turn deadline passed before the word arrived. + RejectReason_REJECT_REASON_TIMEOUT RejectReason = 6 + // The game had already finished. + RejectReason_REJECT_REASON_GAME_OVER RejectReason = 7 +) + +// Enum value maps for RejectReason. +var ( + RejectReason_name = map[int32]string{ + 0: "REJECT_REASON_UNSPECIFIED", + 1: "REJECT_REASON_TOO_FEW_SYLLABLES", + 2: "REJECT_REASON_WRONG_LINK", + 3: "REJECT_REASON_NOT_IN_DICTIONARY", + 4: "REJECT_REASON_ALREADY_USED", + 5: "REJECT_REASON_NOT_YOUR_TURN", + 6: "REJECT_REASON_TIMEOUT", + 7: "REJECT_REASON_GAME_OVER", + } + RejectReason_value = map[string]int32{ + "REJECT_REASON_UNSPECIFIED": 0, + "REJECT_REASON_TOO_FEW_SYLLABLES": 1, + "REJECT_REASON_WRONG_LINK": 2, + "REJECT_REASON_NOT_IN_DICTIONARY": 3, + "REJECT_REASON_ALREADY_USED": 4, + "REJECT_REASON_NOT_YOUR_TURN": 5, + "REJECT_REASON_TIMEOUT": 6, + "REJECT_REASON_GAME_OVER": 7, + } +) + +func (x RejectReason) Enum() *RejectReason { + p := new(RejectReason) + *p = x + return p +} + +func (x RejectReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RejectReason) Descriptor() protoreflect.EnumDescriptor { + return file_noitu_v1_game_proto_enumTypes[1].Descriptor() +} + +func (RejectReason) Type() protoreflect.EnumType { + return &file_noitu_v1_game_proto_enumTypes[1] +} + +func (x RejectReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RejectReason.Descriptor instead. +func (RejectReason) EnumDescriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{1} +} + +// GameEndReason says how a finished game ended. +type GameEndReason int32 + +const ( + GameEndReason_GAME_END_REASON_UNSPECIFIED GameEndReason = 0 + GameEndReason_GAME_END_REASON_TIMEOUT GameEndReason = 1 + GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE GameEndReason = 2 + GameEndReason_GAME_END_REASON_OPPONENT_LEFT GameEndReason = 3 + GameEndReason_GAME_END_REASON_RESIGNED GameEndReason = 4 +) + +// Enum value maps for GameEndReason. +var ( + GameEndReason_name = map[int32]string{ + 0: "GAME_END_REASON_UNSPECIFIED", + 1: "GAME_END_REASON_TIMEOUT", + 2: "GAME_END_REASON_NO_LEGAL_MOVE", + 3: "GAME_END_REASON_OPPONENT_LEFT", + 4: "GAME_END_REASON_RESIGNED", + } + GameEndReason_value = map[string]int32{ + "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, + } +) + +func (x GameEndReason) Enum() *GameEndReason { + p := new(GameEndReason) + *p = x + return p +} + +func (x GameEndReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (GameEndReason) Descriptor() protoreflect.EnumDescriptor { + return file_noitu_v1_game_proto_enumTypes[2].Descriptor() +} + +func (GameEndReason) Type() protoreflect.EnumType { + return &file_noitu_v1_game_proto_enumTypes[2] +} + +func (x GameEndReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use GameEndReason.Descriptor instead. +func (GameEndReason) EnumDescriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{2} +} + +// Hello opens a session. nickname is a *request*: the server sanitizes it and +// reports what it actually stored in Welcome.accepted_nickname. +type Hello struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProtocolVersion uint32 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + // Empty on a fresh session; a token from a previous Welcome to resume one. + ResumeToken string `protobuf:"bytes,2,opt,name=resume_token,json=resumeToken,proto3" json:"resume_token,omitempty"` + Nickname string `protobuf:"bytes,3,opt,name=nickname,proto3" json:"nickname,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Hello) Reset() { + *x = Hello{} + mi := &file_noitu_v1_game_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Hello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Hello) ProtoMessage() {} + +func (x *Hello) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Hello.ProtoReflect.Descriptor instead. +func (*Hello) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{0} +} + +func (x *Hello) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *Hello) GetResumeToken() string { + if x != nil { + return x.ResumeToken + } + return "" +} + +func (x *Hello) GetNickname() string { + if x != nil { + return x.Nickname + } + return "" +} + +type StartBotGame struct { + state protoimpl.MessageState `protogen:"open.v1"` + Difficulty Difficulty `protobuf:"varint,1,opt,name=difficulty,proto3,enum=noitu.v1.Difficulty" json:"difficulty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartBotGame) Reset() { + *x = StartBotGame{} + mi := &file_noitu_v1_game_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartBotGame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartBotGame) ProtoMessage() {} + +func (x *StartBotGame) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartBotGame.ProtoReflect.Descriptor instead. +func (*StartBotGame) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{1} +} + +func (x *StartBotGame) GetDifficulty() Difficulty { + if x != nil { + return x.Difficulty + } + return Difficulty_DIFFICULTY_UNSPECIFIED +} + +type CreateRoom struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateRoom) Reset() { + *x = CreateRoom{} + mi := &file_noitu_v1_game_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateRoom) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateRoom) ProtoMessage() {} + +func (x *CreateRoom) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateRoom.ProtoReflect.Descriptor instead. +func (*CreateRoom) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{2} +} + +type JoinRoom struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomCode string `protobuf:"bytes,1,opt,name=room_code,json=roomCode,proto3" json:"room_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *JoinRoom) Reset() { + *x = JoinRoom{} + mi := &file_noitu_v1_game_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *JoinRoom) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JoinRoom) ProtoMessage() {} + +func (x *JoinRoom) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JoinRoom.ProtoReflect.Descriptor instead. +func (*JoinRoom) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{3} +} + +func (x *JoinRoom) GetRoomCode() string { + if x != nil { + return x.RoomCode + } + return "" +} + +// 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. +type SubmitWord struct { + state protoimpl.MessageState `protogen:"open.v1"` + Word string `protobuf:"bytes,1,opt,name=word,proto3" json:"word,omitempty"` + TurnSeq uint32 `protobuf:"varint,2,opt,name=turn_seq,json=turnSeq,proto3" json:"turn_seq,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitWord) Reset() { + *x = SubmitWord{} + mi := &file_noitu_v1_game_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitWord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitWord) ProtoMessage() {} + +func (x *SubmitWord) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitWord.ProtoReflect.Descriptor instead. +func (*SubmitWord) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{4} +} + +func (x *SubmitWord) GetWord() string { + if x != nil { + return x.Word + } + return "" +} + +func (x *SubmitWord) GetTurnSeq() uint32 { + if x != nil { + return x.TurnSeq + } + return 0 +} + +type Resign struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Resign) Reset() { + *x = Resign{} + mi := &file_noitu_v1_game_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Resign) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Resign) ProtoMessage() {} + +func (x *Resign) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Resign.ProtoReflect.Descriptor instead. +func (*Resign) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{5} +} + +// Ping echoes the client clock so Pong can expose the offset between the two. +type Ping struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientTimeMs int64 `protobuf:"varint,1,opt,name=client_time_ms,json=clientTimeMs,proto3" json:"client_time_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ping) Reset() { + *x = Ping{} + mi := &file_noitu_v1_game_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ping) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ping) ProtoMessage() {} + +func (x *Ping) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ping.ProtoReflect.Descriptor instead. +func (*Ping) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{6} +} + +func (x *Ping) GetClientTimeMs() int64 { + if x != nil { + return x.ClientTimeMs + } + return 0 +} + +type ClientMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *ClientMessage_Hello + // *ClientMessage_StartBotGame + // *ClientMessage_CreateRoom + // *ClientMessage_JoinRoom + // *ClientMessage_SubmitWord + // *ClientMessage_Resign + // *ClientMessage_Ping + Payload isClientMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientMessage) Reset() { + *x = ClientMessage{} + mi := &file_noitu_v1_game_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientMessage) ProtoMessage() {} + +func (x *ClientMessage) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientMessage.ProtoReflect.Descriptor instead. +func (*ClientMessage) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{7} +} + +func (x *ClientMessage) GetPayload() isClientMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ClientMessage) GetHello() *Hello { + if x != nil { + if x, ok := x.Payload.(*ClientMessage_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *ClientMessage) GetStartBotGame() *StartBotGame { + if x != nil { + if x, ok := x.Payload.(*ClientMessage_StartBotGame); ok { + return x.StartBotGame + } + } + return nil +} + +func (x *ClientMessage) GetCreateRoom() *CreateRoom { + if x != nil { + if x, ok := x.Payload.(*ClientMessage_CreateRoom); ok { + return x.CreateRoom + } + } + return nil +} + +func (x *ClientMessage) GetJoinRoom() *JoinRoom { + if x != nil { + if x, ok := x.Payload.(*ClientMessage_JoinRoom); ok { + return x.JoinRoom + } + } + return nil +} + +func (x *ClientMessage) GetSubmitWord() *SubmitWord { + if x != nil { + if x, ok := x.Payload.(*ClientMessage_SubmitWord); ok { + return x.SubmitWord + } + } + return nil +} + +func (x *ClientMessage) GetResign() *Resign { + if x != nil { + if x, ok := x.Payload.(*ClientMessage_Resign); ok { + return x.Resign + } + } + return nil +} + +func (x *ClientMessage) GetPing() *Ping { + if x != nil { + if x, ok := x.Payload.(*ClientMessage_Ping); ok { + return x.Ping + } + } + return nil +} + +type isClientMessage_Payload interface { + isClientMessage_Payload() +} + +type ClientMessage_Hello struct { + Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type ClientMessage_StartBotGame struct { + StartBotGame *StartBotGame `protobuf:"bytes,2,opt,name=start_bot_game,json=startBotGame,proto3,oneof"` +} + +type ClientMessage_CreateRoom struct { + CreateRoom *CreateRoom `protobuf:"bytes,3,opt,name=create_room,json=createRoom,proto3,oneof"` +} + +type ClientMessage_JoinRoom struct { + JoinRoom *JoinRoom `protobuf:"bytes,4,opt,name=join_room,json=joinRoom,proto3,oneof"` +} + +type ClientMessage_SubmitWord struct { + SubmitWord *SubmitWord `protobuf:"bytes,5,opt,name=submit_word,json=submitWord,proto3,oneof"` +} + +type ClientMessage_Resign struct { + Resign *Resign `protobuf:"bytes,6,opt,name=resign,proto3,oneof"` +} + +type ClientMessage_Ping struct { + Ping *Ping `protobuf:"bytes,7,opt,name=ping,proto3,oneof"` +} + +func (*ClientMessage_Hello) isClientMessage_Payload() {} + +func (*ClientMessage_StartBotGame) isClientMessage_Payload() {} + +func (*ClientMessage_CreateRoom) isClientMessage_Payload() {} + +func (*ClientMessage_JoinRoom) isClientMessage_Payload() {} + +func (*ClientMessage_SubmitWord) isClientMessage_Payload() {} + +func (*ClientMessage_Resign) isClientMessage_Payload() {} + +func (*ClientMessage_Ping) isClientMessage_Payload() {} + +type Welcome struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + ResumeToken string `protobuf:"bytes,2,opt,name=resume_token,json=resumeToken,proto3" json:"resume_token,omitempty"` + ProtocolVersion uint32 `protobuf:"varint,3,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + // What the server stored after sanitizing Hello.nickname. The client must + // display this, not the string it sent. + AcceptedNickname string `protobuf:"bytes,4,opt,name=accepted_nickname,json=acceptedNickname,proto3" json:"accepted_nickname,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Welcome) Reset() { + *x = Welcome{} + mi := &file_noitu_v1_game_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Welcome) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Welcome) ProtoMessage() {} + +func (x *Welcome) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Welcome.ProtoReflect.Descriptor instead. +func (*Welcome) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{8} +} + +func (x *Welcome) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Welcome) GetResumeToken() string { + if x != nil { + return x.ResumeToken + } + return "" +} + +func (x *Welcome) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *Welcome) GetAcceptedNickname() string { + if x != nil { + return x.AcceptedNickname + } + return "" +} + +type RoomCreated struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomCode string `protobuf:"bytes,1,opt,name=room_code,json=roomCode,proto3" json:"room_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomCreated) Reset() { + *x = RoomCreated{} + mi := &file_noitu_v1_game_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomCreated) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomCreated) ProtoMessage() {} + +func (x *RoomCreated) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoomCreated.ProtoReflect.Descriptor instead. +func (*RoomCreated) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{9} +} + +func (x *RoomCreated) GetRoomCode() string { + if x != nil { + return x.RoomCode + } + return "" +} + +type RoomJoined struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomCode string `protobuf:"bytes,1,opt,name=room_code,json=roomCode,proto3" json:"room_code,omitempty"` + // Always server-sanitized. A client must never render another player's raw + // input. + OpponentName string `protobuf:"bytes,2,opt,name=opponent_name,json=opponentName,proto3" json:"opponent_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomJoined) Reset() { + *x = RoomJoined{} + mi := &file_noitu_v1_game_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomJoined) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomJoined) ProtoMessage() {} + +func (x *RoomJoined) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoomJoined.ProtoReflect.Descriptor instead. +func (*RoomJoined) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{10} +} + +func (x *RoomJoined) GetRoomCode() string { + if x != nil { + return x.RoomCode + } + return "" +} + +func (x *RoomJoined) GetOpponentName() string { + if x != nil { + return x.OpponentName + } + return "" +} + +// 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. +type PlayedWord struct { + state protoimpl.MessageState `protogen:"open.v1"` + Word string `protobuf:"bytes,1,opt,name=word,proto3" json:"word,omitempty"` + ByMe bool `protobuf:"varint,2,opt,name=by_me,json=byMe,proto3" json:"by_me,omitempty"` + Points uint32 `protobuf:"varint,3,opt,name=points,proto3" json:"points,omitempty"` + Syllables uint32 `protobuf:"varint,4,opt,name=syllables,proto3" json:"syllables,omitempty"` + Typed string `protobuf:"bytes,5,opt,name=typed,proto3" json:"typed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlayedWord) Reset() { + *x = PlayedWord{} + mi := &file_noitu_v1_game_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlayedWord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlayedWord) ProtoMessage() {} + +func (x *PlayedWord) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlayedWord.ProtoReflect.Descriptor instead. +func (*PlayedWord) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{11} +} + +func (x *PlayedWord) GetWord() string { + if x != nil { + return x.Word + } + return "" +} + +func (x *PlayedWord) GetByMe() bool { + if x != nil { + return x.ByMe + } + return false +} + +func (x *PlayedWord) GetPoints() uint32 { + if x != nil { + return x.Points + } + return 0 +} + +func (x *PlayedWord) GetSyllables() uint32 { + if x != nil { + return x.Syllables + } + return 0 +} + +func (x *PlayedWord) GetTyped() string { + if x != nil { + return x.Typed + } + return "" +} + +// GameStarted is rendered per recipient: my_turn is true for exactly one of +// the two players. +type GameStarted struct { + state protoimpl.MessageState `protogen:"open.v1"` + OpeningWord string `protobuf:"bytes,1,opt,name=opening_word,json=openingWord,proto3" json:"opening_word,omitempty"` + CurrentSyllable string `protobuf:"bytes,2,opt,name=current_syllable,json=currentSyllable,proto3" json:"current_syllable,omitempty"` + MyTurn bool `protobuf:"varint,3,opt,name=my_turn,json=myTurn,proto3" json:"my_turn,omitempty"` + // Absolute server timestamp. The client counts down to it and never trusts + // its own clock for authority. + DeadlineUnixMs int64 `protobuf:"varint,4,opt,name=deadline_unix_ms,json=deadlineUnixMs,proto3" json:"deadline_unix_ms,omitempty"` + TurnSeq uint32 `protobuf:"varint,5,opt,name=turn_seq,json=turnSeq,proto3" json:"turn_seq,omitempty"` + TurnLimitMs uint32 `protobuf:"varint,6,opt,name=turn_limit_ms,json=turnLimitMs,proto3" json:"turn_limit_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GameStarted) Reset() { + *x = GameStarted{} + mi := &file_noitu_v1_game_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GameStarted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GameStarted) ProtoMessage() {} + +func (x *GameStarted) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GameStarted.ProtoReflect.Descriptor instead. +func (*GameStarted) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{12} +} + +func (x *GameStarted) GetOpeningWord() string { + if x != nil { + return x.OpeningWord + } + return "" +} + +func (x *GameStarted) GetCurrentSyllable() string { + if x != nil { + return x.CurrentSyllable + } + return "" +} + +func (x *GameStarted) GetMyTurn() bool { + if x != nil { + return x.MyTurn + } + return false +} + +func (x *GameStarted) GetDeadlineUnixMs() int64 { + if x != nil { + return x.DeadlineUnixMs + } + return 0 +} + +func (x *GameStarted) GetTurnSeq() uint32 { + if x != nil { + return x.TurnSeq + } + return 0 +} + +func (x *GameStarted) GetTurnLimitMs() uint32 { + if x != nil { + return x.TurnLimitMs + } + return 0 +} + +// TurnUpdate follows every accepted move and goes to both players, serialized +// once per recipient so by_me and my_turn are correct for each. +type TurnUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Played *PlayedWord `protobuf:"bytes,1,opt,name=played,proto3" json:"played,omitempty"` + CurrentSyllable string `protobuf:"bytes,2,opt,name=current_syllable,json=currentSyllable,proto3" json:"current_syllable,omitempty"` + MyTurn bool `protobuf:"varint,3,opt,name=my_turn,json=myTurn,proto3" json:"my_turn,omitempty"` + DeadlineUnixMs int64 `protobuf:"varint,4,opt,name=deadline_unix_ms,json=deadlineUnixMs,proto3" json:"deadline_unix_ms,omitempty"` + TurnSeq uint32 `protobuf:"varint,5,opt,name=turn_seq,json=turnSeq,proto3" json:"turn_seq,omitempty"` + MyScore uint32 `protobuf:"varint,6,opt,name=my_score,json=myScore,proto3" json:"my_score,omitempty"` + OpponentScore uint32 `protobuf:"varint,7,opt,name=opponent_score,json=opponentScore,proto3" json:"opponent_score,omitempty"` + ChainLength uint32 `protobuf:"varint,8,opt,name=chain_length,json=chainLength,proto3" json:"chain_length,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TurnUpdate) Reset() { + *x = TurnUpdate{} + mi := &file_noitu_v1_game_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TurnUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TurnUpdate) ProtoMessage() {} + +func (x *TurnUpdate) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TurnUpdate.ProtoReflect.Descriptor instead. +func (*TurnUpdate) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{13} +} + +func (x *TurnUpdate) GetPlayed() *PlayedWord { + if x != nil { + return x.Played + } + return nil +} + +func (x *TurnUpdate) GetCurrentSyllable() string { + if x != nil { + return x.CurrentSyllable + } + return "" +} + +func (x *TurnUpdate) GetMyTurn() bool { + if x != nil { + return x.MyTurn + } + return false +} + +func (x *TurnUpdate) GetDeadlineUnixMs() int64 { + if x != nil { + return x.DeadlineUnixMs + } + return 0 +} + +func (x *TurnUpdate) GetTurnSeq() uint32 { + if x != nil { + return x.TurnSeq + } + return 0 +} + +func (x *TurnUpdate) GetMyScore() uint32 { + if x != nil { + return x.MyScore + } + return 0 +} + +func (x *TurnUpdate) GetOpponentScore() uint32 { + if x != nil { + return x.OpponentScore + } + return 0 +} + +func (x *TurnUpdate) GetChainLength() uint32 { + if x != nil { + return x.ChainLength + } + return 0 +} + +type MoveRejected struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reason RejectReason `protobuf:"varint,1,opt,name=reason,proto3,enum=noitu.v1.RejectReason" json:"reason,omitempty"` + Word string `protobuf:"bytes,2,opt,name=word,proto3" json:"word,omitempty"` + TurnSeq uint32 `protobuf:"varint,3,opt,name=turn_seq,json=turnSeq,proto3" json:"turn_seq,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveRejected) Reset() { + *x = MoveRejected{} + mi := &file_noitu_v1_game_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveRejected) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveRejected) ProtoMessage() {} + +func (x *MoveRejected) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveRejected.ProtoReflect.Descriptor instead. +func (*MoveRejected) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{14} +} + +func (x *MoveRejected) GetReason() RejectReason { + if x != nil { + return x.Reason + } + return RejectReason_REJECT_REASON_UNSPECIFIED +} + +func (x *MoveRejected) GetWord() string { + if x != nil { + return x.Word + } + return "" +} + +func (x *MoveRejected) GetTurnSeq() uint32 { + if x != nil { + return x.TurnSeq + } + return 0 +} + +type GameOver struct { + state protoimpl.MessageState `protogen:"open.v1"` + IWon bool `protobuf:"varint,1,opt,name=i_won,json=iWon,proto3" json:"i_won,omitempty"` + Reason GameEndReason `protobuf:"varint,2,opt,name=reason,proto3,enum=noitu.v1.GameEndReason" json:"reason,omitempty"` + MyScore uint32 `protobuf:"varint,3,opt,name=my_score,json=myScore,proto3" json:"my_score,omitempty"` + ChainLength uint32 `protobuf:"varint,4,opt,name=chain_length,json=chainLength,proto3" json:"chain_length,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GameOver) Reset() { + *x = GameOver{} + mi := &file_noitu_v1_game_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GameOver) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GameOver) ProtoMessage() {} + +func (x *GameOver) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GameOver.ProtoReflect.Descriptor instead. +func (*GameOver) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{15} +} + +func (x *GameOver) GetIWon() bool { + if x != nil { + return x.IWon + } + return false +} + +func (x *GameOver) GetReason() GameEndReason { + if x != nil { + return x.Reason + } + return GameEndReason_GAME_END_REASON_UNSPECIFIED +} + +func (x *GameOver) GetMyScore() uint32 { + if x != nil { + return x.MyScore + } + return 0 +} + +func (x *GameOver) GetChainLength() uint32 { + if x != nil { + return x.ChainLength + } + return 0 +} + +type OpponentLeft struct { + state protoimpl.MessageState `protogen:"open.v1"` + CanReconnect bool `protobuf:"varint,1,opt,name=can_reconnect,json=canReconnect,proto3" json:"can_reconnect,omitempty"` + GraceMs uint32 `protobuf:"varint,2,opt,name=grace_ms,json=graceMs,proto3" json:"grace_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpponentLeft) Reset() { + *x = OpponentLeft{} + mi := &file_noitu_v1_game_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpponentLeft) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpponentLeft) ProtoMessage() {} + +func (x *OpponentLeft) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpponentLeft.ProtoReflect.Descriptor instead. +func (*OpponentLeft) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{16} +} + +func (x *OpponentLeft) GetCanReconnect() bool { + if x != nil { + return x.CanReconnect + } + return false +} + +func (x *OpponentLeft) GetGraceMs() uint32 { + if x != nil { + return x.GraceMs + } + return 0 +} + +// 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. +type ServerError struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServerError) Reset() { + *x = ServerError{} + mi := &file_noitu_v1_game_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServerError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerError) ProtoMessage() {} + +func (x *ServerError) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServerError.ProtoReflect.Descriptor instead. +func (*ServerError) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{17} +} + +func (x *ServerError) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *ServerError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type Pong struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientTimeMs int64 `protobuf:"varint,1,opt,name=client_time_ms,json=clientTimeMs,proto3" json:"client_time_ms,omitempty"` + ServerTimeMs int64 `protobuf:"varint,2,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Pong) Reset() { + *x = Pong{} + mi := &file_noitu_v1_game_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Pong) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Pong) ProtoMessage() {} + +func (x *Pong) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Pong.ProtoReflect.Descriptor instead. +func (*Pong) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{18} +} + +func (x *Pong) GetClientTimeMs() int64 { + if x != nil { + return x.ClientTimeMs + } + return 0 +} + +func (x *Pong) GetServerTimeMs() int64 { + if x != nil { + return x.ServerTimeMs + } + return 0 +} + +type ServerMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *ServerMessage_Welcome + // *ServerMessage_RoomCreated + // *ServerMessage_RoomJoined + // *ServerMessage_GameStarted + // *ServerMessage_TurnUpdate + // *ServerMessage_MoveRejected + // *ServerMessage_GameOver + // *ServerMessage_OpponentLeft + // *ServerMessage_Error + // *ServerMessage_Pong + Payload isServerMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServerMessage) Reset() { + *x = ServerMessage{} + mi := &file_noitu_v1_game_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServerMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerMessage) ProtoMessage() {} + +func (x *ServerMessage) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServerMessage.ProtoReflect.Descriptor instead. +func (*ServerMessage) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{19} +} + +func (x *ServerMessage) GetPayload() isServerMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ServerMessage) GetWelcome() *Welcome { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_Welcome); ok { + return x.Welcome + } + } + return nil +} + +func (x *ServerMessage) GetRoomCreated() *RoomCreated { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_RoomCreated); ok { + return x.RoomCreated + } + } + return nil +} + +func (x *ServerMessage) GetRoomJoined() *RoomJoined { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_RoomJoined); ok { + return x.RoomJoined + } + } + return nil +} + +func (x *ServerMessage) GetGameStarted() *GameStarted { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_GameStarted); ok { + return x.GameStarted + } + } + return nil +} + +func (x *ServerMessage) GetTurnUpdate() *TurnUpdate { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_TurnUpdate); ok { + return x.TurnUpdate + } + } + return nil +} + +func (x *ServerMessage) GetMoveRejected() *MoveRejected { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_MoveRejected); ok { + return x.MoveRejected + } + } + return nil +} + +func (x *ServerMessage) GetGameOver() *GameOver { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_GameOver); ok { + return x.GameOver + } + } + return nil +} + +func (x *ServerMessage) GetOpponentLeft() *OpponentLeft { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_OpponentLeft); ok { + return x.OpponentLeft + } + } + return nil +} + +func (x *ServerMessage) GetError() *ServerError { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_Error); ok { + return x.Error + } + } + return nil +} + +func (x *ServerMessage) GetPong() *Pong { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_Pong); ok { + return x.Pong + } + } + return nil +} + +type isServerMessage_Payload interface { + isServerMessage_Payload() +} + +type ServerMessage_Welcome struct { + Welcome *Welcome `protobuf:"bytes,1,opt,name=welcome,proto3,oneof"` +} + +type ServerMessage_RoomCreated struct { + RoomCreated *RoomCreated `protobuf:"bytes,2,opt,name=room_created,json=roomCreated,proto3,oneof"` +} + +type ServerMessage_RoomJoined struct { + RoomJoined *RoomJoined `protobuf:"bytes,3,opt,name=room_joined,json=roomJoined,proto3,oneof"` +} + +type ServerMessage_GameStarted struct { + GameStarted *GameStarted `protobuf:"bytes,4,opt,name=game_started,json=gameStarted,proto3,oneof"` +} + +type ServerMessage_TurnUpdate struct { + TurnUpdate *TurnUpdate `protobuf:"bytes,5,opt,name=turn_update,json=turnUpdate,proto3,oneof"` +} + +type ServerMessage_MoveRejected struct { + MoveRejected *MoveRejected `protobuf:"bytes,6,opt,name=move_rejected,json=moveRejected,proto3,oneof"` +} + +type ServerMessage_GameOver struct { + GameOver *GameOver `protobuf:"bytes,7,opt,name=game_over,json=gameOver,proto3,oneof"` +} + +type ServerMessage_OpponentLeft struct { + OpponentLeft *OpponentLeft `protobuf:"bytes,8,opt,name=opponent_left,json=opponentLeft,proto3,oneof"` +} + +type ServerMessage_Error struct { + Error *ServerError `protobuf:"bytes,9,opt,name=error,proto3,oneof"` +} + +type ServerMessage_Pong struct { + Pong *Pong `protobuf:"bytes,10,opt,name=pong,proto3,oneof"` +} + +func (*ServerMessage_Welcome) isServerMessage_Payload() {} + +func (*ServerMessage_RoomCreated) isServerMessage_Payload() {} + +func (*ServerMessage_RoomJoined) isServerMessage_Payload() {} + +func (*ServerMessage_GameStarted) isServerMessage_Payload() {} + +func (*ServerMessage_TurnUpdate) isServerMessage_Payload() {} + +func (*ServerMessage_MoveRejected) isServerMessage_Payload() {} + +func (*ServerMessage_GameOver) isServerMessage_Payload() {} + +func (*ServerMessage_OpponentLeft) isServerMessage_Payload() {} + +func (*ServerMessage_Error) isServerMessage_Payload() {} + +func (*ServerMessage_Pong) isServerMessage_Payload() {} + +var File_noitu_v1_game_proto protoreflect.FileDescriptor + +const file_noitu_v1_game_proto_rawDesc = "" + + "\n" + + "\x13noitu/v1/game.proto\x12\bnoitu.v1\"q\n" + + "\x05Hello\x12)\n" + + "\x10protocol_version\x18\x01 \x01(\rR\x0fprotocolVersion\x12!\n" + + "\fresume_token\x18\x02 \x01(\tR\vresumeToken\x12\x1a\n" + + "\bnickname\x18\x03 \x01(\tR\bnickname\"D\n" + + "\fStartBotGame\x124\n" + + "\n" + + "difficulty\x18\x01 \x01(\x0e2\x14.noitu.v1.DifficultyR\n" + + "difficulty\"\f\n" + + "\n" + + "CreateRoom\"'\n" + + "\bJoinRoom\x12\x1b\n" + + "\troom_code\x18\x01 \x01(\tR\broomCode\";\n" + + "\n" + + "SubmitWord\x12\x12\n" + + "\x04word\x18\x01 \x01(\tR\x04word\x12\x19\n" + + "\bturn_seq\x18\x02 \x01(\rR\aturnSeq\"\b\n" + + "\x06Resign\",\n" + + "\x04Ping\x12$\n" + + "\x0eclient_time_ms\x18\x01 \x01(\x03R\fclientTimeMs\"\xfa\x02\n" + + "\rClientMessage\x12'\n" + + "\x05hello\x18\x01 \x01(\v2\x0f.noitu.v1.HelloH\x00R\x05hello\x12>\n" + + "\x0estart_bot_game\x18\x02 \x01(\v2\x16.noitu.v1.StartBotGameH\x00R\fstartBotGame\x127\n" + + "\vcreate_room\x18\x03 \x01(\v2\x14.noitu.v1.CreateRoomH\x00R\n" + + "createRoom\x121\n" + + "\tjoin_room\x18\x04 \x01(\v2\x12.noitu.v1.JoinRoomH\x00R\bjoinRoom\x127\n" + + "\vsubmit_word\x18\x05 \x01(\v2\x14.noitu.v1.SubmitWordH\x00R\n" + + "submitWord\x12*\n" + + "\x06resign\x18\x06 \x01(\v2\x10.noitu.v1.ResignH\x00R\x06resign\x12$\n" + + "\x04ping\x18\a \x01(\v2\x0e.noitu.v1.PingH\x00R\x04pingB\t\n" + + "\apayload\"\xa3\x01\n" + + "\aWelcome\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" + + "\fresume_token\x18\x02 \x01(\tR\vresumeToken\x12)\n" + + "\x10protocol_version\x18\x03 \x01(\rR\x0fprotocolVersion\x12+\n" + + "\x11accepted_nickname\x18\x04 \x01(\tR\x10acceptedNickname\"*\n" + + "\vRoomCreated\x12\x1b\n" + + "\troom_code\x18\x01 \x01(\tR\broomCode\"N\n" + + "\n" + + "RoomJoined\x12\x1b\n" + + "\troom_code\x18\x01 \x01(\tR\broomCode\x12#\n" + + "\ropponent_name\x18\x02 \x01(\tR\fopponentName\"\x81\x01\n" + + "\n" + + "PlayedWord\x12\x12\n" + + "\x04word\x18\x01 \x01(\tR\x04word\x12\x13\n" + + "\x05by_me\x18\x02 \x01(\bR\x04byMe\x12\x16\n" + + "\x06points\x18\x03 \x01(\rR\x06points\x12\x1c\n" + + "\tsyllables\x18\x04 \x01(\rR\tsyllables\x12\x14\n" + + "\x05typed\x18\x05 \x01(\tR\x05typed\"\xdd\x01\n" + + "\vGameStarted\x12!\n" + + "\fopening_word\x18\x01 \x01(\tR\vopeningWord\x12)\n" + + "\x10current_syllable\x18\x02 \x01(\tR\x0fcurrentSyllable\x12\x17\n" + + "\amy_turn\x18\x03 \x01(\bR\x06myTurn\x12(\n" + + "\x10deadline_unix_ms\x18\x04 \x01(\x03R\x0edeadlineUnixMs\x12\x19\n" + + "\bturn_seq\x18\x05 \x01(\rR\aturnSeq\x12\"\n" + + "\rturn_limit_ms\x18\x06 \x01(\rR\vturnLimitMs\"\xa8\x02\n" + + "\n" + + "TurnUpdate\x12,\n" + + "\x06played\x18\x01 \x01(\v2\x14.noitu.v1.PlayedWordR\x06played\x12)\n" + + "\x10current_syllable\x18\x02 \x01(\tR\x0fcurrentSyllable\x12\x17\n" + + "\amy_turn\x18\x03 \x01(\bR\x06myTurn\x12(\n" + + "\x10deadline_unix_ms\x18\x04 \x01(\x03R\x0edeadlineUnixMs\x12\x19\n" + + "\bturn_seq\x18\x05 \x01(\rR\aturnSeq\x12\x19\n" + + "\bmy_score\x18\x06 \x01(\rR\amyScore\x12%\n" + + "\x0eopponent_score\x18\a \x01(\rR\ropponentScore\x12!\n" + + "\fchain_length\x18\b \x01(\rR\vchainLength\"m\n" + + "\fMoveRejected\x12.\n" + + "\x06reason\x18\x01 \x01(\x0e2\x16.noitu.v1.RejectReasonR\x06reason\x12\x12\n" + + "\x04word\x18\x02 \x01(\tR\x04word\x12\x19\n" + + "\bturn_seq\x18\x03 \x01(\rR\aturnSeq\"\x8e\x01\n" + + "\bGameOver\x12\x13\n" + + "\x05i_won\x18\x01 \x01(\bR\x04iWon\x12/\n" + + "\x06reason\x18\x02 \x01(\x0e2\x17.noitu.v1.GameEndReasonR\x06reason\x12\x19\n" + + "\bmy_score\x18\x03 \x01(\rR\amyScore\x12!\n" + + "\fchain_length\x18\x04 \x01(\rR\vchainLength\"N\n" + + "\fOpponentLeft\x12#\n" + + "\rcan_reconnect\x18\x01 \x01(\bR\fcanReconnect\x12\x19\n" + + "\bgrace_ms\x18\x02 \x01(\rR\agraceMs\";\n" + + "\vServerError\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"R\n" + + "\x04Pong\x12$\n" + + "\x0eclient_time_ms\x18\x01 \x01(\x03R\fclientTimeMs\x12$\n" + + "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xb9\x04\n" + + "\rServerMessage\x12-\n" + + "\awelcome\x18\x01 \x01(\v2\x11.noitu.v1.WelcomeH\x00R\awelcome\x12:\n" + + "\froom_created\x18\x02 \x01(\v2\x15.noitu.v1.RoomCreatedH\x00R\vroomCreated\x127\n" + + "\vroom_joined\x18\x03 \x01(\v2\x14.noitu.v1.RoomJoinedH\x00R\n" + + "roomJoined\x12:\n" + + "\fgame_started\x18\x04 \x01(\v2\x15.noitu.v1.GameStartedH\x00R\vgameStarted\x127\n" + + "\vturn_update\x18\x05 \x01(\v2\x14.noitu.v1.TurnUpdateH\x00R\n" + + "turnUpdate\x12=\n" + + "\rmove_rejected\x18\x06 \x01(\v2\x16.noitu.v1.MoveRejectedH\x00R\fmoveRejected\x121\n" + + "\tgame_over\x18\a \x01(\v2\x12.noitu.v1.GameOverH\x00R\bgameOver\x12=\n" + + "\ropponent_left\x18\b \x01(\v2\x16.noitu.v1.OpponentLeftH\x00R\fopponentLeft\x12-\n" + + "\x05error\x18\t \x01(\v2\x15.noitu.v1.ServerErrorH\x00R\x05error\x12$\n" + + "\x04pong\x18\n" + + " \x01(\v2\x0e.noitu.v1.PongH\x00R\x04pongB\t\n" + + "\apayload*i\n" + + "\n" + + "Difficulty\x12\x1a\n" + + "\x16DIFFICULTY_UNSPECIFIED\x10\x00\x12\x13\n" + + "\x0fDIFFICULTY_EASY\x10\x01\x12\x15\n" + + "\x11DIFFICULTY_MEDIUM\x10\x02\x12\x13\n" + + "\x0fDIFFICULTY_HARD\x10\x03*\x8e\x02\n" + + "\fRejectReason\x12\x1d\n" + + "\x19REJECT_REASON_UNSPECIFIED\x10\x00\x12#\n" + + "\x1fREJECT_REASON_TOO_FEW_SYLLABLES\x10\x01\x12\x1c\n" + + "\x18REJECT_REASON_WRONG_LINK\x10\x02\x12#\n" + + "\x1fREJECT_REASON_NOT_IN_DICTIONARY\x10\x03\x12\x1e\n" + + "\x1aREJECT_REASON_ALREADY_USED\x10\x04\x12\x1f\n" + + "\x1bREJECT_REASON_NOT_YOUR_TURN\x10\x05\x12\x19\n" + + "\x15REJECT_REASON_TIMEOUT\x10\x06\x12\x1b\n" + + "\x17REJECT_REASON_GAME_OVER\x10\a*\xb1\x01\n" + + "\rGameEndReason\x12\x1f\n" + + "\x1bGAME_END_REASON_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17GAME_END_REASON_TIMEOUT\x10\x01\x12!\n" + + "\x1dGAME_END_REASON_NO_LEGAL_MOVE\x10\x02\x12!\n" + + "\x1dGAME_END_REASON_OPPONENT_LEFT\x10\x03\x12\x1c\n" + + "\x18GAME_END_REASON_RESIGNED\x10\x04B:Z8github.com/tiennm99dev/noitu/server/gen/noitu/v1;noituv1b\x06proto3" + +var ( + file_noitu_v1_game_proto_rawDescOnce sync.Once + file_noitu_v1_game_proto_rawDescData []byte +) + +func file_noitu_v1_game_proto_rawDescGZIP() []byte { + file_noitu_v1_game_proto_rawDescOnce.Do(func() { + file_noitu_v1_game_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_noitu_v1_game_proto_rawDesc), len(file_noitu_v1_game_proto_rawDesc))) + }) + return file_noitu_v1_game_proto_rawDescData +} + +var file_noitu_v1_game_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_noitu_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 20) +var file_noitu_v1_game_proto_goTypes = []any{ + (Difficulty)(0), // 0: noitu.v1.Difficulty + (RejectReason)(0), // 1: noitu.v1.RejectReason + (GameEndReason)(0), // 2: noitu.v1.GameEndReason + (*Hello)(nil), // 3: noitu.v1.Hello + (*StartBotGame)(nil), // 4: noitu.v1.StartBotGame + (*CreateRoom)(nil), // 5: noitu.v1.CreateRoom + (*JoinRoom)(nil), // 6: noitu.v1.JoinRoom + (*SubmitWord)(nil), // 7: noitu.v1.SubmitWord + (*Resign)(nil), // 8: noitu.v1.Resign + (*Ping)(nil), // 9: noitu.v1.Ping + (*ClientMessage)(nil), // 10: noitu.v1.ClientMessage + (*Welcome)(nil), // 11: noitu.v1.Welcome + (*RoomCreated)(nil), // 12: noitu.v1.RoomCreated + (*RoomJoined)(nil), // 13: noitu.v1.RoomJoined + (*PlayedWord)(nil), // 14: noitu.v1.PlayedWord + (*GameStarted)(nil), // 15: noitu.v1.GameStarted + (*TurnUpdate)(nil), // 16: noitu.v1.TurnUpdate + (*MoveRejected)(nil), // 17: noitu.v1.MoveRejected + (*GameOver)(nil), // 18: noitu.v1.GameOver + (*OpponentLeft)(nil), // 19: noitu.v1.OpponentLeft + (*ServerError)(nil), // 20: noitu.v1.ServerError + (*Pong)(nil), // 21: noitu.v1.Pong + (*ServerMessage)(nil), // 22: noitu.v1.ServerMessage +} +var file_noitu_v1_game_proto_depIdxs = []int32{ + 0, // 0: noitu.v1.StartBotGame.difficulty:type_name -> noitu.v1.Difficulty + 3, // 1: noitu.v1.ClientMessage.hello:type_name -> noitu.v1.Hello + 4, // 2: noitu.v1.ClientMessage.start_bot_game:type_name -> noitu.v1.StartBotGame + 5, // 3: noitu.v1.ClientMessage.create_room:type_name -> noitu.v1.CreateRoom + 6, // 4: noitu.v1.ClientMessage.join_room:type_name -> noitu.v1.JoinRoom + 7, // 5: noitu.v1.ClientMessage.submit_word:type_name -> noitu.v1.SubmitWord + 8, // 6: noitu.v1.ClientMessage.resign:type_name -> noitu.v1.Resign + 9, // 7: noitu.v1.ClientMessage.ping:type_name -> noitu.v1.Ping + 14, // 8: noitu.v1.TurnUpdate.played:type_name -> noitu.v1.PlayedWord + 1, // 9: noitu.v1.MoveRejected.reason:type_name -> noitu.v1.RejectReason + 2, // 10: noitu.v1.GameOver.reason:type_name -> noitu.v1.GameEndReason + 11, // 11: noitu.v1.ServerMessage.welcome:type_name -> noitu.v1.Welcome + 12, // 12: noitu.v1.ServerMessage.room_created:type_name -> noitu.v1.RoomCreated + 13, // 13: noitu.v1.ServerMessage.room_joined:type_name -> noitu.v1.RoomJoined + 15, // 14: noitu.v1.ServerMessage.game_started:type_name -> noitu.v1.GameStarted + 16, // 15: noitu.v1.ServerMessage.turn_update:type_name -> noitu.v1.TurnUpdate + 17, // 16: noitu.v1.ServerMessage.move_rejected:type_name -> noitu.v1.MoveRejected + 18, // 17: noitu.v1.ServerMessage.game_over:type_name -> noitu.v1.GameOver + 19, // 18: noitu.v1.ServerMessage.opponent_left:type_name -> noitu.v1.OpponentLeft + 20, // 19: noitu.v1.ServerMessage.error:type_name -> noitu.v1.ServerError + 21, // 20: noitu.v1.ServerMessage.pong:type_name -> noitu.v1.Pong + 21, // [21:21] is the sub-list for method output_type + 21, // [21:21] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name +} + +func init() { file_noitu_v1_game_proto_init() } +func file_noitu_v1_game_proto_init() { + if File_noitu_v1_game_proto != nil { + return + } + file_noitu_v1_game_proto_msgTypes[7].OneofWrappers = []any{ + (*ClientMessage_Hello)(nil), + (*ClientMessage_StartBotGame)(nil), + (*ClientMessage_CreateRoom)(nil), + (*ClientMessage_JoinRoom)(nil), + (*ClientMessage_SubmitWord)(nil), + (*ClientMessage_Resign)(nil), + (*ClientMessage_Ping)(nil), + } + file_noitu_v1_game_proto_msgTypes[19].OneofWrappers = []any{ + (*ServerMessage_Welcome)(nil), + (*ServerMessage_RoomCreated)(nil), + (*ServerMessage_RoomJoined)(nil), + (*ServerMessage_GameStarted)(nil), + (*ServerMessage_TurnUpdate)(nil), + (*ServerMessage_MoveRejected)(nil), + (*ServerMessage_GameOver)(nil), + (*ServerMessage_OpponentLeft)(nil), + (*ServerMessage_Error)(nil), + (*ServerMessage_Pong)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_noitu_v1_game_proto_rawDesc), len(file_noitu_v1_game_proto_rawDesc)), + NumEnums: 3, + NumMessages: 20, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_noitu_v1_game_proto_goTypes, + DependencyIndexes: file_noitu_v1_game_proto_depIdxs, + EnumInfos: file_noitu_v1_game_proto_enumTypes, + MessageInfos: file_noitu_v1_game_proto_msgTypes, + }.Build() + File_noitu_v1_game_proto = out.File + file_noitu_v1_game_proto_goTypes = nil + file_noitu_v1_game_proto_depIdxs = nil +} diff --git a/server/go.mod b/server/go.mod index 07c7588..3c5b235 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( golang.org/x/text v0.41.0 + google.golang.org/protobuf v1.36.12 modernc.org/sqlite v1.58.0 ) @@ -18,3 +19,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect ) + +tool google.golang.org/protobuf/cmd/protoc-gen-go diff --git a/server/go.sum b/server/go.sum index 8d27bc6..d38bc7f 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,5 +1,7 @@ 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/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/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 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/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= 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/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= diff --git a/server/internal/game/state.go b/server/internal/game/state.go index 584c689..b3b47b3 100644 --- a/server/internal/game/state.go +++ b/server/internal/game/state.go @@ -41,6 +41,14 @@ const ( ReasonAlreadyUsed ReasonTimeout 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 { @@ -89,6 +97,10 @@ const ( EndTimeout EndNoLegalMove 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 { diff --git a/server/internal/wsapi/convert.go b/server/internal/wsapi/convert.go new file mode 100644 index 0000000..2656902 --- /dev/null +++ b/server/internal/wsapi/convert.go @@ -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), + } +} diff --git a/server/internal/wsapi/convert_test.go b/server/internal/wsapi/convert_test.go new file mode 100644 index 0000000..b72fba8 --- /dev/null +++ b/server/internal/wsapi/convert_test.go @@ -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 +} diff --git a/server/internal/wsapi/wire_test.go b/server/internal/wsapi/wire_test.go new file mode 100644 index 0000000..3e1cd44 --- /dev/null +++ b/server/internal/wsapi/wire_test.go @@ -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))] +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..59cb676 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1677 @@ +{ + "name": "noitu-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "noitu-web", + "version": "0.0.0", + "dependencies": { + "@bufbuild/protobuf": "^2.14.1" + }, + "devDependencies": { + "@bufbuild/protoc-gen-es": "^2.14.1", + "vitest": "^3.2.4" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.14.1.tgz", + "integrity": "sha512-agRJn3+EJDUe8AvxTx/LnHA/GErvLE62pSaSk7+MwFOtOv8eWBu/qCq2qoZjBjVZ3C2aiFJCveuSs17KMkYGOw==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.14.1.tgz", + "integrity": "sha512-agiGxvA1VAzWPdtOiktK39BNvGgHc1jHBCI1XYQtCQDSpt/5pZ7jTRGKv0fYzExbovDVu+yxJq4Srpv8zCIvUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.14.1", + "@bufbuild/protoplugin": "2.14.1" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.14.1" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.14.1.tgz", + "integrity": "sha512-oddqHkmUlBSo1TgswPW6hzrP/3nxMIdKUMcB6/NSY/oUkepDV7SealCZlE0ZxhLRIgmvDSuNWOb0HEKfsWPJww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.14.1", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..0dd84e1 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/lib/proto/noitu/v1/game_pb.d.ts b/web/src/lib/proto/noitu/v1/game_pb.d.ts new file mode 100644 index 0000000..a73e5dc --- /dev/null +++ b/web/src/lib/proto/noitu/v1/game_pb.d.ts @@ -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; + +/** + * @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; + +/** + * @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; + +/** + * @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; + +/** + * 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; + +/** + * @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; + +/** + * 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; + +/** + * @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; + +/** + * @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; + +/** + * @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; + +/** + * @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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * @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; + +/** + * @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; + +/** + * @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; + +/** + * 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; + +/** + * @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; + +/** + * @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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + diff --git a/web/src/lib/proto/noitu/v1/game_pb.js b/web/src/lib/proto/noitu/v1/game_pb.js new file mode 100644 index 0000000..053b651 --- /dev/null +++ b/web/src/lib/proto/noitu/v1/game_pb.js @@ -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); + diff --git a/web/tests/game-wire.test.js b/web/tests/game-wire.test.js new file mode 100644 index 0000000..75f6d18 --- /dev/null +++ b/web/tests/game-wire.test.js @@ -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'); + }); +});