diff --git a/Dockerfile b/Dockerfile index 153b1d7..ad787ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY web/ ./ RUN npm run build # --- the binary ------------------------------------------------------------- -FROM golang:1.25-alpine AS build +FROM golang:1-alpine AS build # What GET /version answers and the startup log line carries. .dockerignore # deliberately keeps .git out of the build context — a stale copy should @@ -35,7 +35,7 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" RUN CGO_ENABLED=0 go build -trimpath -o /out/build-dictionary ./cmd/build-dictionary # --- the dictionary --------------------------------------------------------- -FROM alpine:3.22 AS dict +FROM alpine:3 AS dict # Fetched fresh, not pinned: Wikimedia regenerates the dump monthly and # repoints `latest/`. The derived dictionary is the one thing in this image diff --git a/README.md b/README.md index f16bd08..0002cfb 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ Configuration is environment-only; every variable has a working default. | `NOITU_TRUSTED_PROXIES` | *(unset)* | Comma-separated proxy addresses or CIDRs whose `X-Forwarded-For` is believed. Unset keys limiters on the socket peer | | `NOITU_MAX_ROOMS` | `1000` | Ceiling on live rooms across the process; a creator past it is told `server_full` | | `NOITU_MAX_CONNECTIONS` | `2000` | Ceiling on open WebSockets; the next upgrade gets HTTP 503 | +| `NOITU_MAX_CONNECTIONS_PER_IP` | `0` (off) | Ceiling on open WebSockets from one address; the next upgrade from it gets HTTP 503. Leave this at `0` behind a reverse proxy unless `NOITU_TRUSTED_PROXIES` is set — otherwise every player behind it shares one address, and turning this on caps them all at whichever gets there first | | `NOITU_DEBUG_ADDR` | *(unset)* | A separate listen address for `GET /debug/vars` (expvar). Unset means the operational counters are not exposed anywhere | | `NOITU_DRAIN_TIMEOUT` | `0s` | How long a shutdown waits for live *games* (not lobbies) to finish before ending them anyway. `0s` is today's behaviour: end them immediately | diff --git a/docs/deployment.md b/docs/deployment.md index f6b77e5..b21a214 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -21,6 +21,7 @@ so the image runs with nothing set. | `NOITU_TRUSTED_PROXIES` | *(unset)* | Comma-separated proxy addresses or CIDRs whose `X-Forwarded-For` is believed. Unset keys limiters on the socket peer | | `NOITU_MAX_ROOMS` | `1000` | Ceiling on live rooms across the process; a creator past it is told `server_full` | | `NOITU_MAX_CONNECTIONS` | `2000` | Ceiling on open WebSockets; the next upgrade gets HTTP 503 | +| `NOITU_MAX_CONNECTIONS_PER_IP` | `0` (off) | Ceiling on open WebSockets from one address; the next upgrade from it gets HTTP 503 | | `NOITU_DEBUG_ADDR` | *(unset)* | Separate listen address for `GET /debug/vars` (expvar counters). Unset means the counters exist in the process but nothing serves them | | `NOITU_DRAIN_TIMEOUT` | `0s` | How long a shutdown waits for live games to finish before ending them anyway; see "Draining on deploy" below | @@ -168,14 +169,25 @@ client past it is disconnected rather than throttled. The defaults are generous for one binary on a small host; lower them if memory is tight, because a room is a goroutine and an engine held for up to its idle window. +A third ceiling, `NOITU_MAX_CONNECTIONS_PER_IP`, bounds how many of those +sockets one address may hold at once, and it is off by default. Turning it on +is safe only once the client's own address (above) is the real one: behind a +reverse proxy with `NOITU_TRUSTED_PROXIES` unset, every player shares the +proxy's own address, and the cap would seat one of them and refuse the rest. + ## Observability Set `NOITU_DEBUG_ADDR` to a second listen address — one that is not the one -players reach — to expose `GET /debug/vars` there: standard-library -[`expvar`](https://pkg.go.dev/expvar), zero extra dependencies, a JSON object -of process counters refreshed on every write. It is never mounted on the -public address, unset or not, so leaving `NOITU_DEBUG_ADDR` unset is the same -as not having it. The counters, all prefixed `noitu_`: connections open and +players reach, and never a public interface — to expose `GET /debug/vars` +there: standard-library [`expvar`](https://pkg.go.dev/expvar), zero extra +dependencies, a JSON object of process counters refreshed on every write. It +is never mounted on the public address, unset or not, so leaving +`NOITU_DEBUG_ADDR` unset is the same as not having it. Besides the counters +below, expvar always publishes the process's full command line and its +runtime memory statistics; that is the standard library's own doing, not +something this server adds, and it is the whole reason `/debug/vars` lives on +a separate address rather than a route on the public mux one config change +could expose. The counters, all prefixed `noitu_`: connections open and total; rooms live and total, each split `bot`/`pvp`; games started and finished the same way; words submitted, accepted, and rejected by reason; eliminations by reason; chat lines; join attempts refused, by whether it was @@ -258,6 +270,18 @@ turn clock already bounds how long any one game can take, so a timeout a little over `NOITU_TURN_LIMIT` covers the common case of a handful of games mid-turn. +## Resuming from a second tab + +A resume token is a bearer credential: whoever presents a live one takes the +seat, and the connection that held it before is closed. Opening the same +game in a second tab, or reloading with the token still in `localStorage`, is +therefore a takeover, not a copy — the newest connection to present the token +wins the seat, on purpose. There is no liveness check on the connection being +replaced beyond that; nothing here treats a second tab as an attack, because +the token already proves it came from the same player. A future version that +wants two tabs to share a seat, rather than fight over it, would need a +different design — this one intentionally does not. + ## What a restart costs A restart with `NOITU_DRAIN_TIMEOUT` unset, or a signal harder than `SIGTERM`, diff --git a/plans/reports/fullstack-developer-260921-1535-server-review-actions.md b/plans/reports/fullstack-developer-260921-1535-server-review-actions.md new file mode 100644 index 0000000..1712a99 --- /dev/null +++ b/plans/reports/fullstack-developer-260921-1535-server-review-actions.md @@ -0,0 +1,184 @@ +# Server architecture review — actions taken + +Branch `worktree-agent-a38a77d87cb0d7781` (rebased onto `dev`@`3ef9f48` — see +note below) · worktree +`/workspace/tiennm99/noitu/.claude/worktrees/agent-a38a77d87cb0d7781` + +## Worktree base — corrected before starting + +The worktree handed to me was branched from an ancestor roughly 18 commits +behind `dev`@`3ef9f48` (missing quick-match, drain mode, dead-end claims, word +reports, counters and readiness — everything the review and this task assume +exists). `git status` showed a clean tree and the branch was a strict ancestor +of `3ef9f48` with zero commits of its own, so I fast-forwarded it +(`git merge --ff-only 3ef9f48`) before touching anything. No content was lost; +this was a pure catch-up. + +## Deliverables + +**1. Join limiter** — `session.go`: `joinsPerSecond`/`joinBurst` raised +1/5 → 5/20, comment rewritten with the room-code-space (31^6) and +CGNAT-egress reasoning the review gives. No existing test pinned the old +numbers. + +**2. Per-IP concurrent-connection cap** — `server.go`: `Config.MaxConnectionsPerIP` +(env `NOITU_MAX_CONNECTIONS_PER_IP`, default `0` = off), enforced in +`handleWS` before `websocket.Accept`, HTTP 503 like the existing global cap. +`reserveIP`/`releaseIP` guarded by their own mutex, independent of the hub's. +Documented in both env tables (README.md, docs/deployment.md) with the +proxy warning. Tests: refuses past the cap, off by default, releases its slot +on disconnect (`limits_test.go`). + +**3. Ghost seat** — `room.go` (now split, see below): `handleCreate`, +`handleJoin`, `handleStartBot` and `handleResume` all check +`sess.ctx.Err() != nil` right after seating. `handleCreate`/`handleJoin`/ +`handleResume` reopen the grace window via a new `disconnectGhostSeat` helper +(the same effect `handleDisconnect` produces) so `allConnected()` reports the +seat honestly and quick match's auto-start cannot fire against it. +`handleStartBot` cancels the room instead — a bot room has no lobby to fall +back to and no idle timer while there is no engine yet, so a grace window +there would orphan the room forever. Five direct room-level tests, in the +style of `TestQuickMatchSkipsAWaiterWhoseConnectionEnded` (a `*session` built +with an already-cancelled `ctx`), plus one confirming quick-match auto-start +is skipped end to end. + +**4. Drain refuses new games** — `handleLobby`'s `lobbyStart` arm and +`handleJoin`'s quick-match auto-start both check `r.hub.isDraining()` and +refuse with `server_restarting`, matching what `newRegisteredRoom` already +does for a brand-new room. Tests: an existing lobby's `StartGame` is refused +after `StartDraining`; quick-match's auto-start is refused the same way at +the room level; a game already running still finishes and decrements +`LiveGameCount` normally. + +**5. Non-resumable resume token** — `session.go` `handleHello`: a non-empty +resume token that does not resolve to a live session now gets +`session_not_resumable` (the code the web client's table already has) instead +of silence, and the connection continues as a fresh session. An empty token +(no resume attempted) gets nothing, as before. Tests: the answer arrives and +the connection stays usable; a fresh Hello with no token gets no such error. + +**6. Drop `History` from `game.State`** — `engine.go`: `Engine.LastMove() +(Move, bool)` and `Engine.UsedWords() iter.Seq[string]` (backed directly by +the engine's own `used` map, which already contains the opening word) added; +`State.History` removed; `Standings()` only computed in `Snapshot()` when +`e.over`. Two call sites migrated: `room.go`'s resume replay now uses +`LastMove()`, and `freezeBoard` now builds its used-set from `UsedWords()` +directly (dropping the `opening` parameter it no longer needs — the engine's +set already includes it). `engine_test.go`/`multiplayer_test.go` updated; +added `TestLastMove` and `TestUsedWords`. Full `game` package still green. + +**7. File splits** — mechanical, verified by counting: every top-level +declaration from the original file appears exactly once afterward. + - `room.go` (1919 lines) → `room.go` (517, struct/constructor/run loop/seat + helpers), `room_inputs.go` (125, message types), `room_lobby.go` (375, + seating and the lobby), `room_game.go` (622, everything touching a + running game), `room_presence.go` (177, reconnect window and resume), + `room_chat.go` (125, the room's conversation), `bot_board.go` (48, the + bot's frozen board). + - `session.go` (762 lines) → `session.go` (445, the socket) and + `dispatch.go` (334, the protocol: `dispatch`, `handleHello`, `resumeFrom`, + `handleSubmit`, `handleReportWord`, `toRoom`, `roomCreateError`, + `leaveRoom`). + - `wsapi_test.go` (2688 lines, 82 tests) → `wsapi_test.go` (615, harness + only: dictionary fixture, server-over-socket helpers, client driving + methods), `game_test.go`, `presence_test.go`, `resume_test.go`, + `lobby_test.go`, `protocol_test.go`, `ratelimit_test.go`, + `bot_board_test.go`, `chat_test.go`, `deadend_test.go`, `report_test.go`. + Three nickname-sanitizing tests moved into the already-existing + `nickname_test.go` alongside its fuzz target. + - `hub_test.go` (3-line comment stub) deleted; its note now lives next to + `hub.go`'s `roomCount`. + - Not done: `hub.go`'s optional `quickmatch.go`/`roomcode.go` split — that + was review §1's "worth doing" suggestion, not one of the 10 assigned + items, and hub.go is not oversized (403 lines, one clear mutex + discipline). Left alone per scope. + +**8. `cmd/noitu-server` tests** — `main_test.go`: `env`, `envInt`, +`envDuration`, `envNonNegDuration`, `envList` all covered for their fallback, +validation and trim behaviour. `waitForGamesToFinish`'s `*wsapi.Server` +parameter narrowed to a `gameCounter` interface (its one method) so the +poll-then-check timing is testable with a fake — covers the "returns as soon +as the count hits zero", "respects its timeout", and "zero timeout returns +immediately" cases. `run()`/`main()`/`newDebugServer` remain untested — they +need a real listener and are out of the review's own recommended scope +("main_test.go for the env parsers + drain loop"). Package coverage: 0% → +33.8%. + +**9. Dockerfile** — `golang:1.25-alpine` → `golang:1-alpine`, +`alpine:3.22` → `alpine:3`. `.github/dependabot.yml` checked: still covers +the same four ecosystems correctly; no change needed, it will simply have +less to propose now that these two also float. + +**10. Decisions recorded** in `docs/deployment.md` — a new "Resuming from a +second tab" section states the two-tab takeover is intended (newest +connection with a live token wins the seat, on purpose); the `/debug/vars` +paragraph now says explicitly that expvar always publishes the process's +argv and heap stats, which is why it lives on a separate address and must +never bind a public interface. + +## Verification tail + +``` +$ gofmt -l . +(clean) +$ go vet ./... +(clean) +$ golangci-lint run ./... +0 issues. +$ go test ./... -race -count=1 +ok .../server/cmd/build-dictionary +ok .../server/cmd/noitu-server +ok .../server/internal/bot +ok .../server/internal/dictionary +ok .../server/internal/game +ok .../server/internal/vietnamese +ok .../server/internal/wsapi +``` + +Coverage: `vietnamese` 100, `game` 95.6 (was 95.5), `bot` 91.2, `wsapi` 91.3 +(was 91.1), `dictionary` 89.0, `build-dictionary` 88.6, `cmd/noitu-server` +33.8 (was 0.0). + +`git diff --stat 3ef9f48 HEAD` touches only `Dockerfile`, `README.md`, +`docs/deployment.md`, `server/**` — nothing under `web/`, `proto/`, +`server/gen/`, or `.github/`. + +## Commits (9, on this branch, not pushed) + +1. `fix(server): cap concurrent connections per client IP` +2. `fix(wsapi): loosen the join limiter and answer a non-resumable token` +3. `fix(wsapi): guard room seating races, refuse games during drain, and drop the history copy` +4. `test(wsapi): cover ghost seats, drain refusals and a silent resume` +5. `test(server): cover the env parsers and the drain-wait loop` +6. `build(docker): track the moving golang and alpine majors` +7. `docs: document the per-IP cap and record two open decisions` +8. `refactor(wsapi): split room.go and session.go along their seams` +9. `refactor(wsapi): split wsapi_test.go by topic, delete hub_test.go` + +## Deferred (explicitly out of the 10 assigned items) + +- **C4** (word_rejected log volume) — sampling or dropping to Debug, not done. +- **C5** (`dispatch`'s missing `default` arm / `unknown_message`) — not done. +- **C6** (`Config.IdleFor` dead knob) — left as-is; review's own unresolved + question, needs a product decision (wire `NOITU_IDLE_TIMEOUT` or delete the + field). +- **C9** (`reserveCode` before the `maxRooms` check) — cheap reorder, not done. +- Review §6's `make test-go` / `lint-go` Makefile target — not done, not one + of the 10 items. +- 85% coverage floor in CI — not done; review's own unresolved question on + whether `cmd/` should be held to it. +- Community allowlist overlay (§3) — design-only in the review, no code + expected. + +## Unresolved questions + +None blocking. The two decisions the review flagged as needing a call (C2's +exact rate, C8's takeover semantics) were both resolved by this task's +explicit instructions (5/s burst 20; takeover is intended and now +documented). The deferred items above are all ones the review itself marked +as open product questions (C6, the coverage floor) or as optional/lower +priority than the 10 assigned actions (C4, C5, C9, the Makefile target). + +Status: DONE +Summary: All 10 review actions implemented on branch `worktree-agent-a38a77d87cb0d7781` at `/workspace/tiennm99/noitu/.claude/worktrees/agent-a38a77d87cb0d7781`, in 9 focused commits; gofmt/vet/golangci-lint/`go test -race` all clean. +Concerns: The worktree's starting branch was 18 commits stale relative to the `dev`@`3ef9f48` base the task specified; I fast-forwarded it before starting (see note above) rather than working against code that predated quick-match, drain mode and dead-end claims entirely. diff --git a/server/cmd/noitu-server/main.go b/server/cmd/noitu-server/main.go index 196c89a..dbbec7d 100644 --- a/server/cmd/noitu-server/main.go +++ b/server/cmd/noitu-server/main.go @@ -44,17 +44,18 @@ const ( var version = "dev" type config struct { - addr string - dbPath string - turnLimit time.Duration - grace time.Duration - allowedOrigins []string - webDir string - trustedProxies []string - maxRooms int - maxConnections int - debugAddr string - drainTimeout time.Duration + addr string + dbPath string + turnLimit time.Duration + grace time.Duration + allowedOrigins []string + webDir string + trustedProxies []string + maxRooms int + maxConnections int + maxConnectionsPerIP int + debugAddr string + drainTimeout time.Duration } func main() { @@ -89,14 +90,15 @@ func run() error { defer stop() api := wsapi.NewServer(ctx, store, wsapi.Config{ - TurnLimit: cfg.turnLimit, - GraceFor: cfg.grace, - AllowedOrigins: cfg.allowedOrigins, - WebDir: cfg.webDir, - TrustedProxies: cfg.trustedProxies, - MaxRooms: cfg.maxRooms, - MaxConnections: cfg.maxConnections, - Version: version, + TurnLimit: cfg.turnLimit, + GraceFor: cfg.grace, + AllowedOrigins: cfg.allowedOrigins, + WebDir: cfg.webDir, + TrustedProxies: cfg.trustedProxies, + MaxRooms: cfg.maxRooms, + MaxConnections: cfg.maxConnections, + MaxConnectionsPerIP: cfg.maxConnectionsPerIP, + Version: version, }) srv := &http.Server{ @@ -167,6 +169,12 @@ func newDebugServer(addr string) *http.Server { return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} } +// gameCounter is the drain loop's only dependency on *wsapi.Server, narrowed +// so the polling logic can be tested without a live server behind it. +type gameCounter interface { + LiveGameCount() int64 +} + // waitForGamesToFinish blocks until every room's game has ended or timeout // passes, whichever is first. timeout <= 0 returns immediately, which is // today's behaviour: rooms are told the server is restarting and torn down @@ -175,7 +183,7 @@ func newDebugServer(addr string) *http.Server { // Only games count, not lobbies: a room nobody has started a game in has // nothing a restart costs, and waiting for it would make every deploy sit out // somebody's abandoned tab for the full timeout. -func waitForGamesToFinish(api *wsapi.Server, timeout time.Duration) { +func waitForGamesToFinish(api gameCounter, timeout time.Duration) { if timeout <= 0 { return } @@ -199,17 +207,18 @@ func waitForGamesToFinish(api *wsapi.Server, timeout time.Duration) { func loadConfig() config { return config{ - addr: env("NOITU_ADDR", defaultAddr), - dbPath: env("NOITU_DB_PATH", defaultDBPath), - turnLimit: envDuration("NOITU_TURN_LIMIT", defaultTurnLimit), - grace: envDuration("NOITU_GRACE", defaultGrace), - allowedOrigins: envList("NOITU_ALLOWED_ORIGINS"), - webDir: env("NOITU_WEB_DIR", ""), - trustedProxies: envList("NOITU_TRUSTED_PROXIES"), - maxRooms: envInt("NOITU_MAX_ROOMS", 0), - maxConnections: envInt("NOITU_MAX_CONNECTIONS", 0), - debugAddr: env("NOITU_DEBUG_ADDR", ""), - drainTimeout: envNonNegDuration("NOITU_DRAIN_TIMEOUT", 0), + addr: env("NOITU_ADDR", defaultAddr), + dbPath: env("NOITU_DB_PATH", defaultDBPath), + turnLimit: envDuration("NOITU_TURN_LIMIT", defaultTurnLimit), + grace: envDuration("NOITU_GRACE", defaultGrace), + allowedOrigins: envList("NOITU_ALLOWED_ORIGINS"), + webDir: env("NOITU_WEB_DIR", ""), + trustedProxies: envList("NOITU_TRUSTED_PROXIES"), + maxRooms: envInt("NOITU_MAX_ROOMS", 0), + maxConnections: envInt("NOITU_MAX_CONNECTIONS", 0), + maxConnectionsPerIP: envInt("NOITU_MAX_CONNECTIONS_PER_IP", 0), + debugAddr: env("NOITU_DEBUG_ADDR", ""), + drainTimeout: envNonNegDuration("NOITU_DRAIN_TIMEOUT", 0), } } diff --git a/server/cmd/noitu-server/main_test.go b/server/cmd/noitu-server/main_test.go new file mode 100644 index 0000000..0409a4f --- /dev/null +++ b/server/cmd/noitu-server/main_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "sync" + "testing" + "time" +) + +// TestEnvFallsBackWhenUnsetOrBlank covers env's whole contract: an unset +// variable and a blank one are the same "nothing configured" signal, since an +// operator's empty override should not silently defeat a default. +func TestEnvFallsBackWhenUnsetOrBlank(t *testing.T) { + const key = "NOITU_TEST_ENV_STRING" + + if got := env(key, "fallback"); got != "fallback" { + t.Errorf("unset: env = %q, want the fallback", got) + } + + t.Setenv(key, " ") + if got := env(key, "fallback"); got != "fallback" { + t.Errorf("blank: env = %q, want the fallback", got) + } + + t.Setenv(key, " configured ") + if got := env(key, "fallback"); got != "configured" { + t.Errorf("set: env = %q, want the trimmed value", got) + } +} + +// TestEnvIntRejectsInvalidAndNegative: a production misconfiguration here +// must fall back loudly rather than silently becoming zero or panicking. +func TestEnvIntRejectsInvalidAndNegative(t *testing.T) { + const key = "NOITU_TEST_ENV_INT" + + if got := envInt(key, 7); got != 7 { + t.Errorf("unset: envInt = %d, want the fallback", got) + } + + tests := []struct { + name, value string + want int + }{ + {"valid", "42", 42}, + {"zero is a real value", "0", 0}, + {"negative rejected", "-1", 7}, + {"not a number rejected", "many", 7}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(key, tc.value) + if got := envInt(key, 7); got != tc.want { + t.Errorf("envInt(%q) = %d, want %d", tc.value, got, tc.want) + } + }) + } +} + +// TestEnvDurationRejectsInvalidAndZero: unlike envNonNegDuration, a plain +// timing variable rejects zero along with garbage — every one of today's +// durations (turn limit, grace) has to be positive to mean anything. +func TestEnvDurationRejectsInvalidAndZero(t *testing.T) { + const key = "NOITU_TEST_ENV_DURATION" + + if got := envDuration(key, time.Second); got != time.Second { + t.Errorf("unset: envDuration = %v, want the fallback", got) + } + + tests := []struct { + name, value string + want time.Duration + }{ + {"valid", "30s", 30 * time.Second}, + {"zero rejected", "0s", time.Second}, + {"negative rejected", "-5s", time.Second}, + {"not a duration rejected", "soon", time.Second}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(key, tc.value) + if got := envDuration(key, time.Second); got != tc.want { + t.Errorf("envDuration(%q) = %v, want %v", tc.value, got, tc.want) + } + }) + } +} + +// TestEnvNonNegDurationAcceptsZero is the one difference from envDuration: +// NOITU_DRAIN_TIMEOUT=0 is a deliberate "do not wait", not a typo, and must +// not be rejected the way a zero turn limit would be. +func TestEnvNonNegDurationAcceptsZero(t *testing.T) { + const key = "NOITU_TEST_ENV_NONNEG_DURATION" + + tests := []struct { + name, value string + want time.Duration + }{ + {"valid", "10s", 10 * time.Second}, + {"zero accepted", "0s", 0}, + {"negative rejected", "-1s", 5 * time.Second}, + {"not a duration rejected", "soon", 5 * time.Second}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(key, tc.value) + if got := envNonNegDuration(key, 5*time.Second); got != tc.want { + t.Errorf("envNonNegDuration(%q) = %v, want %v", tc.value, got, tc.want) + } + }) + } +} + +// TestEnvListSplitsAndTrims covers the shape NOITU_ALLOWED_ORIGINS and +// NOITU_TRUSTED_PROXIES both rely on: comma-separated, whitespace around each +// entry ignored, and empty entries dropped rather than becoming a stray "". +func TestEnvListSplitsAndTrims(t *testing.T) { + const key = "NOITU_TEST_ENV_LIST" + + if got := envList(key); got != nil { + t.Errorf("unset: envList = %v, want nil", got) + } + + t.Setenv(key, " a , b ,, c") + got := envList(key) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("envList = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("envList[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// fakeGameCounter answers LiveGameCount from a value a test can change while +// the drain loop is polling it, so the loop can be driven without a real +// server or rooms behind it. +type fakeGameCounter struct { + mu sync.Mutex + live int64 +} + +func (f *fakeGameCounter) set(n int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.live = n +} + +func (f *fakeGameCounter) LiveGameCount() int64 { + f.mu.Lock() + defer f.mu.Unlock() + return f.live +} + +// TestWaitForGamesToFinishReturnsAsSoonAsTheCountReachesZero: the loop must +// not sit out its whole timeout once the last game has actually ended. +func TestWaitForGamesToFinishReturnsAsSoonAsTheCountReachesZero(t *testing.T) { + counter := &fakeGameCounter{live: 1} + + done := make(chan struct{}) + go func() { + waitForGamesToFinish(counter, time.Minute) + close(done) + }() + + time.Sleep(50 * time.Millisecond) + counter.set(0) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("waitForGamesToFinish did not return once the count reached zero") + } +} + +// TestWaitForGamesToFinishRespectsTheTimeout: a game that never ends must not +// hang the drain forever — it has to give up once its budget is spent. +func TestWaitForGamesToFinishRespectsTheTimeout(t *testing.T) { + counter := &fakeGameCounter{live: 1} + + start := time.Now() + waitForGamesToFinish(counter, 250*time.Millisecond) + if elapsed := time.Since(start); elapsed < 250*time.Millisecond { + t.Errorf("returned after %v, want at least the 250ms timeout", elapsed) + } +} + +// TestWaitForGamesToFinishReturnsImmediatelyOnZeroTimeout: a zero or negative +// timeout is today's original behaviour — rooms are torn down with whatever +// they were doing rather than waited on at all. +func TestWaitForGamesToFinishReturnsImmediatelyOnZeroTimeout(t *testing.T) { + counter := &fakeGameCounter{live: 1} + + start := time.Now() + waitForGamesToFinish(counter, 0) + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Errorf("a zero timeout took %v, want an immediate return", elapsed) + } +} diff --git a/server/internal/game/engine.go b/server/internal/game/engine.go index 36b11b0..29ee707 100644 --- a/server/internal/game/engine.go +++ b/server/internal/game/engine.go @@ -10,6 +10,8 @@ package game import ( "errors" "fmt" + "iter" + "maps" "math/bits" "slices" "time" @@ -179,6 +181,25 @@ func (e *Engine) Winner() PlayerID { return e.winner } // ChainLength reports how many words have been played, opening word included. func (e *Engine) ChainLength() int { return len(e.history) + 1 } +// LastMove reports the most recently played word, and false when none has +// been played yet. It exists so a caller that only ever wants the tail of the +// chain — the resume replay is the one — does not have to copy the whole +// history to reach it. +func (e *Engine) LastMove() (Move, bool) { + if len(e.history) == 0 { + return Move{}, false + } + return e.history[len(e.history)-1], true +} + +// UsedWords iterates every canonical word already played, the opening word +// included. It is read directly off the engine's own set rather than rebuilt +// from history on every call, which is what a bot's board was doing once per +// move. +func (e *Engine) UsedWords() iter.Seq[string] { + return maps.Keys(e.used) +} + // Submit validates a player's word and, if legal, plays it. // // The returned Move carries the canonical spelling; on rejection the reason @@ -575,11 +596,19 @@ func (e *Engine) Snapshot() State { alive[p] = e.alive[i] } + // Standings is meaningless while the game is in play, by its own doc + // comment, so it is only worth computing once the game actually has one — + // otherwise every TurnUpdate and every bot move pays for a table nobody + // reads. + var standings []Standing + if e.over { + standings = e.Standings() + } + return State{ Current: e.current, Turn: e.Turn(), Deadline: e.deadline, - History: append([]Move{}, e.history...), Scores: scores, Alive: alive, Eliminated: append([]PlayerID{}, e.outOrder...), @@ -587,6 +616,6 @@ func (e *Engine) Snapshot() State { Over: e.over, Winner: e.winner, EndReason: e.endReason, - Standings: e.Standings(), + Standings: standings, } } diff --git a/server/internal/game/engine_test.go b/server/internal/game/engine_test.go index 74b98c0..1f6ecd1 100644 --- a/server/internal/game/engine_test.go +++ b/server/internal/game/engine_test.go @@ -756,18 +756,60 @@ func TestSnapshotIsACopy(t *testing.T) { e.Submit(alice, "ngữ pháp", t0) snap := e.Snapshot() - snap.History[0].Word = "MUTATED" snap.Scores[alice] = 9999 fresh := e.Snapshot() - if fresh.History[0].Word == "MUTATED" { - t.Error("mutating a snapshot's history changed engine state") - } if fresh.Scores[alice] == 9999 { t.Error("mutating a snapshot's scores changed engine state") } } +// LastMove is the resume replay's whole reason to exist: the tail of the +// chain without copying the rest of it. +func TestLastMove(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + if _, ok := e.LastMove(); ok { + t.Fatal("LastMove reported a move before any word was played") + } + + if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone { + t.Fatalf("Submit: %s", r) + } + move, ok := e.LastMove() + if !ok { + t.Fatal("LastMove reported none after a word was played") + } + if move.Word != "ngữ pháp" { + t.Errorf("LastMove = %q, want %q", move.Word, "ngữ pháp") + } + + if _, r := e.Submit(bob, "pháp luật", t0); r != ReasonNone { + t.Fatalf("Submit: %s", r) + } + move, ok = e.LastMove() + if !ok || move.Word != "pháp luật" { + t.Errorf("LastMove after a second word = %+v, %v, want %q", move, ok, "pháp luật") + } +} + +// UsedWords is the bot's search boundary: the opening word and everything +// played since, with nothing else in it. +func TestUsedWords(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone { + t.Fatalf("Submit: %s", r) + } + + used := map[string]bool{} + for w := range e.UsedWords() { + used[w] = true + } + if !used["ngôn ngữ"] || !used["ngữ pháp"] || len(used) != 2 { + t.Errorf("UsedWords = %v, want exactly the opening word and the move played", used) + } +} + func TestRejectReasonStrings(t *testing.T) { // Every reason needs a distinct, non-empty description: these become // player-facing messages. diff --git a/server/internal/game/multiplayer_test.go b/server/internal/game/multiplayer_test.go index ff9b421..81d81e8 100644 --- a/server/internal/game/multiplayer_test.go +++ b/server/internal/game/multiplayer_test.go @@ -212,8 +212,8 @@ func TestAnEliminatedPlayerKeepsTheirScore(t *testing.T) { if got := e.Score(alice); got != scored { t.Errorf("score after elimination = %d, want %d", got, scored) } - if state := e.Snapshot(); len(state.History) != 1 || state.History[0].Player != alice { - t.Errorf("the eliminated player's move is missing from the history: %+v", state.History) + if last, ok := e.LastMove(); !ok || last.Player != alice { + t.Errorf("the eliminated player's move is missing from the history: %+v, %v", last, ok) } if !slices.Contains(e.Players(), alice) { t.Error("Players dropped the eliminated seat, which the transport still has to render") diff --git a/server/internal/game/state.go b/server/internal/game/state.go index 9c7f637..de42bb3 100644 --- a/server/internal/game/state.go +++ b/server/internal/game/state.go @@ -186,7 +186,6 @@ type State struct { Current string Turn PlayerID Deadline time.Time - History []Move Scores map[PlayerID]int // Alive says who is still in the game. A player who has been eliminated // keeps their score and their place in the history; they simply no longer diff --git a/server/internal/wsapi/bot_board.go b/server/internal/wsapi/bot_board.go new file mode 100644 index 0000000..8d659e7 --- /dev/null +++ b/server/internal/wsapi/bot_board.go @@ -0,0 +1,48 @@ +package wsapi + +import ( + "iter" + + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// The bot's read-only view of a position, frozen off the engine on the room +// goroutine before a worker goroutine exists to race it. + +// frozenBoard is an immutable position for a bot worker to search. +// +// It satisfies bot.Board without holding the engine. The dictionary is safe to +// share — the store loads once at Open and is read-only thereafter — but the +// used set is engine state, so it is copied. +type frozenBoard struct { + legal []string + used map[string]struct{} + dict game.Dictionary +} + +func freezeBoard(e *game.Engine) *frozenBoard { + // UsedWords already includes the opening word — it seeds the engine's own + // set — so there is nothing left to add here, and nothing to copy out of a + // history that grows with the game. + used := make(map[string]struct{}) + for word := range e.UsedWords() { + used[word] = struct{}{} + } + + return &frozenBoard{legal: e.LegalMoves(), used: used, dict: e.Dict()} +} + +func (b *frozenBoard) LegalMoves() []string { return b.legal } + +func (b *frozenBoard) Used(word string) bool { + _, ok := b.used[word] + return ok +} + +func (b *frozenBoard) WordsStartingWith(syllable string) iter.Seq[string] { + return b.dict.WordsStartingWith(syllable) +} + +func (b *frozenBoard) LastSyllable(word string) (string, bool) { + return b.dict.LastSyllable(word) +} diff --git a/server/internal/wsapi/bot_board_test.go b/server/internal/wsapi/bot_board_test.go new file mode 100644 index 0000000..0f65205 --- /dev/null +++ b/server/internal/wsapi/bot_board_test.go @@ -0,0 +1,31 @@ +package wsapi + +import ( + "slices" + "testing" + "time" + + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// The bot's frozen view of a position. + +func TestFreezeBoardIncludesTheOpeningWord(t *testing.T) { + // The engine counts the opening word as played but Snapshot's history does + // not list it. A frozen board that missed it would let the bot choose a + // word the engine then rejects as already used — the two would disagree + // about the position while appearing to share one dictionary. + dict := chainDict() + e, err := game.New(dict, []game.PlayerID{"p1", "p2"}, "a b", time.Second, time.Now()) + if err != nil { + t.Fatalf("engine: %v", err) + } + + board := freezeBoard(e) + if !board.Used("a b") { + t.Error("frozen board does not consider the opening word played") + } + if !slices.Contains(board.LegalMoves(), "b c") { + t.Errorf("legal moves = %v, want the one continuation", board.LegalMoves()) + } +} diff --git a/server/internal/wsapi/chat_test.go b/server/internal/wsapi/chat_test.go new file mode 100644 index 0000000..27973b6 --- /dev/null +++ b/server/internal/wsapi/chat_test.go @@ -0,0 +1,480 @@ +package wsapi + +import ( + "fmt" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// The room's own conversation: who sees what, when, and what survives a +// seat being vacated. + +// TestChatReachesBothSeatsRenderedPerRecipient is the guard against from_me +// being computed once and shared, which would show a player their opponent's +// words as their own. +func TestChatReachesBothSeatsRenderedPerRecipient(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + host.say("Chào bạn") + + mine := host.await("chat_message").GetChatMessage() + theirs := guest.await("chat_message").GetChatMessage() + + if !mine.GetFromMe() { + t.Errorf("the sender was not shown their own message as theirs: %+v", mine) + } + if theirs.GetFromMe() { + t.Errorf("the recipient was shown the sender's message as their own: %+v", theirs) + } + if theirs.GetText() != "Chào bạn" || theirs.GetAuthor() == "" { + t.Errorf("the recipient's copy is wrong: %+v", theirs) + } + if theirs.GetSentUnixMs() == 0 { + t.Error("a message carries no time") + } + // The seat behind a line is the same fact for everybody: from_me is the + // only field that is relative to the reader, and a client colours a line + // by its author rather than by matching names. + if mine.GetPlayerId() != theirs.GetPlayerId() || theirs.GetPlayerId() == "" { + t.Errorf("the author's seat differs between recipients: %q and %q", + mine.GetPlayerId(), theirs.GetPlayerId()) + } +} + +// TestChatWorksInTheLobbyAndInAGame: the conversation belongs to the room, not +// to a game, so neither phase is a special case. +func TestChatWorksInTheLobbyAndInAGame(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + host.say("trước ván") + if got := guest.await("chat_message").GetChatMessage().GetText(); got != "trước ván" { + t.Errorf("lobby message = %q", got) + } + + // Started inline rather than through readyAndStart: pvpLobby has already + // drained the room states that helper waits for. + guest.setReady(true) + host.await("room_state") + host.startGame() + host.await("game_started") + guest.await("game_started") + + host.say("trong ván") + if got := guest.await("chat_message").GetChatMessage().GetText(); got != "trong ván" { + t.Errorf("in-game message = %q", got) + } +} + +// TestAJoinerSeesNothingSaidBeforeTheySatDown is the disclosure boundary. A +// room code is pasted into group chats by design, so redeeming one must not +// hand over a conversation the joiner was never part of. +func TestAJoinerSeesNothingSaidBeforeTheySatDown(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + host.say("bí mật") + host.await("chat_message") + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + + if got := guest.await("chat_history").GetChatHistory().GetMessages(); len(got) != 0 { + t.Errorf("a joiner was handed %d messages from before they arrived: %+v", len(got), got) + } + + // And from there they share a conversation like anybody else. + host.say("chào") + if got := guest.await("chat_message").GetChatMessage().GetText(); got != "chào" { + t.Errorf("message after joining = %q", got) + } +} + +// TestCreatingARoomReplaysAnEmptyHistory: it is what overwrites the panel a +// client may still be holding from the room it was in before this one. +func TestCreatingARoomReplaysAnEmptyHistory(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + c := dial(t, url) + c.hello("Chủ phòng") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + + if got := c.await("chat_history").GetChatHistory().GetMessages(); len(got) != 0 { + t.Errorf("a new room came with %d messages", len(got)) + } +} + +// TestChatHistoryIsCappedAndOrdered bounds what one room holds. Driven at the +// room directly: the cap is room state, and a socket would only add a rate +// limiter to wait out. +func TestChatHistoryIsCappedAndOrdered(t *testing.T) { + sess := offlineSession(t, chatHistoryLimit+64) + r := &room{ + code: "TESTRM", + seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: sess}}, + owner: "p1", + } + + const sent = chatHistoryLimit + 5 + for i := range sent { + r.handleChat(chatInput{sess: sess, player: "p1", text: fmt.Sprintf("tin %d", i)}) + } + + if len(r.chat) != chatHistoryLimit { + t.Fatalf("the room kept %d messages, want %d", len(r.chat), chatHistoryLimit) + } + if got, want := r.chat[0].text, fmt.Sprintf("tin %d", sent-chatHistoryLimit); got != want { + t.Errorf("oldest kept message = %q, want %q", got, want) + } + if got, want := r.chat[len(r.chat)-1].text, fmt.Sprintf("tin %d", sent-1); got != want { + t.Errorf("newest kept message = %q, want %q", got, want) + } + // The sequence keeps rising past the trim, which is what makes a seat's + // watermark meaningful after the entry it pointed at is gone. + if r.chatSeq != sent { + t.Errorf("chatSeq = %d after %d messages", r.chatSeq, sent) + } +} + +// TestChatHistorySurvivesAGame: the conversation is the room's, and a game +// starting and finishing inside it changes nothing about that. +func TestChatHistorySurvivesAGame(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + host := dial(t, url) + welcome := host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + host.await("room_state") + guest.await("room_state") + + host.say("trước ván") + host.await("chat_message") + + guest.setReady(true) + host.await("room_state") + host.startGame() + start := host.await("game_started").GetGameStarted() + guest.await("game_started") + resignAndSettle(t, host, guest, start) + + _ = host.conn.Close(websocket.StatusAbnormalClosure, "") + back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken()) + + if got := back.await("chat_history").GetChatHistory().GetMessages(); len(got) != 1 { + t.Errorf("a finished game left %d messages, want the 1 said before it", len(got)) + } +} + +// TestChatTextIsSanitizedAndCapped covers the three ways text is made safe to +// render in a stranger's browser. +func TestChatTextIsSanitizedAndCapped(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + // Invisible characters: a zero-width joiner and a bidi override. + host.say("xin" + string(zeroWidthSpace) + "chào" + string(bidiOverride)) + if got := guest.await("chat_message").GetChatMessage().GetText(); got != "xinchào" { + t.Errorf("invisible characters survived: %q", got) + } + + // A stack of combining marks renders as a glyph cluster tall enough to + // cover the board, and the rune cap alone does not stop it. + host.say("a" + strings.Repeat("\u0350", 199)) + stacked := guest.await("chat_message").GetChatMessage().GetText() + if marks := strings.Count(stacked, "\u0350"); marks > maxChatMarks { + t.Errorf("a %d-mark stack survived, want at most %d", marks, maxChatMarks) + } + + // Over the cap, cut on a rune boundary rather than through a character. + host.say(strings.Repeat("ữ", maxChatRunes+100)) + long := guest.await("chat_message").GetChatMessage().GetText() + if runes := []rune(long); len(runes) != maxChatRunes { + t.Errorf("a long message came back %d runes, want %d", len(runes), maxChatRunes) + } + if !utf8.ValidString(long) { + t.Error("the cap cut a character in half") + } +} + +// TestEmptyChatIsDroppedWithoutAnError: nothing survived the sanitizer, so +// there is no message to refuse and nobody who typed one. +func TestEmptyChatIsDroppedWithoutAnError(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + // Sent first, then a real one. Messages arrive in order, so a guest whose + // next message is the real one never received the empty one — and unlike + // silentFor, this leaves the connection alive to prove it. + host.say(" " + string(zeroWidthSpace) + " ") + host.say("thật") + + if got := guest.await("chat_message").GetChatMessage().GetText(); got != "thật" { + t.Errorf("the empty message was delivered as %q", got) + } + // And nothing follows it. + silentFor(t, guest, "chat_message", 250*time.Millisecond) +} + +// TestChatIsRateLimitedOnItsOwnBudget: a burst is refused, and it does not +// cost the sender their moves. +func TestChatIsRateLimitedOnItsOwnBudget(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, start := pvpRoom(t, url) + + // The player on turn does the talking, so the move that follows is one + // they are allowed to make. + mover, other := host, guest + if !start.GetMyTurn() { + mover, other = guest, host + } + + for range chatBurst + 5 { + mover.say("spam") + } + + var refused bool + for range 40 { + if payloadCase(mover.recv()) == "error" { + refused = true + break + } + } + if !refused { + t.Fatal("a burst of chat was never refused") + } + + // The move budget is separate, so a word still reaches the engine: the + // opponent seeing the turn arrive is the proof it was accepted. + mover.submit("b c", start.GetTurnSeq()) + awaitMyTurn(t, other) +} + +// TestChatFromASeatlessConnectionIsRefused: losing the seat is not losing the +// socket. A kicked player's connection is still open and still believes it was +// in a room, which is the reachable half of the guard that also covers a +// connection replaced by a reconnect. +func TestChatFromASeatlessConnectionIsRefused(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + host.kickPlayer("p2") + if got := guest.await("error").GetError().GetCode(); got != "kicked" { + t.Fatalf("the guest was told %q rather than being kicked", got) + } + + guest.say("cho tôi vào lại") + if got := guest.await("error").GetError().GetCode(); got != "not_in_a_room" { + t.Errorf("a kicked connection chatting returned %q, want not_in_a_room", got) + } + silentFor(t, host, "chat_message", 250*time.Millisecond) +} + +// TestChatNeedsASeat: holding a socket is not holding a seat. +func TestChatNeedsASeat(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + c := dial(t, url) + c.hello("Người chơi") + c.say("có ai không") + + if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" { + t.Errorf("chatting from no room returned %q, want not_in_a_room", got) + } +} + +// TestBotRoomHasNoChat: there is nobody to talk to, and the check belongs on +// the room goroutine, which is the only place that knows. +func TestBotRoomHasNoChat(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + c := dial(t, url) + c.hello("Người chơi") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + c.await("game_started") + + c.say("chào máy") + if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" { + t.Errorf("chatting at a bot returned %q, want not_in_a_room", got) + } +} + +// TestChatDoesNotKeepARoomAlive: talking is not playing. Without this a room +// is held open for the life of the process by one message every nine minutes. +func TestChatDoesNotKeepARoomAlive(t *testing.T) { + api, url := newTestServer(t, chainDict(), Config{IdleFor: 300 * time.Millisecond}) + + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + host.await("room_state") + + // Chatting throughout the window; the clock must keep running anyway. + for range 4 { + host.say("vẫn ở đây") + time.Sleep(100 * time.Millisecond) + } + + if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" { + t.Errorf("a chatted-in room closed with %q, want room_idle_closed", got) + } + awaitNoRooms(t, api, "a room held open by chat") +} + +// TestVacatedSeatKeepsItsWordsButLosesItsAuthor: the words stay, the +// attribution does not — checked from the side that stayed, because a name +// left behind is a name the next joiner can ask for. +func TestVacatedSeatKeepsItsWordsButLosesItsAuthor(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + host := dial(t, url) + welcome := host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + host.await("room_state") + guest.await("room_state") + + guest.say("tôi là khách") + host.await("chat_message") + guest.leaveRoom() + host.await("room_state") + + // The player who stayed reloads and reads the room back. + _ = host.conn.Close(websocket.StatusAbnormalClosure, "") + back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken()) + + history := back.await("chat_history").GetChatHistory().GetMessages() + if len(history) != 1 { + t.Fatalf("the departed player's words are gone: %+v", history) + } + if got := history[0]; got.GetText() != "tôi là khách" || got.GetAuthor() != "" || got.GetFromMe() { + t.Errorf("a vacated seat's message is still attributed: %+v", got) + } + // The seat goes with the name. A line still carrying it would be coloured + // as whoever fills that seat next. + if got := history[0].GetPlayerId(); got != "" { + t.Errorf("a vacated seat's message still names its seat: %q", got) + } +} + +// TestTheRemainingPlayerIsResyncedWhenASeatIsVacated is the live half of the +// authorship rule. Clearing the store is not enough: the player who stayed is +// already holding frames that carry the departed name, and nothing else in the +// protocol would correct them before a reload. +func TestTheRemainingPlayerIsResyncedWhenASeatIsVacated(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + guest.say("số của tôi là …") + host.await("chat_message") + + guest.leaveRoom() + + // No reload, no resume: the correction has to arrive on its own. + history := host.await("chat_history").GetChatHistory().GetMessages() + if len(history) != 1 { + t.Fatalf("the remaining player was resynced with %d messages, want 1", len(history)) + } + if got := history[0]; got.GetAuthor() != "" || got.GetText() != "số của tôi là …" { + t.Errorf("the departed player's line is still attributed: %+v", got) + } +} + +// TestChatFromAConnectionThatLostItsSeatIsRefused covers handleChat's own +// authorization, which the transport cannot reach: a frame already queued when +// the seat was freed arrives after it, and by then the seat may be somebody +// else's. Driven directly because that ordering is a race over the wire. +func TestChatFromAConnectionThatLostItsSeatIsRefused(t *testing.T) { + evicted := offlineSession(t, 8) + seated := offlineSession(t, 8) + + r := &room{ + code: "TESTRM", + seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: evicted}}, + owner: "p1", + } + + // The seat changes hands while this connection's message is in flight — + // a reconnect, or a kick and a new arrival. + r.seats[0].sess = seated + r.handleChat(chatInput{sess: evicted, player: "p1", text: "tôi vẫn ở đây"}) + + refusals := queued(t, evicted) + if len(refusals) != 1 || refusals[0].GetError().GetCode() != "not_your_seat" { + t.Fatalf("a connection with no seat was answered %+v, want not_your_seat", refusals) + } + if len(r.chat) != 0 { + t.Errorf("the room stored a message from a connection that holds no seat: %+v", r.chat) + } + if got := queued(t, seated); len(got) != 0 { + t.Errorf("the seated player was sent %d frames from an impostor", len(got)) + } +} + +// TestChatToAFullOutboxIsDroppedNotFatal is the guarantee the chat budget +// rests on: a player who cannot keep up loses a line, not their session. Every +// other frame closes a session at this point, which mid-game costs the game. +// +// Driven through handleChat rather than trySend directly, because the property +// under test is which delivery the chat path chooses — a test that called +// trySend itself would pass just as happily after somebody swapped the call +// site back to send. +func TestChatToAFullOutboxIsDroppedNotFatal(t *testing.T) { + sender := offlineSession(t, 8) + slow := offlineSession(t, 1) + slow.out <- []byte("already queued") + + r := &room{ + code: "TESTRM", + seats: [maxPlayers]*seat{ + {id: "p1", nickname: "Chủ phòng", sess: sender}, + {id: "p2", nickname: "Khách", sess: slow}, + }, + owner: "p1", + } + + r.handleChat(chatInput{sess: sender, player: "p1", text: "bạn còn đó không"}) + + select { + case <-slow.ctx.Done(): + t.Fatal("a chat line closed the session of the player who could not keep up") + default: + } + if len(r.chat) != 1 { + t.Errorf("the room stored %d messages, want 1", len(r.chat)) + } + // The sender is unaffected: their own copy went out and nothing was + // refused. + for _, m := range queued(t, sender) { + if m.GetError() != nil { + t.Errorf("the sender was refused: %q", m.GetError().GetCode()) + } + } +} diff --git a/server/internal/wsapi/deadend_test.go b/server/internal/wsapi/deadend_test.go new file mode 100644 index 0000000..746fd69 --- /dev/null +++ b/server/internal/wsapi/deadend_test.go @@ -0,0 +1,89 @@ +package wsapi + +import ( + "testing" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// Claiming a dead end: the syllable in play has no answer left. + +// TestClaimDeadEndEliminatesImmediately walks a player into a real dead end +// and has them claim it rather than wait out the clock. The outcome must be +// exactly what a timeout would have produced: NO_LEGAL_MOVE, no answerable +// suggestions. +func TestClaimDeadEndEliminatesImmediately(t *testing.T) { + // "b" starts nothing after "b c" is played, so whoever inherits "c" has no + // move at all. + _, url := newTestServer(t, newTestDict("a b", "b c"), Config{}) + host, guest, start := pvpRoom(t, url) + lead, stuck := host, guest + if !start.GetMyTurn() { + lead, stuck = guest, host + } + + lead.submit("b c", start.GetTurnSeq()) + lead.await("turn_update") + turn := stuck.await("turn_update").GetTurnUpdate() + if turn.GetCurrentSyllable() != "c" { + t.Fatalf("current syllable = %q, want %q", turn.GetCurrentSyllable(), "c") + } + if !turn.GetMyTurn() { + t.Fatal("the player left with the dead end should be on turn") + } + + stuck.claimDeadEnd() + + out := stuck.await("player_eliminated").GetPlayerEliminated() + if !out.GetIsMe() { + t.Error("the claimant should be the one eliminated") + } + if out.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE { + t.Errorf("reason = %v, want NO_LEGAL_MOVE", out.GetReason()) + } + if len(out.GetSuggestions()) != 0 { + t.Errorf("suggestions = %v, want none for a genuine dead end", out.GetSuggestions()) + } + + stuck.await("game_over") + lead.await("game_over") +} + +// TestClaimDeadEndRefusedWhenAMoveExists checks the false claim costs nothing +// but the answer: the clock is untouched, proven by the original turn_seq +// still being accepted afterwards. +func TestClaimDeadEndRefusedWhenAMoveExists(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, start := pvpRoom(t, url) + lead := host + if !start.GetMyTurn() { + lead = guest + } + + lead.claimDeadEnd() + if code := lead.await("error").GetError().GetCode(); code != "not_a_dead_end" { + t.Errorf("code = %q, want not_a_dead_end", code) + } + + // The turn_seq the claim was answered on is still the current one: a + // submission stamped with it is still accepted rather than refused as + // stale. + lead.submit("b c", start.GetTurnSeq()) + lead.await("turn_update") +} + +// TestClaimDeadEndOutOfTurnRefused: only the player to act may spend a claim, +// exactly as only they may spend a resignation. +func TestClaimDeadEndOutOfTurnRefused(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, start := pvpRoom(t, url) + waits := guest + if !start.GetMyTurn() { + waits = host + } + + waits.claimDeadEnd() + if code := waits.await("error").GetError().GetCode(); code != "not_your_turn" { + t.Errorf("code = %q, want not_your_turn", code) + } +} diff --git a/server/internal/wsapi/dispatch.go b/server/internal/wsapi/dispatch.go new file mode 100644 index 0000000..4a78400 --- /dev/null +++ b/server/internal/wsapi/dispatch.go @@ -0,0 +1,334 @@ +package wsapi + +import ( + "errors" + "log/slog" + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/vietnamese" +) + +// The protocol half of a connection: routing one decoded ClientMessage to +// whatever it means, and the handshake and resume flow that has to run +// before anything else is meaningful. + +// dispatch routes one client message. +// +// Hello must come first: everything else needs a sanitized nickname and a +// registered resume token, and accepting them before the handshake would mean +// carrying "maybe not greeted yet" through every branch below. +func (s *session) dispatch(msg *noituv1.ClientMessage) error { + if _, isHello := msg.GetPayload().(*noituv1.ClientMessage_Hello); !isHello && s.nickname() == "" { + s.send(errorMsg("handshake_required")) + return errHandshake + } + + switch p := msg.GetPayload().(type) { + case *noituv1.ClientMessage_Hello: + return s.handleHello(p.Hello) + + case *noituv1.ClientMessage_StartBotGame: + // The limiter is charged before the payload is inspected, so a bad + // difficulty costs the same as a good one and cannot be used to probe + // for free. + if !s.roomLimiter.allow(time.Now()) { + s.send(errorMsg("too_many_rooms")) + return nil + } + difficulty, ok := Difficulty(p.StartBotGame.GetDifficulty()) + if !ok { + s.send(errorMsg("unknown_difficulty")) + return nil + } + if err := s.hub.startBotRoom(s, difficulty); err != nil { + s.send(roomCreateError(s.id, err)) + } + + case *noituv1.ClientMessage_CreateRoom: + // Creating a room allocates a goroutine and an engine, so one + // connection must not be able to mint them without limit. + if !s.roomLimiter.allow(time.Now()) { + s.send(errorMsg("too_many_rooms")) + return nil + } + if err := s.hub.createRoom(s); err != nil { + s.send(roomCreateError(s.id, err)) + } + + case *noituv1.ClientMessage_JoinRoom: + if !s.hub.joinLimiter.allow(s.remoteIP, time.Now()) { + metrics.joinsRefused.Add("too_many_attempts", 1) + s.send(errorMsg("too_many_attempts")) + return nil + } + if err := s.hub.joinRoom(p.JoinRoom.GetRoomCode(), s); err != nil { + metrics.joinsRefused.Add("room_not_found", 1) + s.send(errorMsg("room_not_found")) + } + + case *noituv1.ClientMessage_QuickMatch: + if r, _ := s.currentRoom(); r != nil { + s.send(errorMsg("already_in_a_room")) + return nil + } + // A match mints a room exactly as CreateRoom does, so it is charged + // the same way and for the same reason. + if !s.roomLimiter.allow(time.Now()) { + s.send(errorMsg("too_many_rooms")) + return nil + } + if err := s.hub.quickMatch(s); err != nil { + if errors.Is(err, errAlreadyQueued) { + s.send(errorMsg("already_queued")) + } else { + s.send(roomCreateError(s.id, err)) + } + } + + case *noituv1.ClientMessage_CancelQuickMatch: + // Idempotent by design: a cancel that finds nothing queued is not an + // error, it is the answer the player wanted. + s.hub.cancelQuickMatch(s) + s.send(quickMatchStatusMsg(false)) + + case *noituv1.ClientMessage_SubmitWord: + s.handleSubmit(p.SubmitWord) + + case *noituv1.ClientMessage_Resign: + // A silently dropped resignation leaves the player staring at a board + // they thought they had left. + if r, id := s.currentRoom(); r != nil { + if !r.send(resignInput{sess: s, player: id}) { + s.send(errorMsg("game_already_over")) + } + } else { + s.send(errorMsg("not_in_a_game")) + } + + case *noituv1.ClientMessage_ClaimDeadEnd: + // Rate-limited on the same budget as a submission: a claim is the + // alternative to playing a word, not a second action alongside it. + if !s.submitLimiter.allow(time.Now()) { + s.send(errorMsg("too_fast")) + return nil + } + if r, id := s.currentRoom(); r != nil { + if !r.send(claimDeadEndInput{sess: s, player: id}) { + s.send(errorMsg("busy")) + } + } else { + s.send(errorMsg("not_in_a_game")) + } + + case *noituv1.ClientMessage_ReportWord: + s.handleReportWord(p.ReportWord) + + case *noituv1.ClientMessage_SetReady: + s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()}) + + case *noituv1.ClientMessage_StartGame: + s.toRoom(lobbyInput{sess: s, action: lobbyStart}) + + case *noituv1.ClientMessage_KickPlayer: + s.toRoom(lobbyInput{sess: s, action: lobbyKick, target: playerIDFor(p.KickPlayer.GetPlayerId())}) + + case *noituv1.ClientMessage_LeaveRoom: + s.toRoom(lobbyInput{sess: s, action: lobbyLeave}) + + case *noituv1.ClientMessage_SendChat: + // Its own budget, so a talkative player never runs out of moves. The + // seat itself is checked by the room, which is the only place that + // knows whether this connection still holds one. + if !s.chatLimiter.allow(time.Now()) { + s.send(errorMsg("too_fast")) + return nil + } + r, id := s.currentRoom() + if r == nil { + s.send(errorMsg("not_in_a_room")) + return nil + } + // A dropped line would leave the player watching their own message + // fail to appear with no reason given. + if !r.send(chatInput{sess: s, player: id, text: p.SendChat.GetText()}) { + s.send(errorMsg("busy")) + } + + case *noituv1.ClientMessage_Ping: + s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli())) + } + return nil +} + +// roomCreateError names the refusal a room could not be opened for. A full +// server is the player's business — they should wait, not retry at once — and +// anything else is the server's, logged here because the client is only told +// that it failed. +func roomCreateError(sessionID string, err error) *noituv1.ServerMessage { + if errors.Is(err, errServerFull) { + return errorMsg("server_full") + } + if errors.Is(err, errDraining) { + // The same key Shutdown sends to everyone already seated: a room + // refused for this reason will not open a moment later the way a full + // one might, so the client is told the same thing either way. + return errorMsg("server_restarting") + } + slog.Error("open room", "session", sessionID, "err", err) + return errorMsg("room_start_failed") +} + +// toRoom forwards one lobby action to the room this connection is seated in. +// +// Rate-limited like a submission: every accepted action is broadcast to every +// seat, so an unbounded one lets a player flood the other's outbox until +// their session is closed for falling behind. A dropped action would leave a +// button that did nothing and no reason why, so every failure answers. +func (s *session) toRoom(in lobbyInput) { + if !s.submitLimiter.allow(time.Now()) { + s.send(errorMsg("too_fast")) + return + } + r, id := s.currentRoom() + if r == nil { + s.send(errorMsg("not_in_a_room")) + return + } + in.player = id + if !r.send(in) { + s.send(errorMsg("not_in_a_room")) + } +} + +// handleHello completes the handshake, resuming a prior game when the client +// presents a token that is still live. +func (s *session) handleHello(h *noituv1.Hello) error { + if v := h.GetProtocolVersion(); v != ProtocolVersion { + s.send(errorMsg("protocol_version_mismatch")) + return errors.New("wsapi: protocol version mismatch") + } + + // The handshake is a one-shot transition. A second Hello would re-register + // the session and rewrite the nickname of a player already seated in a + // game, which nothing downstream expects. + s.mu.Lock() + repeat := s.greeted + s.greeted = true + s.mu.Unlock() + if repeat { + s.send(errorMsg("already_greeted")) + return errors.New("wsapi: repeated hello") + } + + s.setNickname(sanitizeNickname(h.GetNickname())) + s.hub.register(s) + s.send(welcomeMsg(s.id, s.resumeToken, s.nickname())) + + token := h.GetResumeToken() + switch prior, ok := s.hub.resumable(token); { + case ok && prior != s: + s.resumeFrom(prior) + case token != "" && !ok: + // A token the server restarted since, or that outlived its grace + // window, resolves to nothing. Silence here left the client's resume + // latch waiting forever for a reply that was never coming — this + // connection is answered and carries on as a fresh session instead of + // being closed, since a fresh session is exactly what it is. + s.send(errorMsg("session_not_resumable")) + } + return nil +} + +// resumeFrom takes over the seat a previous connection held. +// +// Every failing branch has to say so. A token can outlive its game — the turn +// clock keeps running through the grace window, so a player who dropped on +// their own turn loses before the window closes — and a client that got a +// Welcome and then silence has nothing to render and no reason to stop +// waiting. +func (s *session) resumeFrom(prior *session) { + metrics.resumesAttempted.Add(1) + r, id := prior.currentRoom() + if r == nil { + s.send(errorMsg("game_already_over")) + return + } + if !r.send(resumeInput{player: id, sess: s, prior: prior}) { + s.send(errorMsg("game_already_over")) + return + } + // Deliberately no attach and no close here. The room has not decided yet, + // and a refused resume that had already closed the old connection would end + // the game it was trying to rejoin. +} + +func (s *session) handleSubmit(w *noituv1.SubmitWord) { + if !s.submitLimiter.allow(time.Now()) { + s.send(errorMsg("too_fast")) + return + } + r, id := s.currentRoom() + if r == nil { + s.send(errorMsg("not_in_a_game")) + return + } + // A dropped submission would otherwise leave the player waiting out the + // turn clock with no idea their word never arrived. + if !r.send(submitInput{sess: s, player: id, word: w.GetWord(), turnSeq: w.GetTurnSeq()}) { + s.send(errorMsg("busy")) + } +} + +// handleReportWord validates a word report and, once it is worth logging, +// hands it to the current room for the context only the room goroutine may +// read — the syllable in play, and the room's own mode and code. +// +// Validation happens here rather than in the room because it is entirely +// about this connection: its own rate budget, and its own running count of +// distinct words already filed. Neither needs the room at all, and a session +// playing no game — smoke-testing the wire directly, per the README — can +// still file a report, acknowledged with mode "none" and no link. +func (s *session) handleReportWord(m *noituv1.ReportWord) { + if !s.chatLimiter.allow(time.Now()) { + s.send(errorMsg("too_fast")) + return + } + + word, syllables, err := vietnamese.Normalize(sanitizeText(m.GetWord(), maxWordRunes, maxNicknameMarks)) + if err != nil || !vietnamese.HasEnoughSyllables(syllables) { + s.send(errorMsg("word_report_refused")) + return + } + + if _, already := s.reportedWords[word]; !already { + if len(s.reportedWords) >= maxWordReportsPerSession { + s.send(errorMsg("word_report_limit")) + return + } + s.reportedWords[word] = struct{}{} + } + + // currentRoom's player id is not needed here: the log line is about the + // word and the room's context, never about who filed it. + if r, _ := s.currentRoom(); r != nil { + if !r.send(reportWordInput{sess: s, word: word}) { + s.send(errorMsg("busy")) + } + return + } + + metrics.wordsReported.Add(1) + slog.Info("word_reported", "word", word, "link", "", "mode", "none", "room", "") + s.send(wordReportedMsg(word)) +} + +// leaveRoom tells the room this connection is gone, so the seat enters its +// grace window rather than the game simply stalling. +func (s *session) leaveRoom() { + r, id := s.currentRoom() + if r == nil { + return + } + r.send(disconnectInput{player: id, sess: s}) +} diff --git a/server/internal/wsapi/drain_test.go b/server/internal/wsapi/drain_test.go index 240fe21..d8aee07 100644 --- a/server/internal/wsapi/drain_test.go +++ b/server/internal/wsapi/drain_test.go @@ -92,6 +92,59 @@ func TestLiveGameCountTracksGamesNotLobbies(t *testing.T) { } } +// TestDrainRefusesStartGameOnAnExistingLobby covers what newRegisteredRoom's +// own drain check cannot: a lobby that existed before the drain decision has +// no further room-creation call to refuse, so beginGame itself has to know. +func TestDrainRefusesStartGameOnAnExistingLobby(t *testing.T) { + api, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + guest.setReady(true) + host.await("room_state") + + api.StartDraining() + + host.startGame() + if got := host.await("error").GetError().GetCode(); got != "server_restarting" { + t.Errorf("error code = %q, want server_restarting", got) + } +} + +// TestDrainRefusesQuickMatchAutoStart is C3's other beginGame call site: the +// second seat filling after the drain decision must not start a game either, +// even though nobody sent StartGame for it to refuse. +func TestDrainRefusesQuickMatchAutoStart(t *testing.T) { + h := &hub{} + h.draining.Store(true) + first := &session{id: "a", ctx: t.Context(), out: make(chan []byte, 8)} + second := &session{id: "b", ctx: t.Context(), out: make(chan []byte, 8)} + + r := &room{hub: h, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute} + r.handleCreate(createInput{sess: first, autoStart: true}) + r.handleJoin(joinInput{sess: second}) + + if r.engine != nil { + t.Error("quick match auto-started a game after the drain decision") + } +} + +// TestDrainLetsALiveGameFinish is the other half of C3: draining must not cut +// a game that was already running short, which is the very outcome it exists +// to avoid. +func TestDrainLetsALiveGameFinish(t *testing.T) { + api, url := newTestServer(t, chainDict(), Config{TurnLimit: 200 * time.Millisecond}) + lead, waits, _ := pvpGame(t, url) + + api.StartDraining() + + // Nobody submits anything: the lead's own turn runs out the clock, which + // is enough to end a two-seat game, same as TestLiveGameCountTracksGamesNotLobbies. + waits.await("game_over") + lead.await("game_over") + if got := api.LiveGameCount(); got != 0 { + t.Errorf("live games = %d after the game ended during a drain, want 0", got) + } +} + // TestVersionEndpoint: GET /version answers with exactly the string the // server was configured with, in plain text, so a deploy check can diff it // against what was just built without parsing anything. diff --git a/server/internal/wsapi/game_test.go b/server/internal/wsapi/game_test.go new file mode 100644 index 0000000..9e40288 --- /dev/null +++ b/server/internal/wsapi/game_test.go @@ -0,0 +1,324 @@ +package wsapi + +import ( + "math/rand/v2" + "runtime" + "testing" + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/bot" + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// Playing a game to completion: submissions, rejections, timeouts, and the +// goroutine accounting a finished one leaves behind. + +// TestBotGamePlaysToCompletion walks a whole vs-bot game with no frontend +// involved. The graph is a forced chain, so the ending is not a matter of +// which word the bot happens to pick. +func TestBotGamePlaysToCompletion(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Ăn") + + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + + started := c.await("game_started").GetGameStarted() + if started.GetOpeningWord() != "a b" { + t.Fatalf("opening = %q, want %q", started.GetOpeningWord(), "a b") + } + if !started.GetMyTurn() { + t.Fatal("human should move first in a bot game") + } + if started.GetCurrentSyllable() != "b" { + t.Fatalf("current syllable = %q, want b", started.GetCurrentSyllable()) + } + + // "b c" is the only legal reply. The bot then has only "c d", which leaves + // the human "d e", after which the bot has nothing and loses. + c.submit("b c", started.GetTurnSeq()) + + // The player's own move comes back first — every accepted move is fanned + // out to both seats — so the bot's reply is the second update. + if own := c.await("turn_update").GetTurnUpdate(); !own.GetPlayed().GetByMe() { + t.Fatal("first update after submitting should be the player's own move") + } + botReply := c.await("turn_update").GetTurnUpdate() + if botReply.GetPlayed().GetByMe() { + t.Fatal("expected the bot's move, not another echo") + } + if !botReply.GetMyTurn() { + t.Fatal("human should be on turn after the bot replies") + } + c.submit("d e", botReply.GetTurnSeq()) + + over := c.await("game_over").GetGameOver() + if !over.GetIWon() { + t.Errorf("human should win when the bot runs out of words, got %+v", over) + } +} + +// TestTheFirstTurnIsDrawn checks that opening the room is not the same as +// opening the game. Moving first is an advantage, and handing it to the owner +// every time would make them favourite in every game of a series. +func TestTheFirstTurnIsDrawn(t *testing.T) { + // Started here rather than over a pair of sockets: a series long enough to + // tell a draw from a fixed lead is far more starts than a lobby's rate + // limiter allows, and none of what is being checked is on the wire. + // beginGame reports to the hub's live-game gauge, so this hand-built room + // needs one even though nothing here reads it back. + r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second} + r.seats[0] = &seat{id: "p1"} + r.seats[1] = &seat{id: "p2"} + + // There are two outcomes, so a series that only ever shows one of them is + // a lead that is not being drawn. A fixed lead fails this every time; a + // fair draw fails it about once in five hundred million runs. + const games = 30 + led := map[game.PlayerID]int{} + for range games { + if err := r.beginGame(); err != nil { + t.Fatalf("beginGame: %v", err) + } + led[r.engine.Turn()]++ + } + + if led["p1"] == 0 || led["p2"] == 0 { + t.Errorf("over %d games p1 led %d and p2 %d; the first turn is not being drawn", + games, led["p1"], led["p2"]) + } +} + +// The one room where the lead is not drawn: the human opens against the bot. +func TestABotGameOpensWithTheHuman(t *testing.T) { + strategy, err := bot.New(bot.Easy, rand.New(rand.NewPCG(1, 2))) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, strategy: strategy} + r.seats[0] = &seat{id: "p1"} + r.seats[1] = &seat{id: botPlayerID} + + for range 20 { + if err := r.beginGame(); err != nil { + t.Fatalf("beginGame: %v", err) + } + if got := r.engine.Turn(); got != "p1" { + t.Fatalf("the bot game opened on %q, want the human", got) + } + } +} + +// TestPvPGameAlternatesTurns runs two clients through a full game and checks +// that each sees the other's move rendered from its own side. +func TestPvPGameAlternatesTurns(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + readyAndStart(t, host, guest) + + lead, waits, start := awaitLead(t, host, guest) + + lead.submit("b c", start.GetTurnSeq()) + + // The same move, rendered per recipient: by_me flips, my_turn flips. + moverUpdate := lead.await("turn_update").GetTurnUpdate() + otherUpdate := waits.await("turn_update").GetTurnUpdate() + + if !moverUpdate.GetPlayed().GetByMe() { + t.Error("mover should see by_me = true") + } + if otherUpdate.GetPlayed().GetByMe() { + t.Error("opponent should see by_me = false") + } + if moverUpdate.GetMyTurn() { + t.Error("mover should not be on turn after moving") + } + if !otherUpdate.GetMyTurn() { + t.Error("opponent should now be on turn") + } + if myScore(moverUpdate) != otherScore(otherUpdate) { + t.Errorf("scores disagree across recipients: %d vs %d", + myScore(moverUpdate), otherScore(otherUpdate)) + } +} + +// TestTurnTimeoutEndsGameServerSide proves the clock is the server's. The +// client sends nothing at all after the game starts. +func TestTurnTimeoutEndsGameServerSide(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 250 * time.Millisecond}) + + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + readyAndStart(t, host, guest) + + _, waits, _ := awaitLead(t, host, guest) + + over := waits.await("game_over").GetGameOver() + if !over.GetIWon() { + t.Error("the player who did not time out should win") + } + if over.GetReason() != noituv1.GameEndReason_GAME_END_REASON_TIMEOUT { + t.Errorf("reason = %v, want TIMEOUT", over.GetReason()) + } +} + +// TestRejectionsCarryTheRightReason checks each rejection a player can +// actually provoke reaches the client as a distinct enum, since the UI copy +// keys off exactly this value. +func TestRejectionsCarryTheRightReason(t *testing.T) { + tests := []struct { + name string + word string + want noituv1.RejectReason + }{ + {"unknown word", "khong co", noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY}, + {"wrong link", "c d", noituv1.RejectReason_REJECT_REASON_WRONG_LINK}, + {"single syllable", "b", noituv1.RejectReason_REJECT_REASON_TOO_FEW_SYLLABLES}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người thử") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + started := c.await("game_started").GetGameStarted() + + c.submit(tc.word, started.GetTurnSeq()) + + got := c.await("move_rejected").GetMoveRejected() + if got.GetReason() != tc.want { + t.Errorf("reason = %v, want %v", got.GetReason(), tc.want) + } + if got.GetWord() != tc.word { + t.Errorf("rejection echoed %q, want %q", got.GetWord(), tc.word) + } + }) + } +} + +// TestReplayingAWordIsRejected needs its own graph: the engine checks the link +// before reuse, so replaying the opening word on the very first turn reports +// WRONG_LINK. Only a word that links correctly *and* has been played can +// surface ALREADY_USED, which takes a cycle in the graph and a move to reach. +func TestReplayingAWordIsRejected(t *testing.T) { + // a b -> b a -> back to a word starting with "a", which is the opening. + dict := newTestDict("a b", "b a", "a c", "c a") + _, url := newTestServer(t, dict, Config{TurnLimit: 10 * time.Second}) + + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + readyAndStart(t, host, guest) + lead, waits, start := awaitLead(t, host, guest) + + // Opening "a b" is used and current syllable is "b". Play "b a" so the + // syllable returns to "a", where the opening word now links legally. + lead.submit("b a", start.GetTurnSeq()) + replyTurn := waits.await("turn_update").GetTurnUpdate() + + waits.submit("a b", replyTurn.GetTurnSeq()) + + got := waits.await("move_rejected").GetMoveRejected() + if got.GetReason() != noituv1.RejectReason_REJECT_REASON_ALREADY_USED { + t.Errorf("reason = %v, want ALREADY_USED", got.GetReason()) + } +} + +// TestStaleTurnSeqIsRejected covers the double-submit guard: a word stamped +// with a turn that has already passed must not be applied to the current one. +func TestStaleTurnSeqIsRejected(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người thử") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + started := c.await("game_started").GetGameStarted() + + c.submit("b c", started.GetTurnSeq()+99) + + got := c.await("move_rejected").GetMoveRejected() + if got.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN { + t.Errorf("reason = %v, want NOT_YOUR_TURN", got.GetReason()) + } +} + +// TestGoroutinesReturnToBaseline guards the leak the risk table names: a bot +// worker, a room, or a session that outlives its game costs a goroutine per +// abandoned match, which only shows up under sustained play. +func TestGoroutinesReturnToBaseline(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 5 * time.Second}) + + // Warm up first: the HTTP server and the dialer start pools of their own, + // and counting those as a leak would make this test lie. + playOneBotGame(t, url) + settle() + baseline := runtime.NumGoroutine() + + for range 10 { + playOneBotGame(t, url) + } + settle() + + // A little slack for the transport's own bookkeeping; a real leak here is + // one goroutine per game, so ten games would show ten or more. + if got := runtime.NumGoroutine(); got > baseline+5 { + t.Errorf("goroutines grew from %d to %d over 10 games", baseline, got) + } +} + +// TestNearMissSuggestionOnWire is the end-to-end proof that a diacritic typo +// carries a suggestion: the dictionary layer is unit-tested on its own, but +// only this shows the room actually wires MoveRejected.suggestion up. +func TestNearMissSuggestionOnWire(t *testing.T) { + _, url := newTestServer(t, newTestDict("ngôn ngữ", "ngữ pháp"), Config{}) + c := dial(t, url) + c.hello("Người chơi") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + started := c.await("game_started").GetGameStarted() + + // Typed with no diacritics at all, which the dictionary does not know as a + // word but which strips to exactly one real one. + c.submit("ngu phap", started.GetTurnSeq()) + + rejected := c.await("move_rejected").GetMoveRejected() + if rejected.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY { + t.Fatalf("reason = %v, want NOT_IN_DICTIONARY", rejected.GetReason()) + } + if rejected.GetSuggestion() != "ngữ pháp" { + t.Errorf("suggestion = %q, want %q", rejected.GetSuggestion(), "ngữ pháp") + } +} diff --git a/server/internal/wsapi/hub.go b/server/internal/wsapi/hub.go index 9878d03..1dccbf7 100644 --- a/server/internal/wsapi/hub.go +++ b/server/internal/wsapi/hub.go @@ -287,7 +287,8 @@ func (h *hub) newRegisteredRoom(mode string) (*room, error) { return r, nil } -// roomCount is how many rooms are live right now. +// roomCount is how many rooms are live right now. Tests use it to assert that +// a room was evicted. func (h *hub) roomCount() int { h.mu.Lock() defer h.mu.Unlock() diff --git a/server/internal/wsapi/hub_test.go b/server/internal/wsapi/hub_test.go deleted file mode 100644 index 0e9124d..0000000 --- a/server/internal/wsapi/hub_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package wsapi - -// roomCount lives in hub.go; tests use it to assert that a room was evicted. diff --git a/server/internal/wsapi/limits_test.go b/server/internal/wsapi/limits_test.go index b55ce30..43cc498 100644 --- a/server/internal/wsapi/limits_test.go +++ b/server/internal/wsapi/limits_test.go @@ -86,6 +86,51 @@ func TestConnectionCapRefusesBeforeUpgrade(t *testing.T) { } } +// TestPerIPConnectionCapRefusesBeforeUpgrade: off by default, on it refuses +// the same way the global cap does — before the upgrade, so the client reads +// an HTTP status rather than losing a socket it was never granted. +func TestPerIPConnectionCapRefusesBeforeUpgrade(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{MaxConnectionsPerIP: 1}) + + first := dial(t, url) + first.hello("Một") + + _, resp, err := websocket.Dial(t.Context(), url+"/ws", nil) + if err == nil { + t.Fatal("a second connection from the same address was accepted past the per-IP cap") + } + if resp == nil || resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("want 503 before the upgrade, got %v (err %v)", resp, err) + } +} + +// TestPerIPConnectionCapIsOffByDefault: a zero MaxConnectionsPerIP must not +// refuse anything — most players share an address behind one NAT egress, and +// the cap defaults off for exactly that reason. +func TestPerIPConnectionCapIsOffByDefault(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + for range 3 { + c := dial(t, url) + c.hello("Người chơi") + } +} + +// TestPerIPConnectionCapReleasesOnDisconnect: the slot a closed connection +// held must be free for the next one, or the cap would starve an address +// permanently after its first burst of reconnects. +func TestPerIPConnectionCapReleasesOnDisconnect(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{MaxConnectionsPerIP: 1}) + + first := dial(t, url) + first.hello("Một") + _ = first.conn.Close(websocket.StatusNormalClosure, "") + settle() + + second := dial(t, url) + second.hello("Hai") +} + // TestFrameFloodClosesTheConnection: a message that matches no dispatch arm // used to be free at line rate. Now every frame is metered before it is // decoded, and a flood is closed rather than throttled. diff --git a/server/internal/wsapi/lobby_test.go b/server/internal/wsapi/lobby_test.go new file mode 100644 index 0000000..4775f1b --- /dev/null +++ b/server/internal/wsapi/lobby_test.go @@ -0,0 +1,505 @@ +package wsapi + +import ( + "strings" + "testing" + "time" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// The lobby between games: seating, readiness, starting, kicking, and +// leaving. + +// TestUnknownRoomCodeIsRefused checks a join against a code nobody holds. +func TestUnknownRoomCodeIsRefused(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Khách") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: "ZZZZZZ"}, + }}) + + if code := c.await("error").GetError().GetCode(); code != "room_not_found" { + t.Errorf("error code = %q, want room_not_found", code) + } +} + +// TestRoomIsEvictedWhenGameEnds guards against rooms accumulating: a finished +// game must leave the registry so its code is reusable. +func TestRoomIsEvictedWhenGameEnds(t *testing.T) { + api, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người thử") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + started := c.await("game_started").GetGameStarted() + c.submit("b c", started.GetTurnSeq()) + c.await("game_over") + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if api.hub.roomCount() == 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Errorf("room still registered after the game ended: %d live", api.hub.roomCount()) +} + +func TestRoomCodeAlphabetAvoidsLookalikes(t *testing.T) { + for _, bad := range []rune{'0', 'O', '1', 'I', 'L'} { + if strings.ContainsRune(roomCodeAlphabet, bad) { + t.Errorf("alphabet contains the easily-confused %q", bad) + } + } + + seen := map[string]bool{} + for range 1000 { + code := randomCode() + if len(code) != roomCodeLen { + t.Fatalf("code %q is %d chars, want %d", code, len(code), roomCodeLen) + } + for _, r := range code { + if !strings.ContainsRune(roomCodeAlphabet, r) { + t.Fatalf("code %q contains %q, outside the alphabet", code, r) + } + } + seen[code] = true + } + // Not a randomness test — just proof the generator is not returning a + // constant, which a broken modulo could. + if len(seen) < 900 { + t.Errorf("only %d distinct codes in 1000 draws", len(seen)) + } +} + +// TestLobbyOpensWithNobodyReady is the room a joiner lands in: it exists, the +// owner is known, and nothing has started. +func TestLobbyOpensWithNobodyReady(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + // Set up by hand rather than through pvpLobby: this test is about the + // frames the handshake produces, and the helper consumes them. + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + + hostState := host.await("room_state").GetRoomState() + guestState := guest.await("room_state").GetRoomState() + + if !mySlot(hostState).GetIsOwner() { + t.Error("the player who created the room does not own it") + } + if mySlot(guestState).GetIsOwner() { + t.Error("the player who joined was made owner") + } + if hostState.GetCanStart() || guestState.GetCanStart() { + t.Error("a game can start with nobody ready") + } + if otherSlot(hostState) == nil || otherSlot(hostState).GetName() == "" { + t.Errorf("the owner cannot see who joined: %+v", hostState) + } + // Nothing starts on its own. Joining used to be the start signal, and a + // player who joined to look at the room found themselves on the clock. + silentFor(t, guest, "game_started", 250*time.Millisecond) +} + +// TestReadyIsRenderedPerRecipient is the guard against the two flags being +// swapped, which would show a player their opponent's readiness as their own. +func TestReadyIsRenderedPerRecipient(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + guest.setReady(true) + + guestState := guest.await("room_state").GetRoomState() + hostState := host.await("room_state").GetRoomState() + + if !mySlot(guestState).GetReady() || otherSlot(guestState).GetReady() { + t.Errorf("the guest should see only their own readiness, got %+v", guestState) + } + if mySlot(hostState).GetReady() || !otherSlot(hostState).GetReady() { + t.Errorf("the owner should see only the guest's readiness, got %+v", hostState) + } + if !hostState.GetCanStart() { + t.Error("the owner cannot start a game their guest is ready for") + } +} + +// TestOwnerHasNoReadinessOfTheirOwn: Start is the owner's readiness, and a +// second flag they would have to set first buys nothing. +func TestOwnerHasNoReadinessOfTheirOwn(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, _, _ := pvpLobby(t, url) + + host.setReady(true) + + if got := host.await("error").GetError().GetCode(); got != "owner_needs_no_ready" { + t.Errorf("the owner readying returned %q", got) + } +} + +// TestStartIsRefusedUntilTheGuestIsReady covers each way a start is not yet a +// game. +func TestStartIsRefusedUntilTheGuestIsReady(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + // Alone in the room. + host.startGame() + if got := host.await("error").GetError().GetCode(); got != "need_more_players" { + t.Errorf("starting alone returned %q, want need_more_players", got) + } + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + host.await("room_state") + guest.await("room_state") + + // Seated, but not ready. + host.startGame() + if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" { + t.Errorf("starting with an unready guest returned %q, want not_everyone_ready", got) + } + + // A guest who readies and takes it back is not ready either. + guest.setReady(true) + host.await("room_state") + guest.setReady(false) + host.await("room_state") + host.startGame() + if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" { + t.Errorf("starting after the guest unreadied returned %q", got) + } +} + +// TestOnlyTheOwnerStartsAndKicks: the guest holds a room code, and a code is +// pasted into group chats by design. +func TestOnlyTheOwnerStartsAndKicks(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + _ = host + + guest.startGame() + if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" { + t.Errorf("a guest starting the game returned %q, want not_the_owner", got) + } + guest.kickPlayer("p1") + if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" { + t.Errorf("a guest kicking returned %q, want not_the_owner", got) + } +} + +// TestNextGameNeedsAFreshReady is the whole replay flow: a finished game +// returns both players to the lobby, and the readiness that started the last +// one is spent. +func TestNextGameNeedsAFreshReady(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, first := pvpRoom(t, url) + + hostState, guestState := resignAndSettle(t, host, guest, first) + if otherSlot(hostState).GetReady() || mySlot(guestState).GetReady() { + t.Error("the readiness that started the last game survived it") + } + if hostState.GetCanStart() { + t.Error("the owner can start a game nobody has readied for") + } + + host.startGame() + if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" { + t.Errorf("starting a second game without a fresh ready returned %q", got) + } + + guest.setReady(true) + host.await("room_state") + host.startGame() + + second := host.await("game_started").GetGameStarted() + guest.await("game_started") + + if second.GetTurnSeq() <= first.GetTurnSeq() { + t.Errorf("turn_seq must keep rising across games: %d then %d, so a submission "+ + "still in flight from the first could be applied to the second", + first.GetTurnSeq(), second.GetTurnSeq()) + } + if second.GetOpeningWord() == "" { + t.Error("the second game needs its own opening word") + } +} + +// TestRoomKeepsARunningWinTally: a room outlives its games, so the score of +// the series is a room fact. It is credited before the lobby is broadcast, so +// the players see it the moment a game ends rather than one input later. +func TestRoomKeepsARunningWinTally(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, first := pvpRoom(t, url) + + // The owner resigns, so the guest takes the first game. + hostState, guestState := resignAndSettle(t, host, guest, first) + if got := mySlot(guestState).GetWins(); got != 1 { + t.Errorf("the winner's tally is %d after one game, want 1", got) + } + if got := mySlot(hostState).GetWins(); got != 0 { + t.Errorf("the loser's tally is %d, want 0", got) + } + // And each player is told the whole table, not just their own row. + if got := otherSlot(hostState).GetWins(); got != 1 { + t.Errorf("the owner sees the guest's tally as %d, want 1", got) + } + + // A second game the other way round leaves the series level. + guest.setReady(true) + host.await("room_state") + host.startGame() + host.await("game_started") + second := guest.await("game_started").GetGameStarted() + + resignFrom(t, guest, host, second) + host.await("game_over") + guest.await("game_over") + hostState = host.await("room_state").GetRoomState() + + if got := mySlot(hostState).GetWins(); got != 1 { + t.Errorf("the owner's tally is %d after winning one of two, want 1", got) + } + if got := otherSlot(hostState).GetWins(); got != 1 { + t.Errorf("the guest's tally is %d after winning one of two, want 1", got) + } +} + +// TestLobbyActionsAreRefusedDuringAGame keeps the lobby's own business out of a +// game in progress. Leaving is not part of it: a player may walk out of a game +// whether or not it is their turn, which +// TestLeavingMidGameFreesTheSeatAndLeavesTheRestPlaying covers. +func TestLobbyActionsAreRefusedDuringAGame(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpRoom(t, url) + + guest.setReady(false) + if got := guest.await("error").GetError().GetCode(); got != "game_in_progress" { + t.Errorf("unreadying mid-game returned %q, want game_in_progress", got) + } + host.kickPlayer("p2") + if got := host.await("error").GetError().GetCode(); got != "game_in_progress" { + t.Errorf("kicking mid-game returned %q, want game_in_progress", got) + } +} + +// TestLeavingNeedsAnUnreadyFirst is the friction the lobby is meant to have: a +// player the owner is waiting on has to take that back before walking away. +func TestLeavingNeedsAnUnreadyFirst(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + guest.setReady(true) + guest.await("room_state") + host.await("room_state") + + guest.leaveRoom() + if got := guest.await("error").GetError().GetCode(); got != "must_unready_first" { + t.Errorf("leaving while ready returned %q, want must_unready_first", got) + } + + guest.setReady(false) + guest.await("room_state") + host.await("room_state") + guest.leaveRoom() + + // The room survives: the owner is still in it, now on their own. + alone := host.await("room_state").GetRoomState() + if otherSlot(alone) != nil { + t.Errorf("the owner still sees a guest who left: %+v", alone) + } + if !mySlot(alone).GetIsOwner() || alone.GetCanStart() { + t.Errorf("the room the owner is left with is wrong: %+v", alone) + } +} + +// TestKickFreesAnUnreadySeatOnly: readiness is a commitment, and the owner +// does not get to overrule one. +func TestKickFreesAnUnreadySeatOnly(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, code := pvpLobby(t, url) + + guest.setReady(true) + host.await("room_state") + host.kickPlayer("p2") + if got := host.await("error").GetError().GetCode(); got != "player_is_ready" { + t.Errorf("kicking a ready guest returned %q, want player_is_ready", got) + } + + guest.setReady(false) + host.await("room_state") + host.kickPlayer("p2") + + if got := guest.await("error").GetError().GetCode(); got != "kicked" { + t.Errorf("the kicked player was told %q", got) + } + if got := host.await("room_state").GetRoomState(); otherSlot(got) != nil { + t.Errorf("the kicked seat is still occupied: %+v", got) + } + + // The seat is free, and a kick is not a ban. + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + if got := guest.await("room_state").GetRoomState(); otherSlot(got) == nil { + t.Errorf("a kicked player could not come back: %+v", got) + } +} + +// TestOwnerLeavingPromotesTheOtherPlayer: the role outlives the player who +// held it, or the room would be one nobody can start. +func TestOwnerLeavingPromotesTheOtherPlayer(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, code := pvpLobby(t, url) + + host.leaveRoom() + + promoted := guest.await("room_state").GetRoomState() + if !mySlot(promoted).GetIsOwner() { + t.Errorf("the player left behind was not promoted: %+v", promoted) + } + if otherSlot(promoted) != nil { + t.Errorf("the owner who left is still shown as present: %+v", promoted) + } + + // And the promotion is real: the new owner can start a game with the next + // person to walk in. + third := dial(t, url) + third.hello("Người mới") + third.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + guest.await("room_state") + third.await("room_state") + third.setReady(true) + guest.await("room_state") + guest.startGame() + + guest.await("game_started") + third.await("game_started") +} + +// TestPromotedOwnerLosesTheirReadiness: their readiness is Start now, and a +// flag left set from being a guest would mean nothing. +func TestPromotedOwnerLosesTheirReadiness(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + guest.setReady(true) + host.await("room_state") + // Drained on both sides, so the state read below is the promotion and not + // the readiness that preceded it. + guest.await("room_state") + host.leaveRoom() + + promoted := guest.await("room_state").GetRoomState() + if mySlot(promoted).GetReady() { + t.Errorf("the promoted owner is still carrying a guest's readiness: %+v", promoted) + } +} + +// TestLastPlayerOutClosesTheRoom bounds the code and the goroutine: nothing is +// coming that could fill a room whose code the hub is about to forget. +func TestLastPlayerOutClosesTheRoom(t *testing.T) { + api, url := newTestServer(t, chainDict(), Config{}) + host, guest, _ := pvpLobby(t, url) + + guest.leaveRoom() + host.await("room_state") + host.leaveRoom() + + awaitNoRooms(t, api, "a room nobody is in") +} + +// TestIdleLobbyCloses bounds a room nobody starts a game in. One open tab +// would otherwise hold a code and a goroutine for the life of the process. +func TestIdleLobbyCloses(t *testing.T) { + api, url := newTestServer(t, chainDict(), Config{IdleFor: 150 * time.Millisecond}) + + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + host.await("room_state") + + if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" { + t.Errorf("an idle room closed with %q", got) + } + awaitNoRooms(t, api, "an idle room") +} + +// TestBotRoomHasNoLobby: a bot room is its game. Keeping it open would leave +// one goroutine and one engine per finished bot game. +func TestBotRoomHasNoLobby(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + c := dial(t, url) + c.hello("Người chơi") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + c.await("game_started") + + c.resign() + c.await("game_over") + + // not_in_a_room is the session reporting that the room has gone: the + // goroutine and engine are released rather than parked in a lobby no bot + // can ready for. + c.setReady(true) + if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" { + t.Errorf("a finished bot room answered %q, want it to be gone", got) + } +} + +// TestOneConnectionCannotStrandRooms is the regression for rooms that outlived +// the only connection that could ever end them. attach overwrote the session's +// room pointer and nothing told the old room, so it parked in select forever +// holding a goroutine and a room code. +func TestOneConnectionCannotStrandRooms(t *testing.T) { + api, url := newTestServer(t, chainDict(), Config{}) + + c := dial(t, url) + c.hello("Người chơi") + + const rooms = 4 + for range rooms { + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + c.await("room_state") + } + + _ = c.conn.Close(websocket.StatusNormalClosure, "") + + deadline := time.Now().Add(5 * time.Second) + for { + api.hub.mu.Lock() + left := len(api.hub.rooms) + api.hub.mu.Unlock() + + if left == 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("%d of %d rooms outlived the only connection that was ever in them", left, rooms) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/server/internal/wsapi/nickname_test.go b/server/internal/wsapi/nickname_test.go index db2a80f..8213258 100644 --- a/server/internal/wsapi/nickname_test.go +++ b/server/internal/wsapi/nickname_test.go @@ -1,6 +1,7 @@ package wsapi import ( + "slices" "strings" "testing" "unicode" @@ -62,3 +63,71 @@ func FuzzSanitizeText(f *testing.F) { } }) } + +func TestSanitizeNickname(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain", "Minh", "Minh"}, + {"vietnamese kept", "Nguyễn Thuý", "Nguyễn Thuý"}, + {"empty falls back", "", defaultNickname}, + {"whitespace only falls back", " \t\n ", defaultNickname}, + {"control characters stripped", "Mi\x00nh\x07", "Minh"}, + {"zero width stripped", "Mi\u200bnh\u200d", "Minh"}, + {"bidi override stripped", "Minh\u202e", "Minh"}, + {"whitespace collapsed", " Minh Nguyen ", "Minh Nguyen"}, + {"newlines become spaces", "Minh\nNguyen", "Minh Nguyen"}, + {"over length truncated", strings.Repeat("a", 40), strings.Repeat("a", maxNicknameRunes)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := sanitizeNickname(tc.in); got != tc.want { + t.Errorf("sanitizeNickname(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestSanitizeNicknameCountsRunesNotBytes guards the cap against being applied +// in bytes, which would cut a Vietnamese name to a third of the length a Latin +// one keeps. +func TestSanitizeNicknameCountsRunesNotBytes(t *testing.T) { + in := strings.Repeat("ữ", maxNicknameRunes) + got := sanitizeNickname(in) + if n := len([]rune(got)); n != maxNicknameRunes { + t.Errorf("kept %d runes, want %d (byte-based truncation?)", n, maxNicknameRunes) + } +} + +// TestDistinguishSeparatesIdenticalNames covers the collision the fallback +// creates — players who all send nothing — and the one it always could: +// players who choose the same name. +func TestDistinguishSeparatesIdenticalNames(t *testing.T) { + if got := distinguish("Minh", []string{"Thuý"}); got != "Minh" { + t.Errorf("distinct names should be left alone, got %q", got) + } + + // A whole room of unnamed players, seated one at a time. Every one of them + // has to end up with a name none of the others is already using: two + // suffixed identically is the same failure as two unsuffixed. + var taken []string + for range maxPlayers { + got := distinguish(defaultNickname, taken) + if slices.Contains(taken, got) { + t.Fatalf("distinguish returned %q, which is already in %v", got, taken) + } + if n := len([]rune(got)); n > maxNicknameRunes { + t.Errorf("distinguished name %q is %d runes, over the %d cap", got, n, maxNicknameRunes) + } + taken = append(taken, got) + } + + // The suffix must not push a name that is already at the cap past it. + long := strings.Repeat("a", maxNicknameRunes) + if got := distinguish(long, []string{long}); len([]rune(got)) > maxNicknameRunes { + t.Errorf("distinguished name is %d runes, over the %d cap", len([]rune(got)), maxNicknameRunes) + } +} diff --git a/server/internal/wsapi/presence_test.go b/server/internal/wsapi/presence_test.go new file mode 100644 index 0000000..770b002 --- /dev/null +++ b/server/internal/wsapi/presence_test.go @@ -0,0 +1,159 @@ +package wsapi + +import ( + "context" + "testing" + "time" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/bot" +) + +// A seat's reconnect window: the race between a connection tearing down and +// the room seating it, and what happens once the window runs out. + +// TestCreateOpensGraceWindowForATornDownConnection covers the race between a +// connection dying and the room draining the message that seats it: nothing +// else would ever tell this room the creator is gone, since leaveRoom only +// notifies a room the session had already attached to. +func TestCreateOpensGraceWindowForATornDownConnection(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)} + + r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute} + r.handleCreate(createInput{sess: dead}) + + s := r.seats[0] + if s == nil { + t.Fatal("handleCreate did not seat the creator") + } + if s.sess != nil { + t.Error("a torn-down connection was left looking connected") + } + if s.graceUntil.IsZero() { + t.Error("no grace window was opened for the torn-down connection") + } +} + +// TestJoinOpensGraceWindowForATornDownConnection is the same race on the +// other seating path: without this, allConnected() would report true for a +// seat nobody is behind. +func TestJoinOpensGraceWindowForATornDownConnection(t *testing.T) { + live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)} + + r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute} + r.handleCreate(createInput{sess: live}) + r.handleJoin(joinInput{sess: dead}) + + s := r.seats[1] + if s == nil { + t.Fatal("handleJoin did not seat the second player") + } + if s.sess != nil { + t.Error("a torn-down connection was left looking connected") + } + if r.allConnected() { + t.Error("allConnected must not report true with a ghost seat behind it") + } +} + +// TestQuickMatchAutoStartSkipsAGhostSeat is the consequence C1 warns about: a +// real player auto-started into a game against a dead socket burns the whole +// turn clock before anyone notices. allConnected() reporting the ghost seat +// honestly is what keeps this from ever reaching beginGame. +func TestQuickMatchAutoStartSkipsAGhostSeat(t *testing.T) { + live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)} + + r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute} + r.handleCreate(createInput{sess: live, autoStart: true}) + r.handleJoin(joinInput{sess: dead}) + + if r.engine != nil { + t.Error("a game was auto-started against a connection that had already torn down") + } +} + +// TestStartBotCancelsTheRoomForATornDownConnection: a bot room has no lobby to +// wait in and no idle timer while it has no engine yet, so a ghost seat here +// must end the room outright rather than being left to a grace window that +// nothing would ever clear. +func TestStartBotCancelsTheRoomForATornDownConnection(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)} + + hctx, hcancel := context.WithCancel(context.Background()) + defer hcancel() + r := newRoom(&hub{ctx: hctx}, "AAAAAA", time.Second, time.Second, time.Minute, roomModeBot) + r.dict = chainDict() + r.handleStartBot(startBotInput{sess: dead, difficulty: bot.Easy}) + + if r.ctx.Err() == nil { + t.Error("a room seated only by a torn-down connection must be cancelled") + } + if r.engine != nil { + t.Error("a bot game was started against a connection that had already torn down") + } +} + +// TestResumeOpensGraceWindowForATornDownConnection covers the same race on +// the resume path: the new connection presenting the token can die before the +// room drains the resumeInput it produced. +func TestResumeOpensGraceWindowForATornDownConnection(t *testing.T) { + live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)} + + r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute} + r.handleCreate(createInput{sess: live}) + r.seats[0].sess = nil + r.seats[0].graceUntil = time.Now().Add(time.Minute) + + r.handleResume(resumeInput{player: "p1", sess: dead}) + + s := r.seats[0] + if s.sess != nil { + t.Error("a torn-down resuming connection was left looking connected") + } + if s.graceUntil.IsZero() { + t.Error("no grace window was reopened for the torn-down resuming connection") + } +} + +// TestGraceExpiryAwardsTheGame is the other half: nobody comes back. +func TestGraceExpiryAwardsTheGame(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second, GraceFor: 200 * time.Millisecond}) + + host := dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + readyAndStart(t, host, guest) + host.await("game_started") + guest.await("game_started") + + _ = host.conn.Close(websocket.StatusGoingAway, "") + + over := guest.await("game_over").GetGameOver() + if !over.GetIWon() { + t.Error("the player who stayed should win") + } + if over.GetReason() != noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT { + t.Errorf("reason = %v, want OPPONENT_LEFT", over.GetReason()) + } +} diff --git a/server/internal/wsapi/protocol_test.go b/server/internal/wsapi/protocol_test.go new file mode 100644 index 0000000..13b29a3 --- /dev/null +++ b/server/internal/wsapi/protocol_test.go @@ -0,0 +1,154 @@ +package wsapi + +import ( + "context" + "errors" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// The wire contract below the game: the handshake, frame limits, origin +// checking, and the endpoints that answer outside of a room. + +// TestProtocolVersionMismatchIsRefused proves an incompatible client is told +// so rather than left to misread frames. +func TestProtocolVersionMismatchIsRefused(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ + ProtocolVersion: ProtocolVersion + 1, + Nickname: "Cũ", + }}}) + + if code := c.await("error").GetError().GetCode(); code != "protocol_version_mismatch" { + t.Errorf("error code = %q, want protocol_version_mismatch", code) + } +} + +// TestHandshakeIsRequiredFirst rejects a client that skips Hello. +func TestHandshakeIsRequiredFirst(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + + if code := c.await("error").GetError().GetCode(); code != "handshake_required" { + t.Errorf("error code = %q, want handshake_required", code) + } +} + +// TestOversizeFrameClosesConnection covers the read limit. The frame is built +// past maxFrameBytes, so the socket must close rather than the decoder be +// asked to parse it. +func TestOversizeFrameClosesConnection(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người thử") + + c.submit(strings.Repeat("x", maxFrameBytes+1), 1) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for { + _, _, err := c.conn.Read(ctx) + if err == nil { + continue + } + // The status matters, not merely that the socket closed: any unrelated + // failure would otherwise let this pass while the read limit did + // nothing. + if got := websocket.CloseStatus(err); got != websocket.StatusMessageTooBig { + t.Errorf("close status = %v, want StatusMessageTooBig", got) + } + return + } +} + +// TestOriginIsChecked confirms a cross-origin handshake is refused when the +// allowlist does not include it. +func TestOriginIsChecked(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{AllowedOrigins: []string{"example.com"}}) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, _, err := websocket.Dial(ctx, url+"/ws", &websocket.DialOptions{ + HTTPHeader: map[string][]string{"Origin": {"http://evil.example"}}, + }) + if err == nil { + t.Fatal("a disallowed origin completed the handshake") + } +} + +// TestPingIsAnswered covers the clock-offset path the client uses to render a +// countdown against the server's absolute deadline. +func TestPingIsAnswered(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người thử") + + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Ping{ + Ping: &noituv1.Ping{ClientTimeMs: 1234}, + }}) + + pong := c.await("pong").GetPong() + if pong.GetClientTimeMs() != 1234 { + t.Errorf("pong echoed %d, want 1234", pong.GetClientTimeMs()) + } + if pong.GetServerTimeMs() == 0 { + t.Error("pong carried no server clock") + } +} + +// TestTextFrameIsRejected keeps the protocol binary-only: guessing at another +// encoding is how a parser becomes an attack surface. +func TestTextFrameIsRejected(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + + if err := c.conn.Write(c.ctx, websocket.MessageText, []byte("hello")); err != nil { + t.Fatalf("write: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for { + if _, _, err := c.conn.Read(ctx); err != nil { + return + } + } +} + +func TestDecodeRejectsTextFrames(t *testing.T) { + if _, err := Decode(websocket.MessageText, nil); !errors.Is(err, ErrNotBinary) { + t.Errorf("Decode(text) error = %v, want ErrNotBinary", err) + } +} + +func TestHealthz(t *testing.T) { + api, _ := newTestServer(t, chainDict(), Config{}) + req := httptest.NewRequest("GET", "/healthz", nil) + rec := httptest.NewRecorder() + api.ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Errorf("healthz returned %d, want 200", rec.Code) + } +} + +func TestErrorMessagesAreUIKeysNotProse(t *testing.T) { + // Error copy lives in the frontend. A server that sent prose would put + // Vietnamese strings in two places, and an internal error string would + // leak server detail to anyone holding a socket. + m := errorMsg("room_not_found").GetError() + if strings.ContainsAny(m.GetCode(), " .") { + t.Errorf("error code %q looks like prose", m.GetCode()) + } + if m.GetMessage() != m.GetCode() { + t.Errorf("message %q diverged from code %q", m.GetMessage(), m.GetCode()) + } +} diff --git a/server/internal/wsapi/ratelimit_test.go b/server/internal/wsapi/ratelimit_test.go new file mode 100644 index 0000000..6411d92 --- /dev/null +++ b/server/internal/wsapi/ratelimit_test.go @@ -0,0 +1,104 @@ +package wsapi + +import ( + "testing" + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// The rate limiters themselves, and the actions that spend them. + +// TestSubmitRateLimit checks the token bucket refuses a burst well past what a +// person types, since each submit costs a dictionary lookup. +func TestSubmitRateLimit(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người thử") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + started := c.await("game_started").GetGameStarted() + + for range submitBurst + 5 { + c.submit("khong co", started.GetTurnSeq()) + } + + for range 40 { + m := c.recv() + if payloadCase(m) == "error" && m.GetError().GetCode() == "too_fast" { + return + } + } + t.Error("never rate-limited despite submitting past the burst") +} + +func TestBucketRefills(t *testing.T) { + now := time.Now() + b := newBucket(5, 2, now) + + for i := range 2 { + if !b.allow(now) { + t.Fatalf("burst should cover the first two, call %d refused", i+1) + } + } + if b.allow(now) { + t.Fatal("third call should exhaust the bucket") + } + + // 5/sec means one token back after 200ms. + if !b.allow(now.Add(220 * time.Millisecond)) { + t.Error("bucket did not refill") + } +} + +func TestKeyedLimiterIsPerKey(t *testing.T) { + now := time.Now() + l := newKeyedLimiter(1, 1, time.Minute) + + if !l.allow("a", now) { + t.Fatal("first call for a key should pass") + } + if l.allow("a", now) { + t.Fatal("second call for the same key should be refused") + } + if !l.allow("b", now) { + t.Error("a different key must have its own bucket") + } +} + +func TestKeyedLimiterSweepsIdleBuckets(t *testing.T) { + now := time.Now() + l := newKeyedLimiter(10, 5, time.Minute) + l.allow("gone", now) + + l.sweep(now.Add(2 * time.Minute)) + + l.mu.Lock() + n := len(l.buckets) + l.mu.Unlock() + if n != 0 { + t.Errorf("%d buckets survived the sweep, want 0", n) + } +} + +// TestLobbyActionsAreRateLimited: every accepted action is broadcast to both +// seats, so an unbounded one lets a player fill the opponent's outbox until +// the server closes their session for falling behind. +func TestLobbyActionsAreRateLimited(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + _, guest, _ := pvpLobby(t, url) + + for i := range submitBurst + 5 { + guest.setReady(i%2 == 0) + } + + // The limiter answers before the room does, so a refusal has to appear in + // the stream rather than an unbroken run of room states. + for range 30 { + if payloadCase(guest.recv()) == "error" { + return + } + } + t.Error("a burst of lobby actions was never refused") +} diff --git a/server/internal/wsapi/report_test.go b/server/internal/wsapi/report_test.go new file mode 100644 index 0000000..7d1f4d8 --- /dev/null +++ b/server/internal/wsapi/report_test.go @@ -0,0 +1,63 @@ +package wsapi + +import ( + "fmt" + "testing" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// Reporting a word the dictionary should have accepted. + +// TestReportWordAcceptsAndEchoes needs no room at all: filing a report is a +// session-level fact, which is also what a direct wire client per the README +// would exercise. +func TestReportWordAcceptsAndEchoes(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người chơi") + + c.reportWord("bình tâm") + got := c.await("word_reported").GetWordReported() + if got.GetWord() != "bình tâm" { + t.Errorf("echoed word = %q, want %q", got.GetWord(), "bình tâm") + } +} + +func TestReportWordRefusesOneSyllable(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người chơi") + + c.reportWord("một") + if code := c.await("error").GetError().GetCode(); code != "word_report_refused" { + t.Errorf("code = %q, want word_report_refused", code) + } +} + +// TestReportWordEnforcesPerSessionCap drives the cap directly against the +// session rather than through a real socket: 21 reports through the chat rate +// limiter (chatBurst=5) would be a test of two budgets fighting each other +// rather than of the cap itself. +func TestReportWordEnforcesPerSessionCap(t *testing.T) { + s := offlineSession(t, maxWordReportsPerSession+8) + + for i := range maxWordReportsPerSession { + s.handleReportWord(&noituv1.ReportWord{Word: fmt.Sprintf("từ số %d", i)}) + } + accepted := queued(t, s) + if len(accepted) != maxWordReportsPerSession { + t.Fatalf("got %d replies for %d distinct reports, want one each", len(accepted), maxWordReportsPerSession) + } + for _, m := range accepted { + if m.GetWordReported() == nil { + t.Errorf("a report inside the cap was refused: %+v", m) + } + } + + s.handleReportWord(&noituv1.ReportWord{Word: "một từ khác nữa"}) + overflow := queued(t, s) + if len(overflow) != 1 || overflow[0].GetError().GetCode() != "word_report_limit" { + t.Fatalf("the report past the cap = %+v, want a single word_report_limit error", overflow) + } +} diff --git a/server/internal/wsapi/resume_test.go b/server/internal/wsapi/resume_test.go new file mode 100644 index 0000000..1f3abf2 --- /dev/null +++ b/server/internal/wsapi/resume_test.go @@ -0,0 +1,212 @@ +package wsapi + +import ( + "testing" + "time" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// Coming back with a resume token: within the grace window, past it, and +// what a token that no longer resolves to anything gets told. + +// TestResumeWithinGraceRestoresGame drops a connection mid-game and brings it +// back with the resume token. +func TestResumeWithinGraceRestoresGame(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second, GraceFor: 5 * time.Second}) + + host := dial(t, url) + welcome := host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + readyAndStart(t, host, guest) + hostStart := host.await("game_started").GetGameStarted() + guest.await("game_started") + + _ = host.conn.Close(websocket.StatusGoingAway, "") + + // Presence is part of the room's state now, so the seat being held is + // something the other player reads there rather than in a message of its + // own. + if away := otherSlot(guest.await("room_state").GetRoomState()); away == nil || away.GetConnected() { + t.Error("opponent should be shown as away while the seat is held") + } + + // Reconnect with the token and expect the position back. + back := dial(t, url) + back.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ + ProtocolVersion: ProtocolVersion, + Nickname: "Chủ phòng", + ResumeToken: welcome.GetResumeToken(), + }}}) + back.await("welcome") + + restored := back.await("game_started").GetGameStarted() + if restored.GetOpeningWord() != "a b" { + t.Errorf("resumed on %q, want the original opening", restored.GetOpeningWord()) + } + if restored.GetMyTurn() != hostStart.GetMyTurn() { + t.Error("the resumed player should come back to the turn they left") + } +} + +// TestUnknownResumeTokenIsAnsweredNotSilent: a token the server never +// registered, or has already forgotten past its grace window, used to get +// silence. The client's own resume latch then waited forever for a reply that +// was never coming — this is the fix, and the connection must still be usable +// afterward as the fresh session it is. +func TestUnknownResumeTokenIsAnsweredNotSilent(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ + ProtocolVersion: ProtocolVersion, + Nickname: "Người chơi", + ResumeToken: "no-such-token", + }}}) + c.await("welcome") + + if code := c.await("error").GetError().GetCode(); code != "session_not_resumable" { + t.Errorf("error code = %q, want session_not_resumable", code) + } + + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + if code := c.await("room_state").GetRoomState().GetRoomCode(); code == "" { + t.Error("a connection answered session_not_resumable must still be usable as a fresh session") + } +} + +// TestFreshHelloIsNotToldItCannotResume: a Hello with no resume token at all +// is not a resume attempt, and must not be answered as a failed one. +func TestFreshHelloIsNotToldItCannotResume(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người chơi") + + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + if m := c.recv(); payloadCase(m) == "error" { + t.Fatalf("a fresh Hello with no resume token got %q, want none", m.GetError().GetCode()) + } +} + +// TestChatHistoryIsReplayedOnResumeInTheLobby is the commonest refresh there +// is, and the one a replay hung off the end of handleResume would miss: that +// function returns early for a lobby resume. +func TestChatHistoryIsReplayedOnResumeInTheLobby(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + host := dial(t, url) + welcome := host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + host.await("room_state") + guest.await("room_state") + + host.say("một") + host.await("chat_message") + guest.say("hai") + host.await("chat_message") + + // The tab reloads: same token, new socket, still in the lobby. + _ = host.conn.Close(websocket.StatusAbnormalClosure, "") + back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken()) + + history := back.await("chat_history").GetChatHistory().GetMessages() + if len(history) != 2 { + t.Fatalf("the resumed lobby replayed %d messages, want 2: %+v", len(history), history) + } + if history[0].GetText() != "một" || history[1].GetText() != "hai" { + t.Errorf("history is out of order: %+v", history) + } + if !history[0].GetFromMe() || history[1].GetFromMe() { + t.Errorf("authorship did not survive the resume: %+v", history) + } +} + +// TestChatHistoryIsReplayedOnResumeMidGame covers the same during a game, +// where handleResume takes its other path. +func TestChatHistoryIsReplayedOnResumeMidGame(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + host := dial(t, url) + welcome := host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + readyAndStart(t, host, guest) + host.await("game_started") + guest.await("game_started") + + host.say("đang chơi") + host.await("chat_message") + + _ = host.conn.Close(websocket.StatusAbnormalClosure, "") + back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken()) + + if got := back.await("chat_history").GetChatHistory().GetMessages(); len(got) != 1 { + t.Errorf("a mid-game resume replayed %d messages, want 1", len(got)) + } +} + +// TestResumeReclaimsALobbySeat: a room outlives its games now, so a player who +// refreshes between them has a lobby to come back to rather than a refusal. +func TestResumeReclaimsALobbySeat(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + + host := dial(t, url) + welcome := host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code := host.await("room_state").GetRoomState().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + host.await("room_state") + guest.await("room_state") + + // The owner's tab reloads: same token, new socket. + _ = host.conn.Close(websocket.StatusAbnormalClosure, "") + guest.await("room_state") + + second := dial(t, url) + second.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ + ProtocolVersion: ProtocolVersion, + Nickname: "Chủ phòng", + ResumeToken: welcome.GetResumeToken(), + }}}) + second.await("welcome") + + back := second.await("room_state").GetRoomState() + if !mySlot(back).GetIsOwner() { + t.Errorf("the owner came back as a guest: %+v", back) + } + if otherSlot(back) == nil || back.GetRoomCode() != code { + t.Errorf("the resumed lobby is not the one they left: %+v", back) + } + + // And the room still works from both sides. + guest.setReady(true) + second.await("room_state") + second.startGame() + second.await("game_started") + guest.await("game_started") +} diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go index b0d8f1b..71fa0bc 100644 --- a/server/internal/wsapi/room.go +++ b/server/internal/wsapi/room.go @@ -2,10 +2,7 @@ package wsapi import ( "context" - "iter" "log/slog" - "math/rand/v2" - "slices" "sync/atomic" "time" @@ -13,9 +10,15 @@ import ( "github.com/tiennm99dev/noitu/server/internal/bot" "github.com/tiennm99dev/noitu/server/internal/dictionary" "github.com/tiennm99dev/noitu/server/internal/game" - "github.com/tiennm99dev/noitu/server/internal/vietnamese" ) +// This file holds the room's core type, its constructor, its input loop, +// and the small seat-authority helpers every other file in this package +// reads. Everything that only ever runs on the room goroutine still lives +// wherever the review's file split put it (room_lobby.go, room_game.go, +// room_presence.go, room_chat.go, bot_board.go) — this is a file boundary, +// not a change to who may touch a *room. + // roomModeBot and roomModePvP are the two values a room's mode ever takes. // They double as the label under which every mode-keyed metric and the // word_rejected log line group their counts, so a reader checking one against @@ -82,125 +85,6 @@ const roomInputCap = 32 // already ends it. const defaultIdleWindow = 10 * time.Minute -// Room input messages. Everything that can change a game arrives as one of -// these on a single channel, which is what makes the engine safe without a -// lock: the room goroutine is its only reader. - -// createInput and startBotInput seat the first player. Seating is a message -// rather than a direct write so that every touch of room state — seats and -// engine alike — happens on the room goroutine, which makes the ownership -// invariant provable by reading run() rather than by reasoning about which -// writes happened before `go r.run()`. -type createInput struct { - sess *session - // autoStart marks a room a quick match opened rather than a player asking - // for a code: once both seats are filled and connected, the room begins - // its own first game instead of waiting on readiness and StartGame. - autoStart bool -} - -type startBotInput struct { - sess *session - difficulty bot.Difficulty -} - -type joinInput struct { - sess *session -} - -// submitInput and resignInput carry the connection that sent them, not just -// the seat it claims. A room code is a shared secret — it is pasted into group -// chats by design — so holding one must not be enough to act as a player who -// is already seated. -type submitInput struct { - sess *session - player game.PlayerID - word string - turnSeq uint32 -} - -// lobbyAction is one thing a player does to the room rather than to a game. -type lobbyAction uint8 - -const ( - lobbyReady lobbyAction = iota - lobbyStart - lobbyKick - lobbyLeave -) - -// lobbyInput is one lobby action. They share a type because they share every -// authorization step — the seat, the room's mode, and whether a game is -// running — and splitting them would mean four copies of those checks. -type lobbyInput struct { - sess *session - player game.PlayerID - action lobbyAction - // ready is the value a lobbyReady is setting. Explicit rather than a - // toggle: a toggle applied to a state the client is a frame behind on sets - // the opposite of what the player clicked. - ready bool - // target is the seat a lobbyKick names. A room holds up to four people, so - // "the other one" stopped being an answer. - target game.PlayerID -} - -// chatInput is one line of text from a seated player. It carries the -// connection, not just the seat it claims, for the same reason submitInput -// does: a room code is a shared secret, and a connection the room has retired -// must not be able to speak as the seat it used to hold. -type chatInput struct { - sess *session - player game.PlayerID - text string -} - -type resignInput struct { - sess *session - player game.PlayerID -} - -// claimDeadEndInput is the player to act saying the syllable has no answer -// left. Carries the connection, not just the claimed seat, for the same -// reason resignInput does. -type claimDeadEndInput struct { - sess *session - player game.PlayerID -} - -// reportWordInput is a word the session has already validated as reportable — -// long enough, and within its own per-session cap — waiting only on the room -// for the context a report is logged with: the syllable in play, if any. -type reportWordInput struct { - sess *session - word string -} - -type disconnectInput struct { - player game.PlayerID - // sess identifies which connection dropped. A player who already - // reconnected has a different session, and that stale notice must not - // evict the seat the new connection just took. - sess *session -} - -type resumeInput struct { - player game.PlayerID - sess *session - // prior is the connection being replaced. The room retires it only once it - // has decided the resume is allowed, because closing it on a refusal would - // end the very game the client was trying to rejoin. - prior *session -} - -type botMoveInput struct { - word string - err error - // turnSeq the bot was thinking about. If the game moved on — a resign - // landed while it thought — the move is stale and dropped. - turnSeq uint32 -} - // seat is one side of a game. type seat struct { id game.PlayerID @@ -225,21 +109,6 @@ type seat struct { graceUntil time.Time } -// chatEntry is one line of the room's conversation. -type chatEntry struct { - // seq is this message's place in the room's whole conversation, compared - // against a seat's chatFrom to decide what that player may be replayed. - seq uint64 - // author and name are cleared together when the seat is vacated: the words - // stay, the attribution does not. Keeping the name would let the next - // person to request that nickname inherit a stranger's messages, since - // distinguish only compares against the seat that is currently occupied. - author game.PlayerID - name string - text string - at time.Time -} - // room owns one game. // // Every field below is touched only by the room goroutine after start. The @@ -566,282 +435,6 @@ func (r *room) run() { } } -// handleCreate seats the room's creator, who owns it, and opens the lobby. -// -// The code goes out in the RoomState the run loop broadcasts, so a client can -// never be handed a code before the seat behind it exists. -func (r *room) handleCreate(m createInput) { - r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq} - r.owner = "p1" - r.autoStart = m.autoStart - m.sess.attach(r, "p1") - r.lobbyChanged = true - // A quick match already popped this session off the pairing queue before - // sending it here, but a plain CreateRoom might still be seating somebody - // who was also waiting in it from another attempt — one dequeue serves - // both room-entry paths. - r.hub.cancelQuickMatch(m.sess) - // Deliberately sent to a brand-new room's creator, where it is always - // empty: it is what replaces the conversation a client may still be - // holding from a room it was in before this one. - r.sendChatHistory(r.seats[0]) -} - -// handleResign is one player giving up on their own turn. The seat, not the -// claimed id, is the authority, as everywhere a connection acts on a room. -// -// Only the player to act may give up. Giving up is a move — it is what is -// played instead of a word — and a seat that could spend it while somebody -// else was thinking would be deciding the turn of a player who had not -// finished theirs. Somebody who wants out of a game they are not on turn in -// leaves the room instead, which handleLobby answers. -func (r *room) handleResign(m resignInput) { - if !r.occupies(m.sess, m.player) { - m.sess.send(errorMsg("not_your_seat")) - return - } - if r.engine == nil || r.engine.Over() { - return - } - if r.engine.Turn() != m.player { - m.sess.send(errorMsg("not_your_turn")) - return - } - before := r.mark() - if r.engine.Resign(m.player, time.Now()) { - r.applyEliminations(before) - } -} - -// handleClaimDeadEnd is the player to act saying the syllable in play has no -// answer left, checked rather than trusted. -// -// A true claim takes them out at once with EndNoLegalMove — exactly what the -// clock would eventually rule, so the game's own outcome is unchanged and -// only the wait is gone. A false claim changes nothing at all: the clock -// keeps running and the claimant is simply told a word exists, which is hint -// enough to be the whole cost of asking wrongly. -func (r *room) handleClaimDeadEnd(m claimDeadEndInput) { - if !r.occupies(m.sess, m.player) { - m.sess.send(errorMsg("not_your_seat")) - return - } - if r.engine == nil { - m.sess.send(errorMsg("game_not_started")) - return - } - if r.engine.Over() { - return - } - if r.engine.Turn() != m.player { - m.sess.send(errorMsg("not_your_turn")) - return - } - if r.engine.HasLegalMove() { - metrics.deadEndClaims.Add("false", 1) - m.sess.send(errorMsg("not_a_dead_end")) - return - } - - metrics.deadEndClaims.Add("true", 1) - before := r.mark() - if r.engine.NoMove(time.Now()) { - r.applyEliminations(before) - } -} - -// handleStartBot seats a bot opposite the player and begins immediately. -func (r *room) handleStartBot(m startBotInput) { - strategy, err := bot.New(m.difficulty, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))) - if err != nil { - m.sess.send(errorMsg("room_start_failed")) - r.cancel() - return - } - - r.strategy = strategy - r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq} - r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"} - r.owner = "p1" - m.sess.attach(r, "p1") - r.hub.cancelQuickMatch(m.sess) - - if err := r.beginGame(); err != nil { - slog.Error("could not start bot game", "room", r.code, "err", err) - m.sess.send(errorMsg("game_start_failed")) - r.cancel() - } -} - -// handleJoin seats another human in the lobby. It does not start anything: the -// owner does that, once everybody has said they are ready. -// -// The seat is bound here, on the room goroutine, and only on success. Binding -// it in the hub before this decision would leave a refused joiner still -// holding a seat, and every later Submit or Resign it sent would be applied to -// the real player sitting there. -func (r *room) handleJoin(m joinInput) { - free := r.freeSeat() - if free < 0 || !r.occupied() { - metrics.joinsRefused.Add("room_full", 1) - m.sess.send(errorMsg("room_full")) - return - } - // A room can have a free seat and still be mid-game — four people can - // start a game three of them are in. Arriving in the middle of one is not - // something to seat somebody for: they would have no words, no score, and - // no way to be told what they had missed. - if !r.inLobby() { - m.sess.send(errorMsg("game_in_progress")) - return - } - for _, s := range r.seats { - if s != nil && s.sess == m.sess { - m.sess.send(errorMsg("cannot_join_own_room")) - return - } - } - - id := seatIDs[free] - r.seats[free] = &seat{ - id: id, - nickname: distinguish(m.sess.nickname(), r.takenNicknames(id)), - sess: m.sess, - // Seated now, so the conversation up to this point is not theirs to - // read. A room code is pasted into group chats by design. - chatFrom: r.chatSeq, - } - m.sess.attach(r, string(id)) - r.lobbyChanged = true - r.hub.cancelQuickMatch(m.sess) - r.sendChatHistory(r.seats[free]) - - // A quick match seats both players itself rather than waiting on - // readiness and StartGame — there is no owner here to press it, only two - // strangers who both already asked to be matched. The lobby is shown - // first, with both seats filled, so the wait ends on an ordinary room a - // beat before GameStarted rather than jumping straight into one with no - // seating frame behind it. - if r.autoStart && r.seatedCount() >= minPlayers && r.allConnected() { - r.autoStart = false - r.lobbyChanged = false - r.broadcastRoomState() - if err := r.beginGame(); err != nil { - slog.Error("could not start quick-matched game", "room", r.code, "err", err) - r.broadcastError("game_start_failed") - } - } -} - -// handleLobby applies one lobby action. -// -// Every refusal answers with a reason. A lobby button that silently does -// nothing is indistinguishable from one that is broken, and the player cannot -// see the state that refused them. -func (r *room) handleLobby(m lobbyInput) { - if !r.occupies(m.sess, m.player) { - m.sess.send(errorMsg("not_your_seat")) - return - } - if r.strategy != nil { - // A bot room has no lobby: one player, no readiness, nobody to kick. - m.sess.send(errorMsg("not_in_a_room")) - return - } - // Leaving is the exception: a player may want out of a game it is not - // their turn in, and resigning is not open to them then. Readying, - // starting and kicking all belong to a room between games. - if !r.inLobby() && m.action != lobbyLeave { - m.sess.send(errorMsg("game_in_progress")) - return - } - - mine := r.seatOf(m.player) - isOwner := m.player == r.owner - - switch m.action { - case lobbyReady: - if isOwner { - // The owner's readiness is StartGame. A flag of their own would - // only be something they had to set before every single start. - m.sess.send(errorMsg("owner_needs_no_ready")) - return - } - mine.ready = m.ready - r.lobbyChanged = true - - case lobbyStart: - if !isOwner { - m.sess.send(errorMsg("not_the_owner")) - return - } - switch { - case r.seatedCount() < minPlayers: - m.sess.send(errorMsg("need_more_players")) - return - case !r.allConnected(): - m.sess.send(errorMsg("player_offline")) - return - case !r.guestsReady(): - m.sess.send(errorMsg("not_everyone_ready")) - return - } - if err := r.beginGame(); err != nil { - slog.Error("could not start pvp game", "room", r.code, "err", err) - r.broadcastError("game_start_failed") - } - - case lobbyKick: - if !isOwner { - m.sess.send(errorMsg("not_the_owner")) - return - } - target := r.seatOf(m.target) - switch { - case target == nil: - m.sess.send(errorMsg("no_one_to_kick")) - return - case target == mine: - // Leaving is what an owner who wants out does, and it hands the - // room on. Kicking yourself would drop the seat and the role - // together while the others were still sitting here. - m.sess.send(errorMsg("cannot_kick_self")) - return - case target.ready: - // Readiness is a commitment, and the owner does not get to - // overrule one: a player who is ready is waiting on the owner, - // not in the way. - m.sess.send(errorMsg("player_is_ready")) - return - } - if target.sess != nil { - target.sess.send(errorMsg("kicked")) - } - r.vacate(target) - r.lobbyChanged = true - - case lobbyLeave: - if r.inLobby() { - // Unreadying first is deliberate friction: a player the other one - // is waiting on should have to take that back before walking away. - if mine.ready { - m.sess.send(errorMsg("must_unready_first")) - return - } - } else { - // Out of a running game, which is the same thing to everybody else - // as a reconnect window running out: somebody left. The engine - // goes first, while the seat is still here to be named in what is - // broadcast about it. - before := r.mark() - r.eliminateAbsent(mine, time.Now()) - r.applyEliminations(before) - } - r.vacate(mine) - r.lobbyChanged = true - } -} - // occupies reports whether this connection is the one seated at p. // // The seat, not the claimed id, is the authority: a session that was never @@ -851,744 +444,6 @@ func (r *room) occupies(sess *session, p game.PlayerID) bool { return s != nil && s.sess != nil && s.sess == sess } -// beginGame builds the engine and tells both seats the game is on. -func (r *room) beginGame() error { - opening, err := r.dict.RandomOpeningWord(minOpeningOutDegree) - if err != nil { - return err - } - - // Seat order is turn order, so a player's place at the table is the place - // they took in the lobby and nothing has to be shuffled or announced. - ids := make([]game.PlayerID, 0, maxPlayers) - for _, s := range r.seats { - if s != nil { - ids = append(ids, s.id) - } - } - // Who leads is drawn rather than owned. Opening the game is an advantage — - // the first player picks from a whole syllable, everyone after them plays - // what is left of it — and giving it to whoever happened to create the - // room would make the same person favourite in every game of a series. - // - // Rotating rather than shuffling keeps the table intact: everybody still - // plays in the order they sat down, the cycle just starts somewhere else. - // A bot room is left alone; it has no table to be fair about, and the - // human opens. - if r.strategy == nil { - lead := rand.IntN(len(ids)) - ids = slices.Concat(ids[lead:], ids[:lead]) - } - - engine, err := game.New(r.dict, ids, opening, r.turnLimit, time.Now()) - if err != nil { - return err - } - r.engine = engine - r.opening = opening - // Fresh per game: an override from the last one would describe a player - // who has since come back and is playing this one. - r.outWire = make(map[game.PlayerID]noituv1.GameEndReason, len(ids)) - // Never restarts at 1. A rematch reuses the same connections, so a - // submission still in flight from the previous game would otherwise be - // able to match a turn in this one and be applied to it. - r.turnSeq++ - // Every game is agreed on its own. The readiness that started this one is - // spent, so the lobby they come back to asks again. - for _, s := range r.seats { - if s != nil { - s.ready = false - } - } - - metrics.gamesStarted.Add(r.mode, 1) - r.hub.gameStarted() - r.liveCounted.Store(true) - - state := r.engine.Snapshot() - for _, s := range r.seats { - r.sendGameStarted(s, state) - } - r.maybeScheduleBot() - return nil -} - -// sendGameStarted renders the opening position for one seat. my_turn and is_me -// are per-recipient, which is why this is built per seat rather than broadcast. -func (r *room) sendGameStarted(s *seat, state game.State) { - if s == nil || s.sess == nil { - return - } - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameStarted{ - GameStarted: &noituv1.GameStarted{ - OpeningWord: r.opening, - OpeningMeanings: Senses(r.dict.Meanings(r.opening)), - CurrentSyllable: state.Current, - MyTurn: state.Turn == s.id, - DeadlineUnixMs: state.Deadline.UnixMilli(), - TurnSeq: r.turnSeq, - TurnLimitMs: uint32(r.turnLimit.Milliseconds()), - Players: r.scoreRows(r.engine.Players(), state, s.id, nil), - TurnPlayerId: string(state.Turn), - }, - }}) -} - -// handleSubmit runs one human move through the engine. -func (r *room) handleSubmit(m submitInput) { - if !r.occupies(m.sess, m.player) { - m.sess.send(errorMsg("not_your_seat")) - return - } - if r.engine == nil { - r.sendTo(m.player, errorMsg("game_not_started")) - return - } - - // A submission stamped with an old turn is answering a position that no - // longer exists — a double-submit, or a word typed as the clock ran out. - // Applying it to the current turn would play a word the player never - // chose for this position. - // The rejection carries the server's sequence, not the client's stale one, - // so the client can resynchronise from the refusal instead of having to - // wait for the next turn update to discover where the game actually is. - metrics.wordsSubmitted.Add(1) - - if m.turnSeq != r.turnSeq { - r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq, "")) - r.recordRejection(game.ReasonNotYourTurn, m.word) - return - } - - // The typed text is echoed back to every seat as PlayedWord.typed, so it - // crosses the same trust boundary a chat line does and gets the same - // filter. The engine's own normalization only lowercases and collapses - // whitespace; it does not drop format characters. - word := sanitizeText(m.word, maxWordRunes, maxNicknameMarks) - - before := r.mark() - move, reason := r.engine.Submit(m.player, word, time.Now()) - if reason != game.ReasonNone { - r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq, r.nearMissFor(reason, word))) - r.recordRejection(reason, word) - // A rejection for an expired turn also took this player out of the - // game, and everybody has to be told which. - r.applyEliminations(before) - return - } - metrics.wordsAccepted.Add(1) - - // An accepted move never ends a game: a dead end is left for whoever - // inherits it, which is what Submit's own comment explains. - r.turnSeq++ - r.broadcastTurn(&move) - r.maybeScheduleBot() -} - -// nearMissFor finds a diacritic-typo suggestion for a word the dictionary -// refused. Only for REJECT_REASON_NOT_IN_DICTIONARY: every other rejection -// means the word IS in the dictionary and was refused for some other reason, -// where a spelling suggestion would be misleading rather than helpful. -func (r *room) nearMissFor(reason game.RejectReason, raw string) string { - if reason != game.ReasonNotInDictionary { - return "" - } - normalized, _, err := vietnamese.Normalize(raw) - if err != nil { - return "" - } - suggestion, ok := r.dict.NearMiss(normalized) - if !ok { - return "" - } - // The dictionary check comes before the link and reuse checks in Submit, - // so a real word can be a near miss and still be unplayable here. Offering - // it would send the player straight into a second refusal. - if first, ok := r.dict.FirstSyllable(suggestion); !ok || first != r.engine.Current() || r.engine.Used(suggestion) { - return "" - } - return suggestion -} - -// recordRejection counts one rejected submission and logs it at Info. -// -// This is the corpus feedback loop the improvement report calls the input to -// every decision about the dictionary: which words players actually type that -// the game does not accept, and why. The word logged is never the raw typed -// text — it is normalized the same way the engine would have matched it -// (NFC, lowercase, single-spaced) and capped, so the line is useful for corpus -// review without ever logging what a player literally typed into the box. -func (r *room) recordRejection(reason game.RejectReason, raw string) { - metrics.wordsRejected.Add(reason.String(), 1) - - // sanitizeText first: raw may be the untouched client payload (the - // not-your-turn path never reaches the sanitizer below it in - // handleSubmit), and Normalize alone does not drop control or format - // characters. - word, _, err := vietnamese.Normalize(sanitizeText(raw, maxWordRunes, maxNicknameMarks)) - if err != nil { - word = "" - } - if runes := []rune(word); len(runes) > maxWordRunes { - word = string(runes[:maxWordRunes]) - } - - slog.Info("word_rejected", - "reason", reason.String(), - "word", word, - "link", r.engine.Current(), - "mode", r.mode, - "room", r.code, - ) -} - -// handleReportWord logs one report with this room's context. -// -// The session has already checked the word is long enough and within its own -// per-session cap before routing it here — this is only about what to log, -// and the syllable in play, this room's mode and its code are all room -// goroutine state that only the room may read. Never the reporting player's -// seat or name: recordRejection keeps the same information out of the corpus -// feedback loop for the same reason. -func (r *room) handleReportWord(m reportWordInput) { - link := "" - if r.engine != nil { - link = r.engine.Current() - } - metrics.wordsReported.Add(1) - slog.Info("word_reported", "word", m.word, "link", link, "mode", r.mode, "room", r.code) - m.sess.send(wordReportedMsg(m.word)) -} - -// handleBotMove applies what the worker chose. -func (r *room) handleBotMove(m botMoveInput) { - if r.engine == nil || r.engine.Over() { - return - } - // The position moved on while it was thinking; the chosen word answers a - // board that no longer exists. - if m.turnSeq != r.turnSeq || r.engine.Turn() != botPlayerID { - return - } - metrics.botMoves.Add(r.strategy.Difficulty().String(), 1) - - now := time.Now() - before := r.mark() - - if m.err != nil { - // The bot has nothing to play. A human in this position keeps their - // turn and loses it to the clock; the bot has no clock to spend, so - // the position is settled now and reported for what it is rather than - // as a resignation it never chose. - if !r.engine.NoMove(now) { - r.engine.Resign(botPlayerID, now) - } - r.applyEliminations(before) - return - } - - move, reason := r.engine.Submit(botPlayerID, m.word, now) - if reason != game.ReasonNone { - // The bot searched the same dictionary the engine validates against, - // so this means the two disagree — a bug worth seeing, not a move to - // retry. - slog.Error("bot move rejected by engine", "room", r.code, "word", m.word, "reason", reason.String()) - r.engine.Resign(botPlayerID, now) - r.applyEliminations(before) - return - } - - r.turnSeq++ - r.broadcastTurn(&move) -} - -// maybeScheduleBot starts the bot thinking if it is now its turn. -func (r *room) maybeScheduleBot() { - if r.strategy == nil || r.engine.Over() || r.engine.Turn() != botPlayerID { - return - } - - // The board is frozen here, on the room goroutine, before the worker - // exists. Handing the worker the live engine instead would race every - // resign and disconnect the room processes while the bot thinks — and - // bot.Board.Used reads engine state, so the race would be real, not - // theoretical. - board := freezeBoard(r.engine, r.opening) - seq := r.turnSeq - strategy := r.strategy - - go func() { - word, err := strategy.Choose(board) - - // The pause is a courtesy to the player, so it must not outlive the - // room: a bot still sleeping after everyone left is a goroutine leak - // per abandoned game. - select { - case <-time.After(strategy.ThinkingDelay()): - case <-r.ctx.Done(): - return - } - r.send(botMoveInput{word: word, err: err, turnSeq: seq}) - }() -} - -// broadcastTurn sends the position to every seat, rendered for each. -// -// move is nil when the turn moved without a word being played, which is what -// an elimination does: the syllable and the used set survive the player who -// could not answer them, and everybody still needs the new deadline and the -// new player to act. -func (r *room) broadcastTurn(move *game.Move) { - state := r.engine.Snapshot() - meanings := r.moveMeanings(move) - for _, s := range r.seats { - r.sendTurnUpdate(s, state, move, meanings) - } -} - -// moveMeanings looks up a played word's senses once per move; they are the -// same for every recipient. nil for no move. -func (r *room) moveMeanings(move *game.Move) []dictionary.Sense { - if move == nil { - return nil - } - return r.dict.Meanings(move.Word) -} - -// sendTurnUpdate renders one position for one seat. by_me, my_turn and is_me -// are all per-recipient, which is why there is no single shared frame; the -// move's meanings are not, and arrive looked up. -func (r *room) sendTurnUpdate(s *seat, state game.State, move *game.Move, meanings []dictionary.Sense) { - if s == nil || s.sess == nil { - return - } - update := &noituv1.TurnUpdate{ - CurrentSyllable: state.Current, - MyTurn: state.Turn == s.id, - DeadlineUnixMs: state.Deadline.UnixMilli(), - TurnSeq: r.turnSeq, - ChainLength: uint32(state.ChainLength), - Players: r.scoreRows(r.engine.Players(), state, s.id, nil), - TurnPlayerId: string(state.Turn), - } - if move != nil { - update.Played = PlayedWord(*move, move.Player == s.id, meanings) - } - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: update}}) -} - -// inputMark is what the game looked like before an input: how many players -// were out, and who was to act. Remembered across the input so -// applyEliminations can tell that input's doing from what was already true, -// and whether it moved the turn. -type inputMark struct { - out int - turn game.PlayerID -} - -// mark reads the current game, or the zero mark when there is no game. -func (r *room) mark() inputMark { - if r.engine == nil { - return inputMark{} - } - return inputMark{out: r.engine.EliminatedCount(), turn: r.engine.Turn()} -} - -// applyEliminations reports everybody the last input knocked out, then whatever -// the game became: finished, or one turn further on. -// -// Every path that takes a player out of a game ends here — a timeout, a -// resignation, a bot with nothing to play, a reconnect window running out — so -// there is one place that decides what the room says about it. -func (r *room) applyEliminations(before inputMark) { - if r.engine == nil { - return - } - state := r.engine.Snapshot() - if len(state.Eliminated) == before.out { - return - } - - // An elimination does not move the position, so one lookup describes it - // for everybody who went out on this input. - suggestions := r.engine.Suggestions(maxSuggestions) - for _, id := range state.Eliminated[before.out:] { - r.broadcastElimination(id, suggestions) - } - - if r.engine.Over() { - r.broadcastGameOver(state) - return - } - // A new turn nobody played into, and the sequence moves with it: a - // submission already in flight was answering the position the player who - // just went out was looking at. - // - // It moves only when the turn does. Somebody forfeiting out of turn — a - // player who left the room, or whose reconnect window ran out — leaves the - // syllable, the deadline and the player to act exactly as they were, so - // the word that player is already sending still answers the board it was - // typed for. Bumping the sequence there would refuse it for something - // somebody else did. - if state.Turn != before.turn { - r.turnSeq++ - } - r.broadcastTurn(nil) -} - -// broadcastElimination tells the room one player is out. -// -// The suggestions go only to that player. They are what the position still had -// to offer, and the people who could still answer it are not the ones who -// needed to be told — an empty list is the answer for whoever was stuck, and -// noise for everybody else. -func (r *room) broadcastElimination(id game.PlayerID, suggestions []string) { - name := "" - if out := r.seatOf(id); out != nil { - name = out.nickname - } - reason := r.wireEndReason(id) - metrics.eliminations.Add(reason.String(), 1) - - for _, s := range r.seats { - if s == nil || s.sess == nil { - continue - } - msg := &noituv1.PlayerEliminated{ - PlayerId: string(id), - Name: name, - IsMe: s.id == id, - Reason: reason, - } - if s.id == id { - msg.Suggestions = suggestions - } - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_PlayerEliminated{ - PlayerEliminated: msg, - }}) - } -} - -// wireEndReason says how one player left the game. -// -// The engine's answer, unless the room overrode it: a reconnect window running -// out is a resignation to the engine, because that is the only shape it has -// for a player who stops playing, and somebody who left to everybody in the -// room. -func (r *room) wireEndReason(p game.PlayerID) noituv1.GameEndReason { - if code, overridden := r.outWire[p]; overridden { - return code - } - return EndReason(r.engine.OutReason(p)) -} - -// broadcastGameOver reports the result from each seat's point of view. -func (r *room) broadcastGameOver(state game.State) { - metrics.gamesFinished.Add(r.mode, 1) - if r.liveCounted.CompareAndSwap(true, false) { - r.hub.gameFinished() - } - - // The reason the game ended is the reason the last player went out, which - // with two seats is the only elimination there was. - reason := noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED - if n := len(state.Eliminated); n > 0 { - reason = r.wireEndReason(state.Eliminated[n-1]) - } - - // Credited before anything is sent, so the RoomState the run loop - // broadcasts after a finished game already carries the game just won. - if s := r.seatOf(state.Winner); s != nil { - s.wins++ - } - - ranks := make(map[game.PlayerID]int, len(state.Standings)) - order := make([]game.PlayerID, 0, len(state.Standings)) - for _, standing := range state.Standings { - ranks[standing.Player] = standing.Rank - order = append(order, standing.Player) - } - - for _, s := range r.seats { - if s == nil || s.sess == nil { - continue - } - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ - GameOver: &noituv1.GameOver{ - IWon: state.Winner == s.id, - Reason: reason, - ChainLength: uint32(state.ChainLength), - Standings: r.scoreRows(order, state, s.id, ranks), - }, - }}) - } - // A finished game is a return to the lobby, and the run loop reports the - // state they are returning to. - r.lobbyChanged = true -} - -// scoreRows renders the players table for one recipient. -// -// order is the sequence to report them in — turn order while a game runs, -// finishing order once one has ended — and ranks is empty until there is a -// result, which is what makes a rank of zero mean "still playing" rather than -// needing a field of its own to say so. -func (r *room) scoreRows(order []game.PlayerID, state game.State, me game.PlayerID, ranks map[game.PlayerID]int) []*noituv1.PlayerScore { - rows := make([]*noituv1.PlayerScore, 0, len(order)) - for _, id := range order { - row := &noituv1.PlayerScore{ - PlayerId: string(id), - IsMe: id == me, - Score: uint32(state.Scores[id]), - // A player the engine no longer knows is a seat that was vacated - // mid-game, which only happens to somebody already out. - Eliminated: !state.Alive[id], - // The bot has no socket to lose, so it is never the one keeping - // the room waiting. - Connected: id == botPlayerID, - Rank: uint32(ranks[id]), - } - if s := r.seatOf(id); s != nil { - row.Name = s.nickname - row.Connected = row.Connected || s.sess != nil - } - rows = append(rows, row) - } - return rows -} - -// handleDisconnect holds the seat open for the player who dropped out of it. -// -// A dropped connection is not a player leaving. The seat is kept for the -// reconnect window whether a game is running or the room is sitting in its -// lobby, so refreshing the page does not cost somebody the room they are in. -// -// The turn clock is deliberately not paused. A player who drops on their own -// turn loses it the way anybody else would; the window decides only whether -// they are still in the game afterwards. -func (r *room) handleDisconnect(m disconnectInput) { - s := r.seatOf(m.player) - // A stale notice from a connection the player already replaced. Acting on - // it would evict the seat the new socket is sitting in. - if s == nil || s.sess == nil || s.sess != m.sess { - return - } - s.sess = nil - s.graceUntil = time.Now().Add(r.graceFor) - // Presence is part of the room's state, and the run loop is what sends it. - // There is nothing extra to say to the players who are still here. - r.lobbyChanged = true -} - -// nextGraceExpiry is the earliest reconnect window still open. -func (r *room) nextGraceExpiry() (time.Time, bool) { - var next time.Time - for _, s := range r.seats { - if s == nil || s.sess != nil || s.graceUntil.IsZero() { - continue - } - if next.IsZero() || s.graceUntil.Before(next) { - next = s.graceUntil - } - } - return next, !next.IsZero() -} - -// handleGraceExpiry frees every seat whose reconnect window has run out. -// -// The engine goes first, while the seats are still here to be named: once one -// is vacated there is nobody left to attribute the elimination to, and the -// players who stayed would be told that somebody with no name went out. -func (r *room) handleGraceExpiry() { - now := time.Now() - - var expired []*seat - for _, s := range r.seats { - if s == nil || s.sess != nil || s.graceUntil.IsZero() || s.graceUntil.After(now) { - continue - } - expired = append(expired, s) - } - if len(expired) == 0 { - return - } - - before := r.mark() - for _, s := range expired { - r.eliminateAbsent(s, now) - } - r.applyEliminations(before) - - for _, s := range expired { - r.vacate(s) - } - r.lobbyChanged = true -} - -// eliminateAbsent takes a seat out of a live game once nobody is coming back -// to it. -// -// The engine is told this is a resignation, because that is the only shape it -// has for a player who stops playing. What the room reports is the transport -// fact instead: from everybody else's side this is somebody who left, not -// somebody who chose to give up. -func (r *room) eliminateAbsent(s *seat, now time.Time) { - if r.engine == nil || r.engine.Over() || !r.engine.Alive(s.id) { - return - } - r.outWire[s.id] = noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT - r.engine.Resign(s.id, now) -} - -// handleResume rebinds a seat to a new connection and replays the position. -// -// The replay is built from the engine, never from stored copies of past -// messages: a recorded stream can drift from the real state, and the resumed -// client would then be shown a board the server does not believe in. -func (r *room) handleResume(m resumeInput) { - s := r.seatOf(m.player) - if s == nil { - m.sess.send(errorMsg("session_not_resumable")) - return - } - - // Accepted. Only now is the old connection finished: its token is spent and - // its socket is either gone or about to be, and leaving it registered would - // let a third connection claim the same seat. - metrics.resumesSucceeded.Add(1) - m.sess.attach(r, string(m.player)) - if m.prior != nil { - m.sess.hub.unregister(m.prior.resumeToken) - m.prior.close() - } - s.sess = m.sess - s.graceUntil = time.Time{} - // The seat keeps the name it was given. Re-reading it from the new - // connection would let a reconnect rename a player mid-game, including - // into somebody else's name. - - // Everybody needs the room's state again: this player to render the lobby - // they came back to, the rest to stop watching a disconnect banner for - // somebody who is already back. The run loop sends it to all of them. - r.lobbyChanged = true - - // Before the lobby return below, not after it: a refresh in the lobby is - // the commonest resume there is, and it is exactly the one that would miss - // a replay hung off the end of this function. - r.sendChatHistory(s) - - // Resumed between games, or before the first one. The lobby state above is - // the whole answer; there is no position to replay. - if r.inLobby() { - return - } - state := r.engine.Snapshot() - r.sendGameStarted(s, state) - if len(state.History) > 0 { - last := state.History[len(state.History)-1] - r.sendTurnUpdate(s, state, &last, r.moveMeanings(&last)) - } -} - -// handleChat delivers one line of text to everybody in the room. -func (r *room) handleChat(m chatInput) { - // The seat, not the claimed id. A connection the room has already retired - // - kicked, or replaced by a reconnect - can still have a frame in flight, - // and by the time the room drains it that seat may belong to somebody else. - if !r.occupies(m.sess, m.player) { - m.sess.send(errorMsg("not_your_seat")) - return - } - // A bot room has no conversation. Checked here rather than in the session, - // because r.strategy is room-goroutine state. - if r.strategy != nil { - m.sess.send(errorMsg("not_in_a_room")) - return - } - - text := sanitizeText(m.text, maxChatRunes, maxChatMarks) - // Nothing usable survived. There is no message to refuse and nobody to - // tell: the client will not enable its send button for input that reduces - // to this, so anything reaching here typed nothing. - if text == "" { - return - } - - from := r.seatOf(m.player) - r.chatSeq++ - entry := chatEntry{ - seq: r.chatSeq, - author: from.id, - name: from.nickname, - text: text, - at: time.Now(), - } - r.chat = append(r.chat, entry) - if len(r.chat) > chatHistoryLimit { - r.chat = r.chat[len(r.chat)-chatHistoryLimit:] - } - metrics.chatLines.Add(1) - - for _, s := range r.seats { - if s == nil || s.sess == nil { - continue - } - // Best effort: a chat frame is dropped rather than allowed to close a - // session whose outbox is full. Losing a line is recoverable - the - // next replay carries it - and closing a session costs its owner the - // game. - s.sess.trySend(chatMessageFor(entry, s.id)) - } -} - -// sendChatHistory replays one seat's slice of the conversation. -// -// Scoped by the seat's chatFrom: a player is shown what was said while they -// were sitting there and nothing else. Sent from the handler, so it reaches the -// client before that input's RoomState - the client must not depend on the -// order, and does not, because a history replaces its panel wholesale. -func (r *room) sendChatHistory(s *seat) { - if s == nil || s.sess == nil || r.strategy != nil { - return - } - - messages := make([]*noituv1.ChatMessage, 0, len(r.chat)) - for _, entry := range r.chat { - if entry.seq <= s.chatFrom { - continue - } - messages = append(messages, chatMessageFor(entry, s.id).GetChatMessage()) - } - - // send, not trySend: this is the frame that corrects a client's whole - // panel, including the empty one that clears a conversation carried in - // from another room. A dropped line recovers on the next replay; a dropped - // replay has nothing behind it. - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatHistory{ - ChatHistory: &noituv1.ChatHistory{Messages: messages}, - }}) -} - -// chatMessageFor renders one entry from one seat's point of view. -// -// An entry whose author has been cleared belongs to nobody: it is from_me for -// neither player and carries no name, so the seat's next occupant is not shown -// a stranger's words as their own and the player who stayed cannot have them -// reattributed to whoever arrives next. -func chatMessageFor(entry chatEntry, id game.PlayerID) *noituv1.ServerMessage { - return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatMessage{ - ChatMessage: &noituv1.ChatMessage{ - FromMe: entry.author != "" && entry.author == id, - // Empty together with the name for a vacated seat: a line nobody - // owns must not be coloured as somebody's either. - PlayerId: string(entry.author), - Author: entry.name, - Text: entry.text, - SentUnixMs: entry.at.UnixMilli(), - }, - }} -} - // inLobby reports whether the room is between games. Everything a lobby // allows is refused while a game is running, and the engine is the authority // on that. @@ -1615,11 +470,6 @@ func (r *room) freeSeat() int { return -1 } -// seatIDs are the engine seat names, indexed by position. An id says which -// seat a player is in and nothing about their role: an owner who leaves hands -// that on, and the seat they vacate is refilled by an ordinary guest. -var seatIDs = [maxPlayers]game.PlayerID{"p1", "p2", "p3", "p4"} - // seatedCount is how many seats are held, including by players inside their // reconnect window. func (r *room) seatedCount() int { @@ -1643,156 +493,6 @@ func (r *room) allConnected() bool { return true } -// guestsReady reports whether every seat but the owner's has said yes. The -// owner's readiness is StartGame itself, which is why they are not counted. -func (r *room) guestsReady() bool { - for _, s := range r.seats { - if s != nil && s.id != r.owner && !s.ready { - return false - } - } - return true -} - -// takenNicknames is every name already in this room except one seat's own, so -// a joiner can be told apart from all of them. -func (r *room) takenNicknames(except game.PlayerID) []string { - names := make([]string, 0, maxPlayers) - for _, s := range r.seats { - if s != nil && s.id != except { - names = append(names, s.nickname) - } - } - return names -} - -// canStart reports whether StartGame would be accepted. The server answers -// this rather than the client because it owns every condition that feeds it. -func (r *room) canStart() bool { - if r.strategy != nil || !r.inLobby() { - return false - } - return r.seatedCount() >= minPlayers && r.allConnected() && r.guestsReady() -} - -// vacate frees a seat for good - the player left, was kicked, or never came -// back - and hands the room on when the seat was the owner's. -func (r *room) vacate(s *seat) { - if s == nil { - return - } - if s.sess != nil { - // The connection stays open; it is simply no longer in this room, so - // anything else it sends here is refused rather than applied to a seat - // somebody else may now be sitting in. - s.sess.release(r) - s.sess = nil - } - for i, existing := range r.seats { - if existing == s { - r.seats[i] = nil - } - } - // The words stay; the attribution goes. Both fields, not just the id: a - // retained name lets the next person to ask for that nickname inherit - // these messages, because distinguish only compares against the seat that - // is occupied. - scrubbed := false - for i := range r.chat { - if r.chat[i].author == s.id { - r.chat[i].author = "" - r.chat[i].name = "" - scrubbed = true - } - } - // Clearing the store is only half of it: the player who stayed is holding - // frames that still carry the departed name, and RoomState carries no - // chat. Without this re-sync they keep that attribution until they happen - // to reload — long enough for somebody to join under the same nickname and - // inherit a stranger's words. - if scrubbed { - // The loop above has already emptied this seat out of r.seats, so what - // is left is exactly the players who need correcting. - for _, other := range r.seats { - r.sendChatHistory(other) - } - } - if r.owner == s.id { - r.promote() - } -} - -// detachAll releases every connection still bound to this room as it exits. -func (r *room) detachAll() { - for _, s := range r.seats { - if s != nil && s.sess != nil { - s.sess.release(r) - } - } -} - -// promote hands the room to whoever is left. -func (r *room) promote() { - for _, s := range r.seats { - if s != nil { - r.owner = s.id - // The new owner starts games, and starting is their readiness. A - // flag they set as a guest would sit there meaning nothing. - s.ready = false - return - } - } - r.owner = "" -} - -// broadcastRoomState sends the whole room to each occupant. -// -// Built per recipient because the field that matters most in it — which of -// these players is you — is relative to who is being told. One snapshot rather -// than a stream of deltas is what lets a client that missed a frame, or has -// just reconnected, be correct again from the next one. -func (r *room) broadcastRoomState() { - canStart := r.canStart() - - for _, s := range r.seats { - if s == nil || s.sess == nil { - continue - } - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomState{ - RoomState: &noituv1.RoomState{ - RoomCode: r.code, - CanStart: canStart, - Players: r.playerSlots(s.id), - MaxPlayers: maxPlayers, - MinPlayers: minPlayers, - GraceMs: uint32(r.graceFor.Milliseconds()), - }, - }}) - } -} - -// playerSlots renders the seating for one recipient, in seat order. That is -// the order they will play in, but not who plays first: the lead is drawn when -// the game starts, and the table sent with it is the one in turn order. -func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot { - slots := make([]*noituv1.PlayerSlot, 0, maxPlayers) - for _, s := range r.seats { - if s == nil { - continue - } - slots = append(slots, &noituv1.PlayerSlot{ - PlayerId: string(s.id), - Name: s.nickname, - IsMe: s.id == me, - IsOwner: s.id == r.owner, - Ready: s.ready, - Connected: s.sess != nil, - Wins: s.wins, - }) - } - return slots -} - func (r *room) broadcastError(code string) { for _, s := range r.seats { if s != nil && s.sess != nil { @@ -1815,44 +515,3 @@ func (r *room) seatOf(p game.PlayerID) *seat { } return nil } - -// frozenBoard is an immutable position for a bot worker to search. -// -// It satisfies bot.Board without holding the engine. The dictionary is safe to -// share — the store loads once at Open and is read-only thereafter — but the -// used set is engine state, so it is copied. -type frozenBoard struct { - legal []string - used map[string]struct{} - dict game.Dictionary -} - -func freezeBoard(e *game.Engine, opening string) *frozenBoard { - state := e.Snapshot() - - // History omits the opening word, but the engine counts it as played. A - // board that disagreed would let the bot pick a word the engine then - // rejects as already used. - used := make(map[string]struct{}, len(state.History)+1) - used[opening] = struct{}{} - for _, m := range state.History { - used[m.Word] = struct{}{} - } - - return &frozenBoard{legal: e.LegalMoves(), used: used, dict: e.Dict()} -} - -func (b *frozenBoard) LegalMoves() []string { return b.legal } - -func (b *frozenBoard) Used(word string) bool { - _, ok := b.used[word] - return ok -} - -func (b *frozenBoard) WordsStartingWith(syllable string) iter.Seq[string] { - return b.dict.WordsStartingWith(syllable) -} - -func (b *frozenBoard) LastSyllable(word string) (string, bool) { - return b.dict.LastSyllable(word) -} diff --git a/server/internal/wsapi/room_chat.go b/server/internal/wsapi/room_chat.go new file mode 100644 index 0000000..c698c06 --- /dev/null +++ b/server/internal/wsapi/room_chat.go @@ -0,0 +1,125 @@ +package wsapi + +import ( + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// The room's own conversation, independent of whatever game is or is not +// running in it. + +// chatEntry is one line of the room's conversation. +type chatEntry struct { + // seq is this message's place in the room's whole conversation, compared + // against a seat's chatFrom to decide what that player may be replayed. + seq uint64 + // author and name are cleared together when the seat is vacated: the words + // stay, the attribution does not. Keeping the name would let the next + // person to request that nickname inherit a stranger's messages, since + // distinguish only compares against the seat that is currently occupied. + author game.PlayerID + name string + text string + at time.Time +} + +// handleChat delivers one line of text to everybody in the room. +func (r *room) handleChat(m chatInput) { + // The seat, not the claimed id. A connection the room has already retired + // - kicked, or replaced by a reconnect - can still have a frame in flight, + // and by the time the room drains it that seat may belong to somebody else. + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + return + } + // A bot room has no conversation. Checked here rather than in the session, + // because r.strategy is room-goroutine state. + if r.strategy != nil { + m.sess.send(errorMsg("not_in_a_room")) + return + } + + text := sanitizeText(m.text, maxChatRunes, maxChatMarks) + // Nothing usable survived. There is no message to refuse and nobody to + // tell: the client will not enable its send button for input that reduces + // to this, so anything reaching here typed nothing. + if text == "" { + return + } + + from := r.seatOf(m.player) + r.chatSeq++ + entry := chatEntry{ + seq: r.chatSeq, + author: from.id, + name: from.nickname, + text: text, + at: time.Now(), + } + r.chat = append(r.chat, entry) + if len(r.chat) > chatHistoryLimit { + r.chat = r.chat[len(r.chat)-chatHistoryLimit:] + } + metrics.chatLines.Add(1) + + for _, s := range r.seats { + if s == nil || s.sess == nil { + continue + } + // Best effort: a chat frame is dropped rather than allowed to close a + // session whose outbox is full. Losing a line is recoverable - the + // next replay carries it - and closing a session costs its owner the + // game. + s.sess.trySend(chatMessageFor(entry, s.id)) + } +} + +// sendChatHistory replays one seat's slice of the conversation. +// +// Scoped by the seat's chatFrom: a player is shown what was said while they +// were sitting there and nothing else. Sent from the handler, so it reaches the +// client before that input's RoomState - the client must not depend on the +// order, and does not, because a history replaces its panel wholesale. +func (r *room) sendChatHistory(s *seat) { + if s == nil || s.sess == nil || r.strategy != nil { + return + } + + messages := make([]*noituv1.ChatMessage, 0, len(r.chat)) + for _, entry := range r.chat { + if entry.seq <= s.chatFrom { + continue + } + messages = append(messages, chatMessageFor(entry, s.id).GetChatMessage()) + } + + // send, not trySend: this is the frame that corrects a client's whole + // panel, including the empty one that clears a conversation carried in + // from another room. A dropped line recovers on the next replay; a dropped + // replay has nothing behind it. + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatHistory{ + ChatHistory: &noituv1.ChatHistory{Messages: messages}, + }}) +} + +// chatMessageFor renders one entry from one seat's point of view. +// +// An entry whose author has been cleared belongs to nobody: it is from_me for +// neither player and carries no name, so the seat's next occupant is not shown +// a stranger's words as their own and the player who stayed cannot have them +// reattributed to whoever arrives next. +func chatMessageFor(entry chatEntry, id game.PlayerID) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatMessage{ + ChatMessage: &noituv1.ChatMessage{ + FromMe: entry.author != "" && entry.author == id, + // Empty together with the name for a vacated seat: a line nobody + // owns must not be coloured as somebody's either. + PlayerId: string(entry.author), + Author: entry.name, + Text: entry.text, + SentUnixMs: entry.at.UnixMilli(), + }, + }} +} diff --git a/server/internal/wsapi/room_game.go b/server/internal/wsapi/room_game.go new file mode 100644 index 0000000..8fa2732 --- /dev/null +++ b/server/internal/wsapi/room_game.go @@ -0,0 +1,622 @@ +package wsapi + +import ( + "log/slog" + "math/rand/v2" + "slices" + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/bot" + "github.com/tiennm99dev/noitu/server/internal/dictionary" + "github.com/tiennm99dev/noitu/server/internal/game" + "github.com/tiennm99dev/noitu/server/internal/vietnamese" +) + +// Everything that touches a running game: starting one, applying a move, +// scoring it, and reporting what an elimination or a game-over means to +// each seat. + +// handleResign is one player giving up on their own turn. The seat, not the +// claimed id, is the authority, as everywhere a connection acts on a room. +// +// Only the player to act may give up. Giving up is a move — it is what is +// played instead of a word — and a seat that could spend it while somebody +// else was thinking would be deciding the turn of a player who had not +// finished theirs. Somebody who wants out of a game they are not on turn in +// leaves the room instead, which handleLobby answers. +func (r *room) handleResign(m resignInput) { + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + return + } + if r.engine == nil || r.engine.Over() { + return + } + if r.engine.Turn() != m.player { + m.sess.send(errorMsg("not_your_turn")) + return + } + before := r.mark() + if r.engine.Resign(m.player, time.Now()) { + r.applyEliminations(before) + } +} + +// handleClaimDeadEnd is the player to act saying the syllable in play has no +// answer left, checked rather than trusted. +// +// A true claim takes them out at once with EndNoLegalMove — exactly what the +// clock would eventually rule, so the game's own outcome is unchanged and +// only the wait is gone. A false claim changes nothing at all: the clock +// keeps running and the claimant is simply told a word exists, which is hint +// enough to be the whole cost of asking wrongly. +func (r *room) handleClaimDeadEnd(m claimDeadEndInput) { + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + return + } + if r.engine == nil { + m.sess.send(errorMsg("game_not_started")) + return + } + if r.engine.Over() { + return + } + if r.engine.Turn() != m.player { + m.sess.send(errorMsg("not_your_turn")) + return + } + if r.engine.HasLegalMove() { + metrics.deadEndClaims.Add("false", 1) + m.sess.send(errorMsg("not_a_dead_end")) + return + } + + metrics.deadEndClaims.Add("true", 1) + before := r.mark() + if r.engine.NoMove(time.Now()) { + r.applyEliminations(before) + } +} + +// handleStartBot seats a bot opposite the player and begins immediately. +func (r *room) handleStartBot(m startBotInput) { + strategy, err := bot.New(m.difficulty, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))) + if err != nil { + m.sess.send(errorMsg("room_start_failed")) + r.cancel() + return + } + + r.strategy = strategy + s := &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq} + r.seats[0] = s + r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"} + r.owner = "p1" + m.sess.attach(r, "p1") + r.hub.cancelQuickMatch(m.sess) + + if s.sess.ctx.Err() != nil { + // A bot room has no lobby to fall back to and no idle timer covering it + // while there is no engine yet (resetIdleTimer skips any room with a + // strategy) — a grace window here would leave the bot's own seat + // holding the room open forever with nothing left to vacate it. The + // room ends now instead, the same way a failed bot.New or beginGame + // above already does. + r.cancel() + return + } + + if err := r.beginGame(); err != nil { + slog.Error("could not start bot game", "room", r.code, "err", err) + m.sess.send(errorMsg("game_start_failed")) + r.cancel() + } +} + +// beginGame builds the engine and tells both seats the game is on. +func (r *room) beginGame() error { + opening, err := r.dict.RandomOpeningWord(minOpeningOutDegree) + if err != nil { + return err + } + + // Seat order is turn order, so a player's place at the table is the place + // they took in the lobby and nothing has to be shuffled or announced. + ids := make([]game.PlayerID, 0, maxPlayers) + for _, s := range r.seats { + if s != nil { + ids = append(ids, s.id) + } + } + // Who leads is drawn rather than owned. Opening the game is an advantage — + // the first player picks from a whole syllable, everyone after them plays + // what is left of it — and giving it to whoever happened to create the + // room would make the same person favourite in every game of a series. + // + // Rotating rather than shuffling keeps the table intact: everybody still + // plays in the order they sat down, the cycle just starts somewhere else. + // A bot room is left alone; it has no table to be fair about, and the + // human opens. + if r.strategy == nil { + lead := rand.IntN(len(ids)) + ids = slices.Concat(ids[lead:], ids[:lead]) + } + + engine, err := game.New(r.dict, ids, opening, r.turnLimit, time.Now()) + if err != nil { + return err + } + r.engine = engine + r.opening = opening + // Fresh per game: an override from the last one would describe a player + // who has since come back and is playing this one. + r.outWire = make(map[game.PlayerID]noituv1.GameEndReason, len(ids)) + // Never restarts at 1. A rematch reuses the same connections, so a + // submission still in flight from the previous game would otherwise be + // able to match a turn in this one and be applied to it. + r.turnSeq++ + // Every game is agreed on its own. The readiness that started this one is + // spent, so the lobby they come back to asks again. + for _, s := range r.seats { + if s != nil { + s.ready = false + } + } + + metrics.gamesStarted.Add(r.mode, 1) + r.hub.gameStarted() + r.liveCounted.Store(true) + + state := r.engine.Snapshot() + for _, s := range r.seats { + r.sendGameStarted(s, state) + } + r.maybeScheduleBot() + return nil +} + +// sendGameStarted renders the opening position for one seat. my_turn and is_me +// are per-recipient, which is why this is built per seat rather than broadcast. +func (r *room) sendGameStarted(s *seat, state game.State) { + if s == nil || s.sess == nil { + return + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameStarted{ + GameStarted: &noituv1.GameStarted{ + OpeningWord: r.opening, + OpeningMeanings: Senses(r.dict.Meanings(r.opening)), + CurrentSyllable: state.Current, + MyTurn: state.Turn == s.id, + DeadlineUnixMs: state.Deadline.UnixMilli(), + TurnSeq: r.turnSeq, + TurnLimitMs: uint32(r.turnLimit.Milliseconds()), + Players: r.scoreRows(r.engine.Players(), state, s.id, nil), + TurnPlayerId: string(state.Turn), + }, + }}) +} + +// handleSubmit runs one human move through the engine. +func (r *room) handleSubmit(m submitInput) { + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + return + } + if r.engine == nil { + r.sendTo(m.player, errorMsg("game_not_started")) + return + } + + // A submission stamped with an old turn is answering a position that no + // longer exists — a double-submit, or a word typed as the clock ran out. + // Applying it to the current turn would play a word the player never + // chose for this position. + // The rejection carries the server's sequence, not the client's stale one, + // so the client can resynchronise from the refusal instead of having to + // wait for the next turn update to discover where the game actually is. + metrics.wordsSubmitted.Add(1) + + if m.turnSeq != r.turnSeq { + r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq, "")) + r.recordRejection(game.ReasonNotYourTurn, m.word) + return + } + + // The typed text is echoed back to every seat as PlayedWord.typed, so it + // crosses the same trust boundary a chat line does and gets the same + // filter. The engine's own normalization only lowercases and collapses + // whitespace; it does not drop format characters. + word := sanitizeText(m.word, maxWordRunes, maxNicknameMarks) + + before := r.mark() + move, reason := r.engine.Submit(m.player, word, time.Now()) + if reason != game.ReasonNone { + r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq, r.nearMissFor(reason, word))) + r.recordRejection(reason, word) + // A rejection for an expired turn also took this player out of the + // game, and everybody has to be told which. + r.applyEliminations(before) + return + } + metrics.wordsAccepted.Add(1) + + // An accepted move never ends a game: a dead end is left for whoever + // inherits it, which is what Submit's own comment explains. + r.turnSeq++ + r.broadcastTurn(&move) + r.maybeScheduleBot() +} + +// nearMissFor finds a diacritic-typo suggestion for a word the dictionary +// refused. Only for REJECT_REASON_NOT_IN_DICTIONARY: every other rejection +// means the word IS in the dictionary and was refused for some other reason, +// where a spelling suggestion would be misleading rather than helpful. +func (r *room) nearMissFor(reason game.RejectReason, raw string) string { + if reason != game.ReasonNotInDictionary { + return "" + } + normalized, _, err := vietnamese.Normalize(raw) + if err != nil { + return "" + } + suggestion, ok := r.dict.NearMiss(normalized) + if !ok { + return "" + } + // The dictionary check comes before the link and reuse checks in Submit, + // so a real word can be a near miss and still be unplayable here. Offering + // it would send the player straight into a second refusal. + if first, ok := r.dict.FirstSyllable(suggestion); !ok || first != r.engine.Current() || r.engine.Used(suggestion) { + return "" + } + return suggestion +} + +// recordRejection counts one rejected submission and logs it at Info. +// +// This is the corpus feedback loop the improvement report calls the input to +// every decision about the dictionary: which words players actually type that +// the game does not accept, and why. The word logged is never the raw typed +// text — it is normalized the same way the engine would have matched it +// (NFC, lowercase, single-spaced) and capped, so the line is useful for corpus +// review without ever logging what a player literally typed into the box. +func (r *room) recordRejection(reason game.RejectReason, raw string) { + metrics.wordsRejected.Add(reason.String(), 1) + + // sanitizeText first: raw may be the untouched client payload (the + // not-your-turn path never reaches the sanitizer below it in + // handleSubmit), and Normalize alone does not drop control or format + // characters. + word, _, err := vietnamese.Normalize(sanitizeText(raw, maxWordRunes, maxNicknameMarks)) + if err != nil { + word = "" + } + if runes := []rune(word); len(runes) > maxWordRunes { + word = string(runes[:maxWordRunes]) + } + + slog.Info("word_rejected", + "reason", reason.String(), + "word", word, + "link", r.engine.Current(), + "mode", r.mode, + "room", r.code, + ) +} + +// handleReportWord logs one report with this room's context. +// +// The session has already checked the word is long enough and within its own +// per-session cap before routing it here — this is only about what to log, +// and the syllable in play, this room's mode and its code are all room +// goroutine state that only the room may read. Never the reporting player's +// seat or name: recordRejection keeps the same information out of the corpus +// feedback loop for the same reason. +func (r *room) handleReportWord(m reportWordInput) { + link := "" + if r.engine != nil { + link = r.engine.Current() + } + metrics.wordsReported.Add(1) + slog.Info("word_reported", "word", m.word, "link", link, "mode", r.mode, "room", r.code) + m.sess.send(wordReportedMsg(m.word)) +} + +// handleBotMove applies what the worker chose. +func (r *room) handleBotMove(m botMoveInput) { + if r.engine == nil || r.engine.Over() { + return + } + // The position moved on while it was thinking; the chosen word answers a + // board that no longer exists. + if m.turnSeq != r.turnSeq || r.engine.Turn() != botPlayerID { + return + } + metrics.botMoves.Add(r.strategy.Difficulty().String(), 1) + + now := time.Now() + before := r.mark() + + if m.err != nil { + // The bot has nothing to play. A human in this position keeps their + // turn and loses it to the clock; the bot has no clock to spend, so + // the position is settled now and reported for what it is rather than + // as a resignation it never chose. + if !r.engine.NoMove(now) { + r.engine.Resign(botPlayerID, now) + } + r.applyEliminations(before) + return + } + + move, reason := r.engine.Submit(botPlayerID, m.word, now) + if reason != game.ReasonNone { + // The bot searched the same dictionary the engine validates against, + // so this means the two disagree — a bug worth seeing, not a move to + // retry. + slog.Error("bot move rejected by engine", "room", r.code, "word", m.word, "reason", reason.String()) + r.engine.Resign(botPlayerID, now) + r.applyEliminations(before) + return + } + + r.turnSeq++ + r.broadcastTurn(&move) +} + +// maybeScheduleBot starts the bot thinking if it is now its turn. +func (r *room) maybeScheduleBot() { + if r.strategy == nil || r.engine.Over() || r.engine.Turn() != botPlayerID { + return + } + + // The board is frozen here, on the room goroutine, before the worker + // exists. Handing the worker the live engine instead would race every + // resign and disconnect the room processes while the bot thinks — and + // bot.Board.Used reads engine state, so the race would be real, not + // theoretical. + board := freezeBoard(r.engine) + seq := r.turnSeq + strategy := r.strategy + + go func() { + word, err := strategy.Choose(board) + + // The pause is a courtesy to the player, so it must not outlive the + // room: a bot still sleeping after everyone left is a goroutine leak + // per abandoned game. + select { + case <-time.After(strategy.ThinkingDelay()): + case <-r.ctx.Done(): + return + } + r.send(botMoveInput{word: word, err: err, turnSeq: seq}) + }() +} + +// broadcastTurn sends the position to every seat, rendered for each. +// +// move is nil when the turn moved without a word being played, which is what +// an elimination does: the syllable and the used set survive the player who +// could not answer them, and everybody still needs the new deadline and the +// new player to act. +func (r *room) broadcastTurn(move *game.Move) { + state := r.engine.Snapshot() + meanings := r.moveMeanings(move) + for _, s := range r.seats { + r.sendTurnUpdate(s, state, move, meanings) + } +} + +// moveMeanings looks up a played word's senses once per move; they are the +// same for every recipient. nil for no move. +func (r *room) moveMeanings(move *game.Move) []dictionary.Sense { + if move == nil { + return nil + } + return r.dict.Meanings(move.Word) +} + +// sendTurnUpdate renders one position for one seat. by_me, my_turn and is_me +// are all per-recipient, which is why there is no single shared frame; the +// move's meanings are not, and arrive looked up. +func (r *room) sendTurnUpdate(s *seat, state game.State, move *game.Move, meanings []dictionary.Sense) { + if s == nil || s.sess == nil { + return + } + update := &noituv1.TurnUpdate{ + CurrentSyllable: state.Current, + MyTurn: state.Turn == s.id, + DeadlineUnixMs: state.Deadline.UnixMilli(), + TurnSeq: r.turnSeq, + ChainLength: uint32(state.ChainLength), + Players: r.scoreRows(r.engine.Players(), state, s.id, nil), + TurnPlayerId: string(state.Turn), + } + if move != nil { + update.Played = PlayedWord(*move, move.Player == s.id, meanings) + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: update}}) +} + +// inputMark is what the game looked like before an input: how many players +// were out, and who was to act. Remembered across the input so +// applyEliminations can tell that input's doing from what was already true, +// and whether it moved the turn. +type inputMark struct { + out int + turn game.PlayerID +} + +// mark reads the current game, or the zero mark when there is no game. +func (r *room) mark() inputMark { + if r.engine == nil { + return inputMark{} + } + return inputMark{out: r.engine.EliminatedCount(), turn: r.engine.Turn()} +} + +// applyEliminations reports everybody the last input knocked out, then whatever +// the game became: finished, or one turn further on. +// +// Every path that takes a player out of a game ends here — a timeout, a +// resignation, a bot with nothing to play, a reconnect window running out — so +// there is one place that decides what the room says about it. +func (r *room) applyEliminations(before inputMark) { + if r.engine == nil { + return + } + state := r.engine.Snapshot() + if len(state.Eliminated) == before.out { + return + } + + // An elimination does not move the position, so one lookup describes it + // for everybody who went out on this input. + suggestions := r.engine.Suggestions(maxSuggestions) + for _, id := range state.Eliminated[before.out:] { + r.broadcastElimination(id, suggestions) + } + + if r.engine.Over() { + r.broadcastGameOver(state) + return + } + // A new turn nobody played into, and the sequence moves with it: a + // submission already in flight was answering the position the player who + // just went out was looking at. + // + // It moves only when the turn does. Somebody forfeiting out of turn — a + // player who left the room, or whose reconnect window ran out — leaves the + // syllable, the deadline and the player to act exactly as they were, so + // the word that player is already sending still answers the board it was + // typed for. Bumping the sequence there would refuse it for something + // somebody else did. + if state.Turn != before.turn { + r.turnSeq++ + } + r.broadcastTurn(nil) +} + +// broadcastElimination tells the room one player is out. +// +// The suggestions go only to that player. They are what the position still had +// to offer, and the people who could still answer it are not the ones who +// needed to be told — an empty list is the answer for whoever was stuck, and +// noise for everybody else. +func (r *room) broadcastElimination(id game.PlayerID, suggestions []string) { + name := "" + if out := r.seatOf(id); out != nil { + name = out.nickname + } + reason := r.wireEndReason(id) + metrics.eliminations.Add(reason.String(), 1) + + for _, s := range r.seats { + if s == nil || s.sess == nil { + continue + } + msg := &noituv1.PlayerEliminated{ + PlayerId: string(id), + Name: name, + IsMe: s.id == id, + Reason: reason, + } + if s.id == id { + msg.Suggestions = suggestions + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_PlayerEliminated{ + PlayerEliminated: msg, + }}) + } +} + +// wireEndReason says how one player left the game. +// +// The engine's answer, unless the room overrode it: a reconnect window running +// out is a resignation to the engine, because that is the only shape it has +// for a player who stops playing, and somebody who left to everybody in the +// room. +func (r *room) wireEndReason(p game.PlayerID) noituv1.GameEndReason { + if code, overridden := r.outWire[p]; overridden { + return code + } + return EndReason(r.engine.OutReason(p)) +} + +// broadcastGameOver reports the result from each seat's point of view. +func (r *room) broadcastGameOver(state game.State) { + metrics.gamesFinished.Add(r.mode, 1) + if r.liveCounted.CompareAndSwap(true, false) { + r.hub.gameFinished() + } + + // The reason the game ended is the reason the last player went out, which + // with two seats is the only elimination there was. + reason := noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED + if n := len(state.Eliminated); n > 0 { + reason = r.wireEndReason(state.Eliminated[n-1]) + } + + // Credited before anything is sent, so the RoomState the run loop + // broadcasts after a finished game already carries the game just won. + if s := r.seatOf(state.Winner); s != nil { + s.wins++ + } + + ranks := make(map[game.PlayerID]int, len(state.Standings)) + order := make([]game.PlayerID, 0, len(state.Standings)) + for _, standing := range state.Standings { + ranks[standing.Player] = standing.Rank + order = append(order, standing.Player) + } + + for _, s := range r.seats { + if s == nil || s.sess == nil { + continue + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ + GameOver: &noituv1.GameOver{ + IWon: state.Winner == s.id, + Reason: reason, + ChainLength: uint32(state.ChainLength), + Standings: r.scoreRows(order, state, s.id, ranks), + }, + }}) + } + // A finished game is a return to the lobby, and the run loop reports the + // state they are returning to. + r.lobbyChanged = true +} + +// scoreRows renders the players table for one recipient. +// +// order is the sequence to report them in — turn order while a game runs, +// finishing order once one has ended — and ranks is empty until there is a +// result, which is what makes a rank of zero mean "still playing" rather than +// needing a field of its own to say so. +func (r *room) scoreRows(order []game.PlayerID, state game.State, me game.PlayerID, ranks map[game.PlayerID]int) []*noituv1.PlayerScore { + rows := make([]*noituv1.PlayerScore, 0, len(order)) + for _, id := range order { + row := &noituv1.PlayerScore{ + PlayerId: string(id), + IsMe: id == me, + Score: uint32(state.Scores[id]), + // A player the engine no longer knows is a seat that was vacated + // mid-game, which only happens to somebody already out. + Eliminated: !state.Alive[id], + // The bot has no socket to lose, so it is never the one keeping + // the room waiting. + Connected: id == botPlayerID, + Rank: uint32(ranks[id]), + } + if s := r.seatOf(id); s != nil { + row.Name = s.nickname + row.Connected = row.Connected || s.sess != nil + } + rows = append(rows, row) + } + return rows +} diff --git a/server/internal/wsapi/room_inputs.go b/server/internal/wsapi/room_inputs.go new file mode 100644 index 0000000..b0c7b5c --- /dev/null +++ b/server/internal/wsapi/room_inputs.go @@ -0,0 +1,125 @@ +package wsapi + +import ( + "github.com/tiennm99dev/noitu/server/internal/bot" + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// Room input messages. Everything that can change a room or a game arrives +// as one of these on room.inputs, which is what makes the engine safe +// without a lock: the room goroutine is its only reader. + +// createInput and startBotInput seat the first player. Seating is a message +// rather than a direct write so that every touch of room state — seats and +// engine alike — happens on the room goroutine, which makes the ownership +// invariant provable by reading run() rather than by reasoning about which +// writes happened before `go r.run()`. +type createInput struct { + sess *session + // autoStart marks a room a quick match opened rather than a player asking + // for a code: once both seats are filled and connected, the room begins + // its own first game instead of waiting on readiness and StartGame. + autoStart bool +} + +type startBotInput struct { + sess *session + difficulty bot.Difficulty +} + +type joinInput struct { + sess *session +} + +// submitInput and resignInput carry the connection that sent them, not just +// the seat it claims. A room code is a shared secret — it is pasted into group +// chats by design — so holding one must not be enough to act as a player who +// is already seated. +type submitInput struct { + sess *session + player game.PlayerID + word string + turnSeq uint32 +} + +// lobbyAction is one thing a player does to the room rather than to a game. +type lobbyAction uint8 + +const ( + lobbyReady lobbyAction = iota + lobbyStart + lobbyKick + lobbyLeave +) + +// lobbyInput is one lobby action. They share a type because they share every +// authorization step — the seat, the room's mode, and whether a game is +// running — and splitting them would mean four copies of those checks. +type lobbyInput struct { + sess *session + player game.PlayerID + action lobbyAction + // ready is the value a lobbyReady is setting. Explicit rather than a + // toggle: a toggle applied to a state the client is a frame behind on sets + // the opposite of what the player clicked. + ready bool + // target is the seat a lobbyKick names. A room holds up to four people, so + // "the other one" stopped being an answer. + target game.PlayerID +} + +// chatInput is one line of text from a seated player. It carries the +// connection, not just the seat it claims, for the same reason submitInput +// does: a room code is a shared secret, and a connection the room has retired +// must not be able to speak as the seat it used to hold. +type chatInput struct { + sess *session + player game.PlayerID + text string +} + +type resignInput struct { + sess *session + player game.PlayerID +} + +// claimDeadEndInput is the player to act saying the syllable has no answer +// left. Carries the connection, not just the claimed seat, for the same +// reason resignInput does. +type claimDeadEndInput struct { + sess *session + player game.PlayerID +} + +// reportWordInput is a word the session has already validated as reportable — +// long enough, and within its own per-session cap — waiting only on the room +// for the context a report is logged with: the syllable in play, if any. +type reportWordInput struct { + sess *session + word string +} + +type disconnectInput struct { + player game.PlayerID + // sess identifies which connection dropped. A player who already + // reconnected has a different session, and that stale notice must not + // evict the seat the new connection just took. + sess *session +} + +type resumeInput struct { + player game.PlayerID + sess *session + // prior is the connection being replaced. The room retires it only once it + // has decided the resume is allowed, because closing it on a refusal would + // end the very game the client was trying to rejoin. + prior *session +} + +type botMoveInput struct { + word string + err error + // turnSeq the bot was thinking about. If the game moved on — a resign + // landed while it thought — the move is stale and dropped. + turnSeq uint32 +} diff --git a/server/internal/wsapi/room_lobby.go b/server/internal/wsapi/room_lobby.go new file mode 100644 index 0000000..ae6b988 --- /dev/null +++ b/server/internal/wsapi/room_lobby.go @@ -0,0 +1,375 @@ +package wsapi + +import ( + "log/slog" + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// Seating and the lobby between games: who is in the room, whether they may +// start, and what happens when one of them leaves or is kicked. + +// handleCreate seats the room's creator, who owns it, and opens the lobby. +// +// The code goes out in the RoomState the run loop broadcasts, so a client can +// never be handed a code before the seat behind it exists. +func (r *room) handleCreate(m createInput) { + s := &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq} + r.seats[0] = s + r.owner = "p1" + r.autoStart = m.autoStart + m.sess.attach(r, "p1") + r.lobbyChanged = true + // A quick match already popped this session off the pairing queue before + // sending it here, but a plain CreateRoom might still be seating somebody + // who was also waiting in it from another attempt — one dequeue serves + // both room-entry paths. + r.hub.cancelQuickMatch(m.sess) + + if s.sess.ctx.Err() != nil { + r.disconnectGhostSeat(s) + return + } + // Deliberately sent to a brand-new room's creator, where it is always + // empty: it is what replaces the conversation a client may still be + // holding from a room it was in before this one. + r.sendChatHistory(s) +} + +// handleJoin seats another human in the lobby. It does not start anything: the +// owner does that, once everybody has said they are ready. +// +// The seat is bound here, on the room goroutine, and only on success. Binding +// it in the hub before this decision would leave a refused joiner still +// holding a seat, and every later Submit or Resign it sent would be applied to +// the real player sitting there. +func (r *room) handleJoin(m joinInput) { + free := r.freeSeat() + if free < 0 || !r.occupied() { + metrics.joinsRefused.Add("room_full", 1) + m.sess.send(errorMsg("room_full")) + return + } + // A room can have a free seat and still be mid-game — four people can + // start a game three of them are in. Arriving in the middle of one is not + // something to seat somebody for: they would have no words, no score, and + // no way to be told what they had missed. + if !r.inLobby() { + m.sess.send(errorMsg("game_in_progress")) + return + } + for _, s := range r.seats { + if s != nil && s.sess == m.sess { + m.sess.send(errorMsg("cannot_join_own_room")) + return + } + } + + id := seatIDs[free] + s := &seat{ + id: id, + nickname: distinguish(m.sess.nickname(), r.takenNicknames(id)), + sess: m.sess, + // Seated now, so the conversation up to this point is not theirs to + // read. A room code is pasted into group chats by design. + chatFrom: r.chatSeq, + } + r.seats[free] = s + m.sess.attach(r, string(id)) + r.lobbyChanged = true + r.hub.cancelQuickMatch(m.sess) + + if s.sess.ctx.Err() != nil { + r.disconnectGhostSeat(s) + return + } + r.sendChatHistory(s) + + // A quick match seats both players itself rather than waiting on + // readiness and StartGame — there is no owner here to press it, only two + // strangers who both already asked to be matched. The lobby is shown + // first, with both seats filled, so the wait ends on an ordinary room a + // beat before GameStarted rather than jumping straight into one with no + // seating frame behind it. + if r.autoStart && r.seatedCount() >= minPlayers && r.allConnected() { + if r.hub.isDraining() { + // The second seat filled after the drain decision. There is no + // owner here to answer with server_restarting the way lobbyStart + // does, so both seats are told directly; the lobby view they are + // left in still shows each other, via lobbyChanged below. + r.broadcastError("server_restarting") + return + } + r.autoStart = false + r.lobbyChanged = false + r.broadcastRoomState() + if err := r.beginGame(); err != nil { + slog.Error("could not start quick-matched game", "room", r.code, "err", err) + r.broadcastError("game_start_failed") + } + } +} + +// handleLobby applies one lobby action. +// +// Every refusal answers with a reason. A lobby button that silently does +// nothing is indistinguishable from one that is broken, and the player cannot +// see the state that refused them. +func (r *room) handleLobby(m lobbyInput) { + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + return + } + if r.strategy != nil { + // A bot room has no lobby: one player, no readiness, nobody to kick. + m.sess.send(errorMsg("not_in_a_room")) + return + } + // Leaving is the exception: a player may want out of a game it is not + // their turn in, and resigning is not open to them then. Readying, + // starting and kicking all belong to a room between games. + if !r.inLobby() && m.action != lobbyLeave { + m.sess.send(errorMsg("game_in_progress")) + return + } + + mine := r.seatOf(m.player) + isOwner := m.player == r.owner + + switch m.action { + case lobbyReady: + if isOwner { + // The owner's readiness is StartGame. A flag of their own would + // only be something they had to set before every single start. + m.sess.send(errorMsg("owner_needs_no_ready")) + return + } + mine.ready = m.ready + r.lobbyChanged = true + + case lobbyStart: + if !isOwner { + m.sess.send(errorMsg("not_the_owner")) + return + } + switch { + case r.hub.isDraining(): + // newRegisteredRoom already refuses a brand-new room once draining + // starts; this lobby existed before that point, and starting its + // game now would raise hub.liveGames after the drain decided how + // long to wait for exactly that number to reach zero. + m.sess.send(errorMsg("server_restarting")) + return + case r.seatedCount() < minPlayers: + m.sess.send(errorMsg("need_more_players")) + return + case !r.allConnected(): + m.sess.send(errorMsg("player_offline")) + return + case !r.guestsReady(): + m.sess.send(errorMsg("not_everyone_ready")) + return + } + if err := r.beginGame(); err != nil { + slog.Error("could not start pvp game", "room", r.code, "err", err) + r.broadcastError("game_start_failed") + } + + case lobbyKick: + if !isOwner { + m.sess.send(errorMsg("not_the_owner")) + return + } + target := r.seatOf(m.target) + switch { + case target == nil: + m.sess.send(errorMsg("no_one_to_kick")) + return + case target == mine: + // Leaving is what an owner who wants out does, and it hands the + // room on. Kicking yourself would drop the seat and the role + // together while the others were still sitting here. + m.sess.send(errorMsg("cannot_kick_self")) + return + case target.ready: + // Readiness is a commitment, and the owner does not get to + // overrule one: a player who is ready is waiting on the owner, + // not in the way. + m.sess.send(errorMsg("player_is_ready")) + return + } + if target.sess != nil { + target.sess.send(errorMsg("kicked")) + } + r.vacate(target) + r.lobbyChanged = true + + case lobbyLeave: + if r.inLobby() { + // Unreadying first is deliberate friction: a player the other one + // is waiting on should have to take that back before walking away. + if mine.ready { + m.sess.send(errorMsg("must_unready_first")) + return + } + } else { + // Out of a running game, which is the same thing to everybody else + // as a reconnect window running out: somebody left. The engine + // goes first, while the seat is still here to be named in what is + // broadcast about it. + before := r.mark() + r.eliminateAbsent(mine, time.Now()) + r.applyEliminations(before) + } + r.vacate(mine) + r.lobbyChanged = true + } +} + +// guestsReady reports whether every seat but the owner's has said yes. The +// owner's readiness is StartGame itself, which is why they are not counted. +func (r *room) guestsReady() bool { + for _, s := range r.seats { + if s != nil && s.id != r.owner && !s.ready { + return false + } + } + return true +} + +// takenNicknames is every name already in this room except one seat's own, so +// a joiner can be told apart from all of them. +func (r *room) takenNicknames(except game.PlayerID) []string { + names := make([]string, 0, maxPlayers) + for _, s := range r.seats { + if s != nil && s.id != except { + names = append(names, s.nickname) + } + } + return names +} + +// canStart reports whether StartGame would be accepted. The server answers +// this rather than the client because it owns every condition that feeds it. +func (r *room) canStart() bool { + if r.strategy != nil || !r.inLobby() { + return false + } + return r.seatedCount() >= minPlayers && r.allConnected() && r.guestsReady() +} + +// vacate frees a seat for good - the player left, was kicked, or never came +// back - and hands the room on when the seat was the owner's. +func (r *room) vacate(s *seat) { + if s == nil { + return + } + if s.sess != nil { + // The connection stays open; it is simply no longer in this room, so + // anything else it sends here is refused rather than applied to a seat + // somebody else may now be sitting in. + s.sess.release(r) + s.sess = nil + } + for i, existing := range r.seats { + if existing == s { + r.seats[i] = nil + } + } + // The words stay; the attribution goes. Both fields, not just the id: a + // retained name lets the next person to ask for that nickname inherit + // these messages, because distinguish only compares against the seat that + // is occupied. + scrubbed := false + for i := range r.chat { + if r.chat[i].author == s.id { + r.chat[i].author = "" + r.chat[i].name = "" + scrubbed = true + } + } + // Clearing the store is only half of it: the player who stayed is holding + // frames that still carry the departed name, and RoomState carries no + // chat. Without this re-sync they keep that attribution until they happen + // to reload — long enough for somebody to join under the same nickname and + // inherit a stranger's words. + if scrubbed { + // The loop above has already emptied this seat out of r.seats, so what + // is left is exactly the players who need correcting. + for _, other := range r.seats { + r.sendChatHistory(other) + } + } + if r.owner == s.id { + r.promote() + } +} + +// promote hands the room to whoever is left. +func (r *room) promote() { + for _, s := range r.seats { + if s != nil { + r.owner = s.id + // The new owner starts games, and starting is their readiness. A + // flag they set as a guest would sit there meaning nothing. + s.ready = false + return + } + } + r.owner = "" +} + +// broadcastRoomState sends the whole room to each occupant. +// +// Built per recipient because the field that matters most in it — which of +// these players is you — is relative to who is being told. One snapshot rather +// than a stream of deltas is what lets a client that missed a frame, or has +// just reconnected, be correct again from the next one. +func (r *room) broadcastRoomState() { + canStart := r.canStart() + + for _, s := range r.seats { + if s == nil || s.sess == nil { + continue + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomState{ + RoomState: &noituv1.RoomState{ + RoomCode: r.code, + CanStart: canStart, + Players: r.playerSlots(s.id), + MaxPlayers: maxPlayers, + MinPlayers: minPlayers, + GraceMs: uint32(r.graceFor.Milliseconds()), + }, + }}) + } +} + +// playerSlots renders the seating for one recipient, in seat order. That is +// the order they will play in, but not who plays first: the lead is drawn when +// the game starts, and the table sent with it is the one in turn order. +func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot { + slots := make([]*noituv1.PlayerSlot, 0, maxPlayers) + for _, s := range r.seats { + if s == nil { + continue + } + slots = append(slots, &noituv1.PlayerSlot{ + PlayerId: string(s.id), + Name: s.nickname, + IsMe: s.id == me, + IsOwner: s.id == r.owner, + Ready: s.ready, + Connected: s.sess != nil, + Wins: s.wins, + }) + } + return slots +} + +// seatIDs are the engine seat names, indexed by position. An id says which +// seat a player is in and nothing about their role: an owner who leaves hands +// that on, and the seat they vacate is refilled by an ordinary guest. +var seatIDs = [maxPlayers]game.PlayerID{"p1", "p2", "p3", "p4"} diff --git a/server/internal/wsapi/room_presence.go b/server/internal/wsapi/room_presence.go new file mode 100644 index 0000000..4a44176 --- /dev/null +++ b/server/internal/wsapi/room_presence.go @@ -0,0 +1,177 @@ +package wsapi + +import ( + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// Presence: a seat's reconnect window, opening it, closing it, and what a +// resume does once a connection comes back inside one. + +// disconnectGhostSeat opens the seat's reconnect window the moment it is +// filled, for a connection that turns out to have already torn down. +// +// The session can die between the hub handing this room the seating message +// and the room goroutine draining it off the queue — nothing else ever learns +// that, because leaveRoom only notifies a room the session was already +// attached to, and attaching is exactly what has not happened yet. Left +// seated as if connected, allConnected() would report true and quick match's +// own auto-start (see handleJoin) could begin a game against a socket nobody +// is behind. Applying the same grace window handleDisconnect would reuses the +// one mechanism that already bounds this instead of adding a second one. +func (r *room) disconnectGhostSeat(s *seat) { + s.sess = nil + s.graceUntil = time.Now().Add(r.graceFor) +} + +// handleDisconnect holds the seat open for the player who dropped out of it. +// +// A dropped connection is not a player leaving. The seat is kept for the +// reconnect window whether a game is running or the room is sitting in its +// lobby, so refreshing the page does not cost somebody the room they are in. +// +// The turn clock is deliberately not paused. A player who drops on their own +// turn loses it the way anybody else would; the window decides only whether +// they are still in the game afterwards. +func (r *room) handleDisconnect(m disconnectInput) { + s := r.seatOf(m.player) + // A stale notice from a connection the player already replaced. Acting on + // it would evict the seat the new socket is sitting in. + if s == nil || s.sess == nil || s.sess != m.sess { + return + } + s.sess = nil + s.graceUntil = time.Now().Add(r.graceFor) + // Presence is part of the room's state, and the run loop is what sends it. + // There is nothing extra to say to the players who are still here. + r.lobbyChanged = true +} + +// nextGraceExpiry is the earliest reconnect window still open. +func (r *room) nextGraceExpiry() (time.Time, bool) { + var next time.Time + for _, s := range r.seats { + if s == nil || s.sess != nil || s.graceUntil.IsZero() { + continue + } + if next.IsZero() || s.graceUntil.Before(next) { + next = s.graceUntil + } + } + return next, !next.IsZero() +} + +// handleGraceExpiry frees every seat whose reconnect window has run out. +// +// The engine goes first, while the seats are still here to be named: once one +// is vacated there is nobody left to attribute the elimination to, and the +// players who stayed would be told that somebody with no name went out. +func (r *room) handleGraceExpiry() { + now := time.Now() + + var expired []*seat + for _, s := range r.seats { + if s == nil || s.sess != nil || s.graceUntil.IsZero() || s.graceUntil.After(now) { + continue + } + expired = append(expired, s) + } + if len(expired) == 0 { + return + } + + before := r.mark() + for _, s := range expired { + r.eliminateAbsent(s, now) + } + r.applyEliminations(before) + + for _, s := range expired { + r.vacate(s) + } + r.lobbyChanged = true +} + +// eliminateAbsent takes a seat out of a live game once nobody is coming back +// to it. +// +// The engine is told this is a resignation, because that is the only shape it +// has for a player who stops playing. What the room reports is the transport +// fact instead: from everybody else's side this is somebody who left, not +// somebody who chose to give up. +func (r *room) eliminateAbsent(s *seat, now time.Time) { + if r.engine == nil || r.engine.Over() || !r.engine.Alive(s.id) { + return + } + r.outWire[s.id] = noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT + r.engine.Resign(s.id, now) +} + +// handleResume rebinds a seat to a new connection and replays the position. +// +// The replay is built from the engine, never from stored copies of past +// messages: a recorded stream can drift from the real state, and the resumed +// client would then be shown a board the server does not believe in. +func (r *room) handleResume(m resumeInput) { + s := r.seatOf(m.player) + if s == nil { + m.sess.send(errorMsg("session_not_resumable")) + return + } + + // Accepted. Only now is the old connection finished: its token is spent and + // its socket is either gone or about to be, and leaving it registered would + // let a third connection claim the same seat. + metrics.resumesSucceeded.Add(1) + m.sess.attach(r, string(m.player)) + if m.prior != nil { + m.sess.hub.unregister(m.prior.resumeToken) + m.prior.close() + } + s.sess = m.sess + s.graceUntil = time.Time{} + // The seat keeps the name it was given. Re-reading it from the new + // connection would let a reconnect rename a player mid-game, including + // into somebody else's name. + + // Everybody needs the room's state again: this player to render the lobby + // they came back to, the rest to stop watching a disconnect banner for + // somebody who is already back. The run loop sends it to all of them. + r.lobbyChanged = true + + if m.sess.ctx.Err() != nil { + // The new connection can die between the client's Hello landing and + // this resume being drained off the room's queue, the same race + // handleCreate and handleJoin guard against. Reopening the window it + // just closed leaves the seat exactly as reachable as it was before + // this resume was ever attempted. + r.disconnectGhostSeat(s) + return + } + + // Before the lobby return below, not after it: a refresh in the lobby is + // the commonest resume there is, and it is exactly the one that would miss + // a replay hung off the end of this function. + r.sendChatHistory(s) + + // Resumed between games, or before the first one. The lobby state above is + // the whole answer; there is no position to replay. + if r.inLobby() { + return + } + state := r.engine.Snapshot() + r.sendGameStarted(s, state) + if last, ok := r.engine.LastMove(); ok { + r.sendTurnUpdate(s, state, &last, r.moveMeanings(&last)) + } +} + +// detachAll releases every connection still bound to this room as it exits. +func (r *room) detachAll() { + for _, s := range r.seats { + if s != nil && s.sess != nil { + s.sess.release(r) + } + } +} diff --git a/server/internal/wsapi/server.go b/server/internal/wsapi/server.go index d5f18a8..7a2f834 100644 --- a/server/internal/wsapi/server.go +++ b/server/internal/wsapi/server.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "time" @@ -40,6 +41,12 @@ type Config struct { // default. MaxConnections caps open sockets the same way. MaxRooms int MaxConnections int + // MaxConnectionsPerIP caps how many open sockets one address may hold at + // once. Zero — the default — turns it off: an address is only ever one + // player behind a trusted proxy that unmasks the real client (see + // TrustedProxies and clientIP); everywhere else it can be a whole NAT + // egress, and capping it would cap that egress at one player. + MaxConnectionsPerIP int // Version is what GET /version answers and what the startup log line // carries. Empty falls back to defaultVersion, which is what a plain // `go run` or a test server — nothing built with -ldflags — reports. @@ -66,6 +73,14 @@ type Server struct { proxies []netip.Prefix maxConns int64 conns atomic.Int64 + + // maxConnsPerIP is 0 when the cap is off. connsByIP is only ever touched + // under its own mutex, separate from the hub's: it is purely a transport + // accounting concern, one HTTP handler wide, with nothing to do with + // rooms or sessions. + maxConnsPerIP int64 + connsByIPMu sync.Mutex + connsByIP map[string]int } // NewServer builds the handler tree. @@ -78,13 +93,15 @@ func NewServer(ctx context.Context, dict Dictionary, cfg Config) *Server { } s := &Server{ - hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.IdleFor, cfg.MaxRooms), - mux: http.NewServeMux(), - cancel: cancel, - cfg: cfg, - version: version, - proxies: parsePrefixes(cfg.TrustedProxies), - maxConns: int64(cfg.MaxConnections), + hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.IdleFor, cfg.MaxRooms), + mux: http.NewServeMux(), + cancel: cancel, + cfg: cfg, + version: version, + proxies: parsePrefixes(cfg.TrustedProxies), + maxConns: int64(cfg.MaxConnections), + maxConnsPerIP: int64(cfg.MaxConnectionsPerIP), + connsByIP: map[string]int{}, } if s.maxConns <= 0 { s.maxConns = defaultMaxConnections @@ -150,6 +167,15 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { } defer s.conns.Add(-1) + ip := s.clientIP(r) + if s.maxConnsPerIP > 0 { + if !s.reserveIP(ip) { + http.Error(w, "too many connections from this address", http.StatusServiceUnavailable) + return + } + defer s.releaseIP(ip) + } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ OriginPatterns: s.cfg.AllowedOrigins, }) @@ -164,7 +190,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { metrics.connectionsTotal.Add(1) defer metrics.connectionsOpen.Add(-1) - sess := newSession(s.hub.ctx, conn, s.hub, s.clientIP(r)) + sess := newSession(s.hub.ctx, conn, s.hub, ip) sess.run() // The token has to outlive the socket by exactly the grace window: that is @@ -279,6 +305,33 @@ func (s *Server) trusted(host string) bool { return false } +// reserveIP claims one of an address's connection slots, refusing once +// MaxConnectionsPerIP of them are already open. Only called when the cap is +// on; off is the default for exactly the reason clientIP's own comment gives — +// without a trusted proxy unmasking the real client, one address can be an +// entire NAT egress, and this would cap it at a single player. +func (s *Server) reserveIP(ip string) bool { + s.connsByIPMu.Lock() + defer s.connsByIPMu.Unlock() + if int64(s.connsByIP[ip]) >= s.maxConnsPerIP { + return false + } + s.connsByIP[ip]++ + return true +} + +// releaseIP frees the slot reserveIP claimed. The entry is dropped once it +// reaches zero rather than left behind at 0, so the map does not grow for +// every address that has ever connected and disconnected. +func (s *Server) releaseIP(ip string) { + s.connsByIPMu.Lock() + defer s.connsByIPMu.Unlock() + s.connsByIP[ip]-- + if s.connsByIP[ip] <= 0 { + delete(s.connsByIP, ip) + } +} + // remoteHost strips the port from a RemoteAddr. func remoteHost(remoteAddr string) string { host, _, err := net.SplitHostPort(remoteAddr) diff --git a/server/internal/wsapi/session.go b/server/internal/wsapi/session.go index a7615f0..5415a0c 100644 --- a/server/internal/wsapi/session.go +++ b/server/internal/wsapi/session.go @@ -12,9 +12,12 @@ import ( "github.com/coder/websocket" noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" "github.com/tiennm99dev/noitu/server/internal/game" - "github.com/tiennm99dev/noitu/server/internal/vietnamese" ) +// The socket half of a connection: reading and writing frames, the +// keepalive that detects a dead peer, and the identity — nickname, room, +// seat — the protocol half below reads and writes through the same mutex. + const ( // outboxCap buffers writes. A client that cannot keep up with this many // pending frames is not going to catch up, so the session is closed rather @@ -47,8 +50,15 @@ const ( chatsPerSecond = 2.0 chatBurst = 5 - joinsPerSecond = 1 - joinBurst = 5 + // joinsPerSecond and joinBurst bound how many rooms one address may join + // or attempt to join. The limiter exists to slow a brute-force walk of the + // room-code space (31 characters over 6 places, ~8.9e8 codes) to + // centuries even at this rate — it is not meant to ration ordinary play. + // A single NAT/CGNAT egress (a café, a school, a mobile carrier) can be + // many real players sharing one address, so the budget has to be generous + // enough for a whole one of those, not just one person. + joinsPerSecond = 5 + joinBurst = 20 // maxWordReportsPerSession bounds how many distinct words one session may // file with ReportWord. A duplicate report of a word already filed does @@ -428,317 +438,6 @@ func (s *session) keepalive() { } } -// dispatch routes one client message. -// -// Hello must come first: everything else needs a sanitized nickname and a -// registered resume token, and accepting them before the handshake would mean -// carrying "maybe not greeted yet" through every branch below. -func (s *session) dispatch(msg *noituv1.ClientMessage) error { - if _, isHello := msg.GetPayload().(*noituv1.ClientMessage_Hello); !isHello && s.nickname() == "" { - s.send(errorMsg("handshake_required")) - return errHandshake - } - - switch p := msg.GetPayload().(type) { - case *noituv1.ClientMessage_Hello: - return s.handleHello(p.Hello) - - case *noituv1.ClientMessage_StartBotGame: - // The limiter is charged before the payload is inspected, so a bad - // difficulty costs the same as a good one and cannot be used to probe - // for free. - if !s.roomLimiter.allow(time.Now()) { - s.send(errorMsg("too_many_rooms")) - return nil - } - difficulty, ok := Difficulty(p.StartBotGame.GetDifficulty()) - if !ok { - s.send(errorMsg("unknown_difficulty")) - return nil - } - if err := s.hub.startBotRoom(s, difficulty); err != nil { - s.send(roomCreateError(s.id, err)) - } - - case *noituv1.ClientMessage_CreateRoom: - // Creating a room allocates a goroutine and an engine, so one - // connection must not be able to mint them without limit. - if !s.roomLimiter.allow(time.Now()) { - s.send(errorMsg("too_many_rooms")) - return nil - } - if err := s.hub.createRoom(s); err != nil { - s.send(roomCreateError(s.id, err)) - } - - case *noituv1.ClientMessage_JoinRoom: - if !s.hub.joinLimiter.allow(s.remoteIP, time.Now()) { - metrics.joinsRefused.Add("too_many_attempts", 1) - s.send(errorMsg("too_many_attempts")) - return nil - } - if err := s.hub.joinRoom(p.JoinRoom.GetRoomCode(), s); err != nil { - metrics.joinsRefused.Add("room_not_found", 1) - s.send(errorMsg("room_not_found")) - } - - case *noituv1.ClientMessage_QuickMatch: - if r, _ := s.currentRoom(); r != nil { - s.send(errorMsg("already_in_a_room")) - return nil - } - // A match mints a room exactly as CreateRoom does, so it is charged - // the same way and for the same reason. - if !s.roomLimiter.allow(time.Now()) { - s.send(errorMsg("too_many_rooms")) - return nil - } - if err := s.hub.quickMatch(s); err != nil { - if errors.Is(err, errAlreadyQueued) { - s.send(errorMsg("already_queued")) - } else { - s.send(roomCreateError(s.id, err)) - } - } - - case *noituv1.ClientMessage_CancelQuickMatch: - // Idempotent by design: a cancel that finds nothing queued is not an - // error, it is the answer the player wanted. - s.hub.cancelQuickMatch(s) - s.send(quickMatchStatusMsg(false)) - - case *noituv1.ClientMessage_SubmitWord: - s.handleSubmit(p.SubmitWord) - - case *noituv1.ClientMessage_Resign: - // A silently dropped resignation leaves the player staring at a board - // they thought they had left. - if r, id := s.currentRoom(); r != nil { - if !r.send(resignInput{sess: s, player: id}) { - s.send(errorMsg("game_already_over")) - } - } else { - s.send(errorMsg("not_in_a_game")) - } - - case *noituv1.ClientMessage_ClaimDeadEnd: - // Rate-limited on the same budget as a submission: a claim is the - // alternative to playing a word, not a second action alongside it. - if !s.submitLimiter.allow(time.Now()) { - s.send(errorMsg("too_fast")) - return nil - } - if r, id := s.currentRoom(); r != nil { - if !r.send(claimDeadEndInput{sess: s, player: id}) { - s.send(errorMsg("busy")) - } - } else { - s.send(errorMsg("not_in_a_game")) - } - - case *noituv1.ClientMessage_ReportWord: - s.handleReportWord(p.ReportWord) - - case *noituv1.ClientMessage_SetReady: - s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()}) - - case *noituv1.ClientMessage_StartGame: - s.toRoom(lobbyInput{sess: s, action: lobbyStart}) - - case *noituv1.ClientMessage_KickPlayer: - s.toRoom(lobbyInput{sess: s, action: lobbyKick, target: playerIDFor(p.KickPlayer.GetPlayerId())}) - - case *noituv1.ClientMessage_LeaveRoom: - s.toRoom(lobbyInput{sess: s, action: lobbyLeave}) - - case *noituv1.ClientMessage_SendChat: - // Its own budget, so a talkative player never runs out of moves. The - // seat itself is checked by the room, which is the only place that - // knows whether this connection still holds one. - if !s.chatLimiter.allow(time.Now()) { - s.send(errorMsg("too_fast")) - return nil - } - r, id := s.currentRoom() - if r == nil { - s.send(errorMsg("not_in_a_room")) - return nil - } - // A dropped line would leave the player watching their own message - // fail to appear with no reason given. - if !r.send(chatInput{sess: s, player: id, text: p.SendChat.GetText()}) { - s.send(errorMsg("busy")) - } - - case *noituv1.ClientMessage_Ping: - s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli())) - } - return nil -} - -// roomCreateError names the refusal a room could not be opened for. A full -// server is the player's business — they should wait, not retry at once — and -// anything else is the server's, logged here because the client is only told -// that it failed. -func roomCreateError(sessionID string, err error) *noituv1.ServerMessage { - if errors.Is(err, errServerFull) { - return errorMsg("server_full") - } - if errors.Is(err, errDraining) { - // The same key Shutdown sends to everyone already seated: a room - // refused for this reason will not open a moment later the way a full - // one might, so the client is told the same thing either way. - return errorMsg("server_restarting") - } - slog.Error("open room", "session", sessionID, "err", err) - return errorMsg("room_start_failed") -} - -// toRoom forwards one lobby action to the room this connection is seated in. -// -// Rate-limited like a submission: every accepted action is broadcast to every -// seat, so an unbounded one lets a player flood the other's outbox until -// their session is closed for falling behind. A dropped action would leave a -// button that did nothing and no reason why, so every failure answers. -func (s *session) toRoom(in lobbyInput) { - if !s.submitLimiter.allow(time.Now()) { - s.send(errorMsg("too_fast")) - return - } - r, id := s.currentRoom() - if r == nil { - s.send(errorMsg("not_in_a_room")) - return - } - in.player = id - if !r.send(in) { - s.send(errorMsg("not_in_a_room")) - } -} - -// handleHello completes the handshake, resuming a prior game when the client -// presents a token that is still live. -func (s *session) handleHello(h *noituv1.Hello) error { - if v := h.GetProtocolVersion(); v != ProtocolVersion { - s.send(errorMsg("protocol_version_mismatch")) - return errors.New("wsapi: protocol version mismatch") - } - - // The handshake is a one-shot transition. A second Hello would re-register - // the session and rewrite the nickname of a player already seated in a - // game, which nothing downstream expects. - s.mu.Lock() - repeat := s.greeted - s.greeted = true - s.mu.Unlock() - if repeat { - s.send(errorMsg("already_greeted")) - return errors.New("wsapi: repeated hello") - } - - s.setNickname(sanitizeNickname(h.GetNickname())) - s.hub.register(s) - s.send(welcomeMsg(s.id, s.resumeToken, s.nickname())) - - if prior, ok := s.hub.resumable(h.GetResumeToken()); ok && prior != s { - s.resumeFrom(prior) - } - return nil -} - -// resumeFrom takes over the seat a previous connection held. -// -// Every failing branch has to say so. A token can outlive its game — the turn -// clock keeps running through the grace window, so a player who dropped on -// their own turn loses before the window closes — and a client that got a -// Welcome and then silence has nothing to render and no reason to stop -// waiting. -func (s *session) resumeFrom(prior *session) { - metrics.resumesAttempted.Add(1) - r, id := prior.currentRoom() - if r == nil { - s.send(errorMsg("game_already_over")) - return - } - if !r.send(resumeInput{player: id, sess: s, prior: prior}) { - s.send(errorMsg("game_already_over")) - return - } - // Deliberately no attach and no close here. The room has not decided yet, - // and a refused resume that had already closed the old connection would end - // the game it was trying to rejoin. -} - -func (s *session) handleSubmit(w *noituv1.SubmitWord) { - if !s.submitLimiter.allow(time.Now()) { - s.send(errorMsg("too_fast")) - return - } - r, id := s.currentRoom() - if r == nil { - s.send(errorMsg("not_in_a_game")) - return - } - // A dropped submission would otherwise leave the player waiting out the - // turn clock with no idea their word never arrived. - if !r.send(submitInput{sess: s, player: id, word: w.GetWord(), turnSeq: w.GetTurnSeq()}) { - s.send(errorMsg("busy")) - } -} - -// handleReportWord validates a word report and, once it is worth logging, -// hands it to the current room for the context only the room goroutine may -// read — the syllable in play, and the room's own mode and code. -// -// Validation happens here rather than in the room because it is entirely -// about this connection: its own rate budget, and its own running count of -// distinct words already filed. Neither needs the room at all, and a session -// playing no game — smoke-testing the wire directly, per the README — can -// still file a report, acknowledged with mode "none" and no link. -func (s *session) handleReportWord(m *noituv1.ReportWord) { - if !s.chatLimiter.allow(time.Now()) { - s.send(errorMsg("too_fast")) - return - } - - word, syllables, err := vietnamese.Normalize(sanitizeText(m.GetWord(), maxWordRunes, maxNicknameMarks)) - if err != nil || !vietnamese.HasEnoughSyllables(syllables) { - s.send(errorMsg("word_report_refused")) - return - } - - if _, already := s.reportedWords[word]; !already { - if len(s.reportedWords) >= maxWordReportsPerSession { - s.send(errorMsg("word_report_limit")) - return - } - s.reportedWords[word] = struct{}{} - } - - // currentRoom's player id is not needed here: the log line is about the - // word and the room's context, never about who filed it. - if r, _ := s.currentRoom(); r != nil { - if !r.send(reportWordInput{sess: s, word: word}) { - s.send(errorMsg("busy")) - } - return - } - - metrics.wordsReported.Add(1) - slog.Info("word_reported", "word", word, "link", "", "mode", "none", "room", "") - s.send(wordReportedMsg(word)) -} - -// leaveRoom tells the room this connection is gone, so the seat enters its -// grace window rather than the game simply stalling. -func (s *session) leaveRoom() { - r, id := s.currentRoom() - if r == nil { - return - } - r.send(disconnectInput{player: id, sess: s}) -} - func randomToken() string { raw := make([]byte, 16) _, _ = rand.Read(raw) diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go index 59e8b25..18e9049 100644 --- a/server/internal/wsapi/wsapi_test.go +++ b/server/internal/wsapi/wsapi_test.go @@ -2,10 +2,8 @@ package wsapi import ( "context" - "errors" "fmt" "iter" - "math/rand/v2" "net/http/httptest" "runtime" "slices" @@ -13,17 +11,18 @@ import ( "testing" "time" "unicode" - "unicode/utf8" "github.com/coder/websocket" noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" - "github.com/tiennm99dev/noitu/server/internal/bot" "github.com/tiennm99dev/noitu/server/internal/dictionary" - "github.com/tiennm99dev/noitu/server/internal/game" "golang.org/x/text/unicode/norm" "google.golang.org/protobuf/proto" ) +// Shared test harness: a hand-built dictionary a reader can follow, a +// server wired to it over a real WebSocket, and the client-side helpers +// every topic file below drives a room through. + // testDict is a hand-built word graph. // // A dozen words with edges a reader can follow beats a real dictionary here: @@ -145,8 +144,6 @@ func chainDict() *testDict { return newTestDict("a b", "b c", "c d", "d e") } -// --- test client ----------------------------------------------------------- - type testClient struct { t *testing.T conn *websocket.Conn @@ -340,733 +337,9 @@ func (c *testClient) submit(word string, seq uint32) { }}) } -// --- tests ----------------------------------------------------------------- - -// TestBotGamePlaysToCompletion walks a whole vs-bot game with no frontend -// involved. The graph is a forced chain, so the ending is not a matter of -// which word the bot happens to pick. -func TestBotGamePlaysToCompletion(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Ăn") - - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ - StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, - }}) - - started := c.await("game_started").GetGameStarted() - if started.GetOpeningWord() != "a b" { - t.Fatalf("opening = %q, want %q", started.GetOpeningWord(), "a b") - } - if !started.GetMyTurn() { - t.Fatal("human should move first in a bot game") - } - if started.GetCurrentSyllable() != "b" { - t.Fatalf("current syllable = %q, want b", started.GetCurrentSyllable()) - } - - // "b c" is the only legal reply. The bot then has only "c d", which leaves - // the human "d e", after which the bot has nothing and loses. - c.submit("b c", started.GetTurnSeq()) - - // The player's own move comes back first — every accepted move is fanned - // out to both seats — so the bot's reply is the second update. - if own := c.await("turn_update").GetTurnUpdate(); !own.GetPlayed().GetByMe() { - t.Fatal("first update after submitting should be the player's own move") - } - botReply := c.await("turn_update").GetTurnUpdate() - if botReply.GetPlayed().GetByMe() { - t.Fatal("expected the bot's move, not another echo") - } - if !botReply.GetMyTurn() { - t.Fatal("human should be on turn after the bot replies") - } - c.submit("d e", botReply.GetTurnSeq()) - - over := c.await("game_over").GetGameOver() - if !over.GetIWon() { - t.Errorf("human should win when the bot runs out of words, got %+v", over) - } -} - -// TestTheFirstTurnIsDrawn checks that opening the room is not the same as -// opening the game. Moving first is an advantage, and handing it to the owner -// every time would make them favourite in every game of a series. -func TestTheFirstTurnIsDrawn(t *testing.T) { - // Started here rather than over a pair of sockets: a series long enough to - // tell a draw from a fixed lead is far more starts than a lobby's rate - // limiter allows, and none of what is being checked is on the wire. - // beginGame reports to the hub's live-game gauge, so this hand-built room - // needs one even though nothing here reads it back. - r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second} - r.seats[0] = &seat{id: "p1"} - r.seats[1] = &seat{id: "p2"} - - // There are two outcomes, so a series that only ever shows one of them is - // a lead that is not being drawn. A fixed lead fails this every time; a - // fair draw fails it about once in five hundred million runs. - const games = 30 - led := map[game.PlayerID]int{} - for range games { - if err := r.beginGame(); err != nil { - t.Fatalf("beginGame: %v", err) - } - led[r.engine.Turn()]++ - } - - if led["p1"] == 0 || led["p2"] == 0 { - t.Errorf("over %d games p1 led %d and p2 %d; the first turn is not being drawn", - games, led["p1"], led["p2"]) - } -} - -// The one room where the lead is not drawn: the human opens against the bot. -func TestABotGameOpensWithTheHuman(t *testing.T) { - strategy, err := bot.New(bot.Easy, rand.New(rand.NewPCG(1, 2))) - if err != nil { - t.Fatalf("bot.New: %v", err) - } - r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, strategy: strategy} - r.seats[0] = &seat{id: "p1"} - r.seats[1] = &seat{id: botPlayerID} - - for range 20 { - if err := r.beginGame(); err != nil { - t.Fatalf("beginGame: %v", err) - } - if got := r.engine.Turn(); got != "p1" { - t.Fatalf("the bot game opened on %q, want the human", got) - } - } -} - -// TestPvPGameAlternatesTurns runs two clients through a full game and checks -// that each sees the other's move rendered from its own side. -func TestPvPGameAlternatesTurns(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - readyAndStart(t, host, guest) - - lead, waits, start := awaitLead(t, host, guest) - - lead.submit("b c", start.GetTurnSeq()) - - // The same move, rendered per recipient: by_me flips, my_turn flips. - moverUpdate := lead.await("turn_update").GetTurnUpdate() - otherUpdate := waits.await("turn_update").GetTurnUpdate() - - if !moverUpdate.GetPlayed().GetByMe() { - t.Error("mover should see by_me = true") - } - if otherUpdate.GetPlayed().GetByMe() { - t.Error("opponent should see by_me = false") - } - if moverUpdate.GetMyTurn() { - t.Error("mover should not be on turn after moving") - } - if !otherUpdate.GetMyTurn() { - t.Error("opponent should now be on turn") - } - if myScore(moverUpdate) != otherScore(otherUpdate) { - t.Errorf("scores disagree across recipients: %d vs %d", - myScore(moverUpdate), otherScore(otherUpdate)) - } -} - -// TestTurnTimeoutEndsGameServerSide proves the clock is the server's. The -// client sends nothing at all after the game starts. -func TestTurnTimeoutEndsGameServerSide(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{TurnLimit: 250 * time.Millisecond}) - - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - readyAndStart(t, host, guest) - - _, waits, _ := awaitLead(t, host, guest) - - over := waits.await("game_over").GetGameOver() - if !over.GetIWon() { - t.Error("the player who did not time out should win") - } - if over.GetReason() != noituv1.GameEndReason_GAME_END_REASON_TIMEOUT { - t.Errorf("reason = %v, want TIMEOUT", over.GetReason()) - } -} - -// TestRejectionsCarryTheRightReason checks each rejection a player can -// actually provoke reaches the client as a distinct enum, since the UI copy -// keys off exactly this value. -func TestRejectionsCarryTheRightReason(t *testing.T) { - tests := []struct { - name string - word string - want noituv1.RejectReason - }{ - {"unknown word", "khong co", noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY}, - {"wrong link", "c d", noituv1.RejectReason_REJECT_REASON_WRONG_LINK}, - {"single syllable", "b", noituv1.RejectReason_REJECT_REASON_TOO_FEW_SYLLABLES}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Người thử") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ - StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, - }}) - started := c.await("game_started").GetGameStarted() - - c.submit(tc.word, started.GetTurnSeq()) - - got := c.await("move_rejected").GetMoveRejected() - if got.GetReason() != tc.want { - t.Errorf("reason = %v, want %v", got.GetReason(), tc.want) - } - if got.GetWord() != tc.word { - t.Errorf("rejection echoed %q, want %q", got.GetWord(), tc.word) - } - }) - } -} - -// TestReplayingAWordIsRejected needs its own graph: the engine checks the link -// before reuse, so replaying the opening word on the very first turn reports -// WRONG_LINK. Only a word that links correctly *and* has been played can -// surface ALREADY_USED, which takes a cycle in the graph and a move to reach. -func TestReplayingAWordIsRejected(t *testing.T) { - // a b -> b a -> back to a word starting with "a", which is the opening. - dict := newTestDict("a b", "b a", "a c", "c a") - _, url := newTestServer(t, dict, Config{TurnLimit: 10 * time.Second}) - - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - readyAndStart(t, host, guest) - lead, waits, start := awaitLead(t, host, guest) - - // Opening "a b" is used and current syllable is "b". Play "b a" so the - // syllable returns to "a", where the opening word now links legally. - lead.submit("b a", start.GetTurnSeq()) - replyTurn := waits.await("turn_update").GetTurnUpdate() - - waits.submit("a b", replyTurn.GetTurnSeq()) - - got := waits.await("move_rejected").GetMoveRejected() - if got.GetReason() != noituv1.RejectReason_REJECT_REASON_ALREADY_USED { - t.Errorf("reason = %v, want ALREADY_USED", got.GetReason()) - } -} - -// TestStaleTurnSeqIsRejected covers the double-submit guard: a word stamped -// with a turn that has already passed must not be applied to the current one. -func TestStaleTurnSeqIsRejected(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Người thử") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ - StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, - }}) - started := c.await("game_started").GetGameStarted() - - c.submit("b c", started.GetTurnSeq()+99) - - got := c.await("move_rejected").GetMoveRejected() - if got.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN { - t.Errorf("reason = %v, want NOT_YOUR_TURN", got.GetReason()) - } -} - -// TestResumeWithinGraceRestoresGame drops a connection mid-game and brings it -// back with the resume token. -func TestResumeWithinGraceRestoresGame(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second, GraceFor: 5 * time.Second}) - - host := dial(t, url) - welcome := host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - readyAndStart(t, host, guest) - hostStart := host.await("game_started").GetGameStarted() - guest.await("game_started") - - _ = host.conn.Close(websocket.StatusGoingAway, "") - - // Presence is part of the room's state now, so the seat being held is - // something the other player reads there rather than in a message of its - // own. - if away := otherSlot(guest.await("room_state").GetRoomState()); away == nil || away.GetConnected() { - t.Error("opponent should be shown as away while the seat is held") - } - - // Reconnect with the token and expect the position back. - back := dial(t, url) - back.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ - ProtocolVersion: ProtocolVersion, - Nickname: "Chủ phòng", - ResumeToken: welcome.GetResumeToken(), - }}}) - back.await("welcome") - - restored := back.await("game_started").GetGameStarted() - if restored.GetOpeningWord() != "a b" { - t.Errorf("resumed on %q, want the original opening", restored.GetOpeningWord()) - } - if restored.GetMyTurn() != hostStart.GetMyTurn() { - t.Error("the resumed player should come back to the turn they left") - } -} - -// TestGraceExpiryAwardsTheGame is the other half: nobody comes back. -func TestGraceExpiryAwardsTheGame(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second, GraceFor: 200 * time.Millisecond}) - - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - readyAndStart(t, host, guest) - host.await("game_started") - guest.await("game_started") - - _ = host.conn.Close(websocket.StatusGoingAway, "") - - over := guest.await("game_over").GetGameOver() - if !over.GetIWon() { - t.Error("the player who stayed should win") - } - if over.GetReason() != noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT { - t.Errorf("reason = %v, want OPPONENT_LEFT", over.GetReason()) - } -} - -// TestUnknownRoomCodeIsRefused checks a join against a code nobody holds. -func TestUnknownRoomCodeIsRefused(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Khách") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: "ZZZZZZ"}, - }}) - - if code := c.await("error").GetError().GetCode(); code != "room_not_found" { - t.Errorf("error code = %q, want room_not_found", code) - } -} - -// TestProtocolVersionMismatchIsRefused proves an incompatible client is told -// so rather than left to misread frames. -func TestProtocolVersionMismatchIsRefused(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ - ProtocolVersion: ProtocolVersion + 1, - Nickname: "Cũ", - }}}) - - if code := c.await("error").GetError().GetCode(); code != "protocol_version_mismatch" { - t.Errorf("error code = %q, want protocol_version_mismatch", code) - } -} - -// TestHandshakeIsRequiredFirst rejects a client that skips Hello. -func TestHandshakeIsRequiredFirst(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - - if code := c.await("error").GetError().GetCode(); code != "handshake_required" { - t.Errorf("error code = %q, want handshake_required", code) - } -} - -// TestOversizeFrameClosesConnection covers the read limit. The frame is built -// past maxFrameBytes, so the socket must close rather than the decoder be -// asked to parse it. -func TestOversizeFrameClosesConnection(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Người thử") - - c.submit(strings.Repeat("x", maxFrameBytes+1), 1) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - for { - _, _, err := c.conn.Read(ctx) - if err == nil { - continue - } - // The status matters, not merely that the socket closed: any unrelated - // failure would otherwise let this pass while the read limit did - // nothing. - if got := websocket.CloseStatus(err); got != websocket.StatusMessageTooBig { - t.Errorf("close status = %v, want StatusMessageTooBig", got) - } - return - } -} - -// TestSubmitRateLimit checks the token bucket refuses a burst well past what a -// person types, since each submit costs a dictionary lookup. -func TestSubmitRateLimit(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Người thử") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ - StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, - }}) - started := c.await("game_started").GetGameStarted() - - for range submitBurst + 5 { - c.submit("khong co", started.GetTurnSeq()) - } - - for range 40 { - m := c.recv() - if payloadCase(m) == "error" && m.GetError().GetCode() == "too_fast" { - return - } - } - t.Error("never rate-limited despite submitting past the burst") -} - -// TestOriginIsChecked confirms a cross-origin handshake is refused when the -// allowlist does not include it. -func TestOriginIsChecked(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{AllowedOrigins: []string{"example.com"}}) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - _, _, err := websocket.Dial(ctx, url+"/ws", &websocket.DialOptions{ - HTTPHeader: map[string][]string{"Origin": {"http://evil.example"}}, - }) - if err == nil { - t.Fatal("a disallowed origin completed the handshake") - } -} - -// TestRoomIsEvictedWhenGameEnds guards against rooms accumulating: a finished -// game must leave the registry so its code is reusable. -func TestRoomIsEvictedWhenGameEnds(t *testing.T) { - api, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Người thử") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ - StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, - }}) - started := c.await("game_started").GetGameStarted() - c.submit("b c", started.GetTurnSeq()) - c.await("game_over") - - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - if api.hub.roomCount() == 0 { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Errorf("room still registered after the game ended: %d live", api.hub.roomCount()) -} - -// TestPingIsAnswered covers the clock-offset path the client uses to render a -// countdown against the server's absolute deadline. -func TestPingIsAnswered(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Người thử") - - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Ping{ - Ping: &noituv1.Ping{ClientTimeMs: 1234}, - }}) - - pong := c.await("pong").GetPong() - if pong.GetClientTimeMs() != 1234 { - t.Errorf("pong echoed %d, want 1234", pong.GetClientTimeMs()) - } - if pong.GetServerTimeMs() == 0 { - t.Error("pong carried no server clock") - } -} - -// TestTextFrameIsRejected keeps the protocol binary-only: guessing at another -// encoding is how a parser becomes an attack surface. -func TestTextFrameIsRejected(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - - if err := c.conn.Write(c.ctx, websocket.MessageText, []byte("hello")); err != nil { - t.Fatalf("write: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - for { - if _, _, err := c.conn.Read(ctx); err != nil { - return - } - } -} - -func TestDecodeRejectsTextFrames(t *testing.T) { - if _, err := Decode(websocket.MessageText, nil); !errors.Is(err, ErrNotBinary) { - t.Errorf("Decode(text) error = %v, want ErrNotBinary", err) - } -} - -func TestHealthz(t *testing.T) { - api, _ := newTestServer(t, chainDict(), Config{}) - req := httptest.NewRequest("GET", "/healthz", nil) - rec := httptest.NewRecorder() - api.ServeHTTP(rec, req) - - if rec.Code != 200 { - t.Errorf("healthz returned %d, want 200", rec.Code) - } -} - -func TestRoomCodeAlphabetAvoidsLookalikes(t *testing.T) { - for _, bad := range []rune{'0', 'O', '1', 'I', 'L'} { - if strings.ContainsRune(roomCodeAlphabet, bad) { - t.Errorf("alphabet contains the easily-confused %q", bad) - } - } - - seen := map[string]bool{} - for range 1000 { - code := randomCode() - if len(code) != roomCodeLen { - t.Fatalf("code %q is %d chars, want %d", code, len(code), roomCodeLen) - } - for _, r := range code { - if !strings.ContainsRune(roomCodeAlphabet, r) { - t.Fatalf("code %q contains %q, outside the alphabet", code, r) - } - } - seen[code] = true - } - // Not a randomness test — just proof the generator is not returning a - // constant, which a broken modulo could. - if len(seen) < 900 { - t.Errorf("only %d distinct codes in 1000 draws", len(seen)) - } -} - -func TestFreezeBoardIncludesTheOpeningWord(t *testing.T) { - // The engine counts the opening word as played but Snapshot's history does - // not list it. A frozen board that missed it would let the bot choose a - // word the engine then rejects as already used — the two would disagree - // about the position while appearing to share one dictionary. - dict := chainDict() - e, err := game.New(dict, []game.PlayerID{"p1", "p2"}, "a b", time.Second, time.Now()) - if err != nil { - t.Fatalf("engine: %v", err) - } - - board := freezeBoard(e, "a b") - if !board.Used("a b") { - t.Error("frozen board does not consider the opening word played") - } - if !slices.Contains(board.LegalMoves(), "b c") { - t.Errorf("legal moves = %v, want the one continuation", board.LegalMoves()) - } -} - -func TestSanitizeNickname(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {"plain", "Minh", "Minh"}, - {"vietnamese kept", "Nguyễn Thuý", "Nguyễn Thuý"}, - {"empty falls back", "", defaultNickname}, - {"whitespace only falls back", " \t\n ", defaultNickname}, - {"control characters stripped", "Mi\x00nh\x07", "Minh"}, - {"zero width stripped", "Mi\u200bnh\u200d", "Minh"}, - {"bidi override stripped", "Minh\u202e", "Minh"}, - {"whitespace collapsed", " Minh Nguyen ", "Minh Nguyen"}, - {"newlines become spaces", "Minh\nNguyen", "Minh Nguyen"}, - {"over length truncated", strings.Repeat("a", 40), strings.Repeat("a", maxNicknameRunes)}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := sanitizeNickname(tc.in); got != tc.want { - t.Errorf("sanitizeNickname(%q) = %q, want %q", tc.in, got, tc.want) - } - }) - } -} - -// TestSanitizeNicknameCountsRunesNotBytes guards the cap against being applied -// in bytes, which would cut a Vietnamese name to a third of the length a Latin -// one keeps. -func TestSanitizeNicknameCountsRunesNotBytes(t *testing.T) { - in := strings.Repeat("ữ", maxNicknameRunes) - got := sanitizeNickname(in) - if n := len([]rune(got)); n != maxNicknameRunes { - t.Errorf("kept %d runes, want %d (byte-based truncation?)", n, maxNicknameRunes) - } -} - -// TestDistinguishSeparatesIdenticalNames covers the collision the fallback -// creates — players who all send nothing — and the one it always could: -// players who choose the same name. -func TestDistinguishSeparatesIdenticalNames(t *testing.T) { - if got := distinguish("Minh", []string{"Thuý"}); got != "Minh" { - t.Errorf("distinct names should be left alone, got %q", got) - } - - // A whole room of unnamed players, seated one at a time. Every one of them - // has to end up with a name none of the others is already using: two - // suffixed identically is the same failure as two unsuffixed. - var taken []string - for range maxPlayers { - got := distinguish(defaultNickname, taken) - if slices.Contains(taken, got) { - t.Fatalf("distinguish returned %q, which is already in %v", got, taken) - } - if n := len([]rune(got)); n > maxNicknameRunes { - t.Errorf("distinguished name %q is %d runes, over the %d cap", got, n, maxNicknameRunes) - } - taken = append(taken, got) - } - - // The suffix must not push a name that is already at the cap past it. - long := strings.Repeat("a", maxNicknameRunes) - if got := distinguish(long, []string{long}); len([]rune(got)) > maxNicknameRunes { - t.Errorf("distinguished name is %d runes, over the %d cap", len([]rune(got)), maxNicknameRunes) - } -} - -func TestBucketRefills(t *testing.T) { - now := time.Now() - b := newBucket(5, 2, now) - - for i := range 2 { - if !b.allow(now) { - t.Fatalf("burst should cover the first two, call %d refused", i+1) - } - } - if b.allow(now) { - t.Fatal("third call should exhaust the bucket") - } - - // 5/sec means one token back after 200ms. - if !b.allow(now.Add(220 * time.Millisecond)) { - t.Error("bucket did not refill") - } -} - -func TestKeyedLimiterIsPerKey(t *testing.T) { - now := time.Now() - l := newKeyedLimiter(1, 1, time.Minute) - - if !l.allow("a", now) { - t.Fatal("first call for a key should pass") - } - if l.allow("a", now) { - t.Fatal("second call for the same key should be refused") - } - if !l.allow("b", now) { - t.Error("a different key must have its own bucket") - } -} - -func TestKeyedLimiterSweepsIdleBuckets(t *testing.T) { - now := time.Now() - l := newKeyedLimiter(10, 5, time.Minute) - l.allow("gone", now) - - l.sweep(now.Add(2 * time.Minute)) - - l.mu.Lock() - n := len(l.buckets) - l.mu.Unlock() - if n != 0 { - t.Errorf("%d buckets survived the sweep, want 0", n) - } -} - -func TestErrorMessagesAreUIKeysNotProse(t *testing.T) { - // Error copy lives in the frontend. A server that sent prose would put - // Vietnamese strings in two places, and an internal error string would - // leak server detail to anyone holding a socket. - m := errorMsg("room_not_found").GetError() - if strings.ContainsAny(m.GetCode(), " .") { - t.Errorf("error code %q looks like prose", m.GetCode()) - } - if m.GetMessage() != m.GetCode() { - t.Errorf("message %q diverged from code %q", m.GetMessage(), m.GetCode()) - } -} - // Fail the build, not a test, if the helper drifts from what the server needs. var _ Dictionary = (*testDict)(nil) -// TestGoroutinesReturnToBaseline guards the leak the risk table names: a bot -// worker, a room, or a session that outlives its game costs a goroutine per -// abandoned match, which only shows up under sustained play. -func TestGoroutinesReturnToBaseline(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{TurnLimit: 5 * time.Second}) - - // Warm up first: the HTTP server and the dialer start pools of their own, - // and counting those as a leak would make this test lie. - playOneBotGame(t, url) - settle() - baseline := runtime.NumGoroutine() - - for range 10 { - playOneBotGame(t, url) - } - settle() - - // A little slack for the transport's own bookkeeping; a real leak here is - // one goroutine per game, so ten games would show ten or more. - if got := runtime.NumGoroutine(); got > baseline+5 { - t.Errorf("goroutines grew from %d to %d over 10 games", baseline, got) - } -} - func playOneBotGame(t *testing.T, url string) { t.Helper() c := dial(t, url) @@ -1094,8 +367,6 @@ func settle() { runtime.GC() } -// --- the lobby ------------------------------------------------------------- - // pvpRoom seats two players and plays them into a game, so a test about what // happens next does not restate the whole handshake. func pvpRoom(t *testing.T, url string) (host, guest *testClient, start *noituv1.GameStarted) { @@ -1254,402 +525,6 @@ func resignAndSettle(t *testing.T, host, guest *testClient, start *noituv1.GameS guest.await("room_state").GetRoomState() } -// TestLobbyOpensWithNobodyReady is the room a joiner lands in: it exists, the -// owner is known, and nothing has started. -func TestLobbyOpensWithNobodyReady(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - // Set up by hand rather than through pvpLobby: this test is about the - // frames the handshake produces, and the helper consumes them. - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - - hostState := host.await("room_state").GetRoomState() - guestState := guest.await("room_state").GetRoomState() - - if !mySlot(hostState).GetIsOwner() { - t.Error("the player who created the room does not own it") - } - if mySlot(guestState).GetIsOwner() { - t.Error("the player who joined was made owner") - } - if hostState.GetCanStart() || guestState.GetCanStart() { - t.Error("a game can start with nobody ready") - } - if otherSlot(hostState) == nil || otherSlot(hostState).GetName() == "" { - t.Errorf("the owner cannot see who joined: %+v", hostState) - } - // Nothing starts on its own. Joining used to be the start signal, and a - // player who joined to look at the room found themselves on the clock. - silentFor(t, guest, "game_started", 250*time.Millisecond) -} - -// TestReadyIsRenderedPerRecipient is the guard against the two flags being -// swapped, which would show a player their opponent's readiness as their own. -func TestReadyIsRenderedPerRecipient(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - guest.setReady(true) - - guestState := guest.await("room_state").GetRoomState() - hostState := host.await("room_state").GetRoomState() - - if !mySlot(guestState).GetReady() || otherSlot(guestState).GetReady() { - t.Errorf("the guest should see only their own readiness, got %+v", guestState) - } - if mySlot(hostState).GetReady() || !otherSlot(hostState).GetReady() { - t.Errorf("the owner should see only the guest's readiness, got %+v", hostState) - } - if !hostState.GetCanStart() { - t.Error("the owner cannot start a game their guest is ready for") - } -} - -// TestOwnerHasNoReadinessOfTheirOwn: Start is the owner's readiness, and a -// second flag they would have to set first buys nothing. -func TestOwnerHasNoReadinessOfTheirOwn(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, _, _ := pvpLobby(t, url) - - host.setReady(true) - - if got := host.await("error").GetError().GetCode(); got != "owner_needs_no_ready" { - t.Errorf("the owner readying returned %q", got) - } -} - -// TestStartIsRefusedUntilTheGuestIsReady covers each way a start is not yet a -// game. -func TestStartIsRefusedUntilTheGuestIsReady(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - // Alone in the room. - host.startGame() - if got := host.await("error").GetError().GetCode(); got != "need_more_players" { - t.Errorf("starting alone returned %q, want need_more_players", got) - } - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - host.await("room_state") - guest.await("room_state") - - // Seated, but not ready. - host.startGame() - if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" { - t.Errorf("starting with an unready guest returned %q, want not_everyone_ready", got) - } - - // A guest who readies and takes it back is not ready either. - guest.setReady(true) - host.await("room_state") - guest.setReady(false) - host.await("room_state") - host.startGame() - if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" { - t.Errorf("starting after the guest unreadied returned %q", got) - } -} - -// TestOnlyTheOwnerStartsAndKicks: the guest holds a room code, and a code is -// pasted into group chats by design. -func TestOnlyTheOwnerStartsAndKicks(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - _ = host - - guest.startGame() - if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" { - t.Errorf("a guest starting the game returned %q, want not_the_owner", got) - } - guest.kickPlayer("p1") - if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" { - t.Errorf("a guest kicking returned %q, want not_the_owner", got) - } -} - -// TestNextGameNeedsAFreshReady is the whole replay flow: a finished game -// returns both players to the lobby, and the readiness that started the last -// one is spent. -func TestNextGameNeedsAFreshReady(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, first := pvpRoom(t, url) - - hostState, guestState := resignAndSettle(t, host, guest, first) - if otherSlot(hostState).GetReady() || mySlot(guestState).GetReady() { - t.Error("the readiness that started the last game survived it") - } - if hostState.GetCanStart() { - t.Error("the owner can start a game nobody has readied for") - } - - host.startGame() - if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" { - t.Errorf("starting a second game without a fresh ready returned %q", got) - } - - guest.setReady(true) - host.await("room_state") - host.startGame() - - second := host.await("game_started").GetGameStarted() - guest.await("game_started") - - if second.GetTurnSeq() <= first.GetTurnSeq() { - t.Errorf("turn_seq must keep rising across games: %d then %d, so a submission "+ - "still in flight from the first could be applied to the second", - first.GetTurnSeq(), second.GetTurnSeq()) - } - if second.GetOpeningWord() == "" { - t.Error("the second game needs its own opening word") - } -} - -// TestRoomKeepsARunningWinTally: a room outlives its games, so the score of -// the series is a room fact. It is credited before the lobby is broadcast, so -// the players see it the moment a game ends rather than one input later. -func TestRoomKeepsARunningWinTally(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, first := pvpRoom(t, url) - - // The owner resigns, so the guest takes the first game. - hostState, guestState := resignAndSettle(t, host, guest, first) - if got := mySlot(guestState).GetWins(); got != 1 { - t.Errorf("the winner's tally is %d after one game, want 1", got) - } - if got := mySlot(hostState).GetWins(); got != 0 { - t.Errorf("the loser's tally is %d, want 0", got) - } - // And each player is told the whole table, not just their own row. - if got := otherSlot(hostState).GetWins(); got != 1 { - t.Errorf("the owner sees the guest's tally as %d, want 1", got) - } - - // A second game the other way round leaves the series level. - guest.setReady(true) - host.await("room_state") - host.startGame() - host.await("game_started") - second := guest.await("game_started").GetGameStarted() - - resignFrom(t, guest, host, second) - host.await("game_over") - guest.await("game_over") - hostState = host.await("room_state").GetRoomState() - - if got := mySlot(hostState).GetWins(); got != 1 { - t.Errorf("the owner's tally is %d after winning one of two, want 1", got) - } - if got := otherSlot(hostState).GetWins(); got != 1 { - t.Errorf("the guest's tally is %d after winning one of two, want 1", got) - } -} - -// TestLobbyActionsAreRefusedDuringAGame keeps the lobby's own business out of a -// game in progress. Leaving is not part of it: a player may walk out of a game -// whether or not it is their turn, which -// TestLeavingMidGameFreesTheSeatAndLeavesTheRestPlaying covers. -func TestLobbyActionsAreRefusedDuringAGame(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpRoom(t, url) - - guest.setReady(false) - if got := guest.await("error").GetError().GetCode(); got != "game_in_progress" { - t.Errorf("unreadying mid-game returned %q, want game_in_progress", got) - } - host.kickPlayer("p2") - if got := host.await("error").GetError().GetCode(); got != "game_in_progress" { - t.Errorf("kicking mid-game returned %q, want game_in_progress", got) - } -} - -// TestLeavingNeedsAnUnreadyFirst is the friction the lobby is meant to have: a -// player the owner is waiting on has to take that back before walking away. -func TestLeavingNeedsAnUnreadyFirst(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - guest.setReady(true) - guest.await("room_state") - host.await("room_state") - - guest.leaveRoom() - if got := guest.await("error").GetError().GetCode(); got != "must_unready_first" { - t.Errorf("leaving while ready returned %q, want must_unready_first", got) - } - - guest.setReady(false) - guest.await("room_state") - host.await("room_state") - guest.leaveRoom() - - // The room survives: the owner is still in it, now on their own. - alone := host.await("room_state").GetRoomState() - if otherSlot(alone) != nil { - t.Errorf("the owner still sees a guest who left: %+v", alone) - } - if !mySlot(alone).GetIsOwner() || alone.GetCanStart() { - t.Errorf("the room the owner is left with is wrong: %+v", alone) - } -} - -// TestKickFreesAnUnreadySeatOnly: readiness is a commitment, and the owner -// does not get to overrule one. -func TestKickFreesAnUnreadySeatOnly(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, code := pvpLobby(t, url) - - guest.setReady(true) - host.await("room_state") - host.kickPlayer("p2") - if got := host.await("error").GetError().GetCode(); got != "player_is_ready" { - t.Errorf("kicking a ready guest returned %q, want player_is_ready", got) - } - - guest.setReady(false) - host.await("room_state") - host.kickPlayer("p2") - - if got := guest.await("error").GetError().GetCode(); got != "kicked" { - t.Errorf("the kicked player was told %q", got) - } - if got := host.await("room_state").GetRoomState(); otherSlot(got) != nil { - t.Errorf("the kicked seat is still occupied: %+v", got) - } - - // The seat is free, and a kick is not a ban. - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - if got := guest.await("room_state").GetRoomState(); otherSlot(got) == nil { - t.Errorf("a kicked player could not come back: %+v", got) - } -} - -// TestOwnerLeavingPromotesTheOtherPlayer: the role outlives the player who -// held it, or the room would be one nobody can start. -func TestOwnerLeavingPromotesTheOtherPlayer(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, code := pvpLobby(t, url) - - host.leaveRoom() - - promoted := guest.await("room_state").GetRoomState() - if !mySlot(promoted).GetIsOwner() { - t.Errorf("the player left behind was not promoted: %+v", promoted) - } - if otherSlot(promoted) != nil { - t.Errorf("the owner who left is still shown as present: %+v", promoted) - } - - // And the promotion is real: the new owner can start a game with the next - // person to walk in. - third := dial(t, url) - third.hello("Người mới") - third.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - guest.await("room_state") - third.await("room_state") - third.setReady(true) - guest.await("room_state") - guest.startGame() - - guest.await("game_started") - third.await("game_started") -} - -// TestPromotedOwnerLosesTheirReadiness: their readiness is Start now, and a -// flag left set from being a guest would mean nothing. -func TestPromotedOwnerLosesTheirReadiness(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - guest.setReady(true) - host.await("room_state") - // Drained on both sides, so the state read below is the promotion and not - // the readiness that preceded it. - guest.await("room_state") - host.leaveRoom() - - promoted := guest.await("room_state").GetRoomState() - if mySlot(promoted).GetReady() { - t.Errorf("the promoted owner is still carrying a guest's readiness: %+v", promoted) - } -} - -// TestLastPlayerOutClosesTheRoom bounds the code and the goroutine: nothing is -// coming that could fill a room whose code the hub is about to forget. -func TestLastPlayerOutClosesTheRoom(t *testing.T) { - api, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - guest.leaveRoom() - host.await("room_state") - host.leaveRoom() - - awaitNoRooms(t, api, "a room nobody is in") -} - -// TestIdleLobbyCloses bounds a room nobody starts a game in. One open tab -// would otherwise hold a code and a goroutine for the life of the process. -func TestIdleLobbyCloses(t *testing.T) { - api, url := newTestServer(t, chainDict(), Config{IdleFor: 150 * time.Millisecond}) - - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - host.await("room_state") - - if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" { - t.Errorf("an idle room closed with %q", got) - } - awaitNoRooms(t, api, "an idle room") -} - -// TestBotRoomHasNoLobby: a bot room is its game. Keeping it open would leave -// one goroutine and one engine per finished bot game. -func TestBotRoomHasNoLobby(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - c := dial(t, url) - c.hello("Người chơi") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ - StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, - }}) - c.await("game_started") - - c.resign() - c.await("game_over") - - // not_in_a_room is the session reporting that the room has gone: the - // goroutine and engine are released rather than parked in a lobby no bot - // can ready for. - c.setReady(true) - if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" { - t.Errorf("a finished bot room answered %q, want it to be gone", got) - } -} - -// --- chat ------------------------------------------------------------------- - // resumeAs reconnects with a live token and returns the new connection, having // consumed its Welcome. Reading a room's conversation back is what it is for: // a seat's own resume is the only replay that carries the whole window. @@ -1666,469 +541,6 @@ func resumeAs(t *testing.T, url, nickname, token string) *testClient { return c } -// TestChatReachesBothSeatsRenderedPerRecipient is the guard against from_me -// being computed once and shared, which would show a player their opponent's -// words as their own. -func TestChatReachesBothSeatsRenderedPerRecipient(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - host.say("Chào bạn") - - mine := host.await("chat_message").GetChatMessage() - theirs := guest.await("chat_message").GetChatMessage() - - if !mine.GetFromMe() { - t.Errorf("the sender was not shown their own message as theirs: %+v", mine) - } - if theirs.GetFromMe() { - t.Errorf("the recipient was shown the sender's message as their own: %+v", theirs) - } - if theirs.GetText() != "Chào bạn" || theirs.GetAuthor() == "" { - t.Errorf("the recipient's copy is wrong: %+v", theirs) - } - if theirs.GetSentUnixMs() == 0 { - t.Error("a message carries no time") - } - // The seat behind a line is the same fact for everybody: from_me is the - // only field that is relative to the reader, and a client colours a line - // by its author rather than by matching names. - if mine.GetPlayerId() != theirs.GetPlayerId() || theirs.GetPlayerId() == "" { - t.Errorf("the author's seat differs between recipients: %q and %q", - mine.GetPlayerId(), theirs.GetPlayerId()) - } -} - -// TestChatWorksInTheLobbyAndInAGame: the conversation belongs to the room, not -// to a game, so neither phase is a special case. -func TestChatWorksInTheLobbyAndInAGame(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - host.say("trước ván") - if got := guest.await("chat_message").GetChatMessage().GetText(); got != "trước ván" { - t.Errorf("lobby message = %q", got) - } - - // Started inline rather than through readyAndStart: pvpLobby has already - // drained the room states that helper waits for. - guest.setReady(true) - host.await("room_state") - host.startGame() - host.await("game_started") - guest.await("game_started") - - host.say("trong ván") - if got := guest.await("chat_message").GetChatMessage().GetText(); got != "trong ván" { - t.Errorf("in-game message = %q", got) - } -} - -// TestAJoinerSeesNothingSaidBeforeTheySatDown is the disclosure boundary. A -// room code is pasted into group chats by design, so redeeming one must not -// hand over a conversation the joiner was never part of. -func TestAJoinerSeesNothingSaidBeforeTheySatDown(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - host.say("bí mật") - host.await("chat_message") - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - - if got := guest.await("chat_history").GetChatHistory().GetMessages(); len(got) != 0 { - t.Errorf("a joiner was handed %d messages from before they arrived: %+v", len(got), got) - } - - // And from there they share a conversation like anybody else. - host.say("chào") - if got := guest.await("chat_message").GetChatMessage().GetText(); got != "chào" { - t.Errorf("message after joining = %q", got) - } -} - -// TestCreatingARoomReplaysAnEmptyHistory: it is what overwrites the panel a -// client may still be holding from the room it was in before this one. -func TestCreatingARoomReplaysAnEmptyHistory(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - c := dial(t, url) - c.hello("Chủ phòng") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - - if got := c.await("chat_history").GetChatHistory().GetMessages(); len(got) != 0 { - t.Errorf("a new room came with %d messages", len(got)) - } -} - -// TestChatHistoryIsReplayedOnResumeInTheLobby is the commonest refresh there -// is, and the one a replay hung off the end of handleResume would miss: that -// function returns early for a lobby resume. -func TestChatHistoryIsReplayedOnResumeInTheLobby(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - host := dial(t, url) - welcome := host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - host.await("room_state") - guest.await("room_state") - - host.say("một") - host.await("chat_message") - guest.say("hai") - host.await("chat_message") - - // The tab reloads: same token, new socket, still in the lobby. - _ = host.conn.Close(websocket.StatusAbnormalClosure, "") - back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken()) - - history := back.await("chat_history").GetChatHistory().GetMessages() - if len(history) != 2 { - t.Fatalf("the resumed lobby replayed %d messages, want 2: %+v", len(history), history) - } - if history[0].GetText() != "một" || history[1].GetText() != "hai" { - t.Errorf("history is out of order: %+v", history) - } - if !history[0].GetFromMe() || history[1].GetFromMe() { - t.Errorf("authorship did not survive the resume: %+v", history) - } -} - -// TestChatHistoryIsReplayedOnResumeMidGame covers the same during a game, -// where handleResume takes its other path. -func TestChatHistoryIsReplayedOnResumeMidGame(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - host := dial(t, url) - welcome := host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - readyAndStart(t, host, guest) - host.await("game_started") - guest.await("game_started") - - host.say("đang chơi") - host.await("chat_message") - - _ = host.conn.Close(websocket.StatusAbnormalClosure, "") - back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken()) - - if got := back.await("chat_history").GetChatHistory().GetMessages(); len(got) != 1 { - t.Errorf("a mid-game resume replayed %d messages, want 1", len(got)) - } -} - -// TestChatHistoryIsCappedAndOrdered bounds what one room holds. Driven at the -// room directly: the cap is room state, and a socket would only add a rate -// limiter to wait out. -func TestChatHistoryIsCappedAndOrdered(t *testing.T) { - sess := offlineSession(t, chatHistoryLimit+64) - r := &room{ - code: "TESTRM", - seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: sess}}, - owner: "p1", - } - - const sent = chatHistoryLimit + 5 - for i := range sent { - r.handleChat(chatInput{sess: sess, player: "p1", text: fmt.Sprintf("tin %d", i)}) - } - - if len(r.chat) != chatHistoryLimit { - t.Fatalf("the room kept %d messages, want %d", len(r.chat), chatHistoryLimit) - } - if got, want := r.chat[0].text, fmt.Sprintf("tin %d", sent-chatHistoryLimit); got != want { - t.Errorf("oldest kept message = %q, want %q", got, want) - } - if got, want := r.chat[len(r.chat)-1].text, fmt.Sprintf("tin %d", sent-1); got != want { - t.Errorf("newest kept message = %q, want %q", got, want) - } - // The sequence keeps rising past the trim, which is what makes a seat's - // watermark meaningful after the entry it pointed at is gone. - if r.chatSeq != sent { - t.Errorf("chatSeq = %d after %d messages", r.chatSeq, sent) - } -} - -// TestChatHistorySurvivesAGame: the conversation is the room's, and a game -// starting and finishing inside it changes nothing about that. -func TestChatHistorySurvivesAGame(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - host := dial(t, url) - welcome := host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - host.await("room_state") - guest.await("room_state") - - host.say("trước ván") - host.await("chat_message") - - guest.setReady(true) - host.await("room_state") - host.startGame() - start := host.await("game_started").GetGameStarted() - guest.await("game_started") - resignAndSettle(t, host, guest, start) - - _ = host.conn.Close(websocket.StatusAbnormalClosure, "") - back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken()) - - if got := back.await("chat_history").GetChatHistory().GetMessages(); len(got) != 1 { - t.Errorf("a finished game left %d messages, want the 1 said before it", len(got)) - } -} - -// TestChatTextIsSanitizedAndCapped covers the three ways text is made safe to -// render in a stranger's browser. -func TestChatTextIsSanitizedAndCapped(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - // Invisible characters: a zero-width joiner and a bidi override. - host.say("xin" + string(zeroWidthSpace) + "chào" + string(bidiOverride)) - if got := guest.await("chat_message").GetChatMessage().GetText(); got != "xinchào" { - t.Errorf("invisible characters survived: %q", got) - } - - // A stack of combining marks renders as a glyph cluster tall enough to - // cover the board, and the rune cap alone does not stop it. - host.say("a" + strings.Repeat("\u0350", 199)) - stacked := guest.await("chat_message").GetChatMessage().GetText() - if marks := strings.Count(stacked, "\u0350"); marks > maxChatMarks { - t.Errorf("a %d-mark stack survived, want at most %d", marks, maxChatMarks) - } - - // Over the cap, cut on a rune boundary rather than through a character. - host.say(strings.Repeat("ữ", maxChatRunes+100)) - long := guest.await("chat_message").GetChatMessage().GetText() - if runes := []rune(long); len(runes) != maxChatRunes { - t.Errorf("a long message came back %d runes, want %d", len(runes), maxChatRunes) - } - if !utf8.ValidString(long) { - t.Error("the cap cut a character in half") - } -} - -// TestEmptyChatIsDroppedWithoutAnError: nothing survived the sanitizer, so -// there is no message to refuse and nobody who typed one. -func TestEmptyChatIsDroppedWithoutAnError(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - // Sent first, then a real one. Messages arrive in order, so a guest whose - // next message is the real one never received the empty one — and unlike - // silentFor, this leaves the connection alive to prove it. - host.say(" " + string(zeroWidthSpace) + " ") - host.say("thật") - - if got := guest.await("chat_message").GetChatMessage().GetText(); got != "thật" { - t.Errorf("the empty message was delivered as %q", got) - } - // And nothing follows it. - silentFor(t, guest, "chat_message", 250*time.Millisecond) -} - -// TestChatIsRateLimitedOnItsOwnBudget: a burst is refused, and it does not -// cost the sender their moves. -func TestChatIsRateLimitedOnItsOwnBudget(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, start := pvpRoom(t, url) - - // The player on turn does the talking, so the move that follows is one - // they are allowed to make. - mover, other := host, guest - if !start.GetMyTurn() { - mover, other = guest, host - } - - for range chatBurst + 5 { - mover.say("spam") - } - - var refused bool - for range 40 { - if payloadCase(mover.recv()) == "error" { - refused = true - break - } - } - if !refused { - t.Fatal("a burst of chat was never refused") - } - - // The move budget is separate, so a word still reaches the engine: the - // opponent seeing the turn arrive is the proof it was accepted. - mover.submit("b c", start.GetTurnSeq()) - awaitMyTurn(t, other) -} - -// TestChatFromASeatlessConnectionIsRefused: losing the seat is not losing the -// socket. A kicked player's connection is still open and still believes it was -// in a room, which is the reachable half of the guard that also covers a -// connection replaced by a reconnect. -func TestChatFromASeatlessConnectionIsRefused(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - host.kickPlayer("p2") - if got := guest.await("error").GetError().GetCode(); got != "kicked" { - t.Fatalf("the guest was told %q rather than being kicked", got) - } - - guest.say("cho tôi vào lại") - if got := guest.await("error").GetError().GetCode(); got != "not_in_a_room" { - t.Errorf("a kicked connection chatting returned %q, want not_in_a_room", got) - } - silentFor(t, host, "chat_message", 250*time.Millisecond) -} - -// TestChatNeedsASeat: holding a socket is not holding a seat. -func TestChatNeedsASeat(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - c := dial(t, url) - c.hello("Người chơi") - c.say("có ai không") - - if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" { - t.Errorf("chatting from no room returned %q, want not_in_a_room", got) - } -} - -// TestBotRoomHasNoChat: there is nobody to talk to, and the check belongs on -// the room goroutine, which is the only place that knows. -func TestBotRoomHasNoChat(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - c := dial(t, url) - c.hello("Người chơi") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ - StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, - }}) - c.await("game_started") - - c.say("chào máy") - if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" { - t.Errorf("chatting at a bot returned %q, want not_in_a_room", got) - } -} - -// TestChatDoesNotKeepARoomAlive: talking is not playing. Without this a room -// is held open for the life of the process by one message every nine minutes. -func TestChatDoesNotKeepARoomAlive(t *testing.T) { - api, url := newTestServer(t, chainDict(), Config{IdleFor: 300 * time.Millisecond}) - - host := dial(t, url) - host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - host.await("room_state") - - // Chatting throughout the window; the clock must keep running anyway. - for range 4 { - host.say("vẫn ở đây") - time.Sleep(100 * time.Millisecond) - } - - if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" { - t.Errorf("a chatted-in room closed with %q, want room_idle_closed", got) - } - awaitNoRooms(t, api, "a room held open by chat") -} - -// TestVacatedSeatKeepsItsWordsButLosesItsAuthor: the words stay, the -// attribution does not — checked from the side that stayed, because a name -// left behind is a name the next joiner can ask for. -func TestVacatedSeatKeepsItsWordsButLosesItsAuthor(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - host := dial(t, url) - welcome := host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - host.await("room_state") - guest.await("room_state") - - guest.say("tôi là khách") - host.await("chat_message") - guest.leaveRoom() - host.await("room_state") - - // The player who stayed reloads and reads the room back. - _ = host.conn.Close(websocket.StatusAbnormalClosure, "") - back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken()) - - history := back.await("chat_history").GetChatHistory().GetMessages() - if len(history) != 1 { - t.Fatalf("the departed player's words are gone: %+v", history) - } - if got := history[0]; got.GetText() != "tôi là khách" || got.GetAuthor() != "" || got.GetFromMe() { - t.Errorf("a vacated seat's message is still attributed: %+v", got) - } - // The seat goes with the name. A line still carrying it would be coloured - // as whoever fills that seat next. - if got := history[0].GetPlayerId(); got != "" { - t.Errorf("a vacated seat's message still names its seat: %q", got) - } -} - -// TestTheRemainingPlayerIsResyncedWhenASeatIsVacated is the live half of the -// authorship rule. Clearing the store is not enough: the player who stayed is -// already holding frames that carry the departed name, and nothing else in the -// protocol would correct them before a reload. -func TestTheRemainingPlayerIsResyncedWhenASeatIsVacated(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, _ := pvpLobby(t, url) - - guest.say("số của tôi là …") - host.await("chat_message") - - guest.leaveRoom() - - // No reload, no resume: the correction has to arrive on its own. - history := host.await("chat_history").GetChatHistory().GetMessages() - if len(history) != 1 { - t.Fatalf("the remaining player was resynced with %d messages, want 1", len(history)) - } - if got := history[0]; got.GetAuthor() != "" || got.GetText() != "số của tôi là …" { - t.Errorf("the departed player's line is still attributed: %+v", got) - } -} - // offlineSession builds a session with no websocket behind it, for the two // guarantees that live below the transport: who may speak for a seat, and what // a full outbox costs the player behind it. @@ -2176,78 +588,6 @@ func queued(t *testing.T, s *session) []*noituv1.ServerMessage { } } -// TestChatFromAConnectionThatLostItsSeatIsRefused covers handleChat's own -// authorization, which the transport cannot reach: a frame already queued when -// the seat was freed arrives after it, and by then the seat may be somebody -// else's. Driven directly because that ordering is a race over the wire. -func TestChatFromAConnectionThatLostItsSeatIsRefused(t *testing.T) { - evicted := offlineSession(t, 8) - seated := offlineSession(t, 8) - - r := &room{ - code: "TESTRM", - seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: evicted}}, - owner: "p1", - } - - // The seat changes hands while this connection's message is in flight — - // a reconnect, or a kick and a new arrival. - r.seats[0].sess = seated - r.handleChat(chatInput{sess: evicted, player: "p1", text: "tôi vẫn ở đây"}) - - refusals := queued(t, evicted) - if len(refusals) != 1 || refusals[0].GetError().GetCode() != "not_your_seat" { - t.Fatalf("a connection with no seat was answered %+v, want not_your_seat", refusals) - } - if len(r.chat) != 0 { - t.Errorf("the room stored a message from a connection that holds no seat: %+v", r.chat) - } - if got := queued(t, seated); len(got) != 0 { - t.Errorf("the seated player was sent %d frames from an impostor", len(got)) - } -} - -// TestChatToAFullOutboxIsDroppedNotFatal is the guarantee the chat budget -// rests on: a player who cannot keep up loses a line, not their session. Every -// other frame closes a session at this point, which mid-game costs the game. -// -// Driven through handleChat rather than trySend directly, because the property -// under test is which delivery the chat path chooses — a test that called -// trySend itself would pass just as happily after somebody swapped the call -// site back to send. -func TestChatToAFullOutboxIsDroppedNotFatal(t *testing.T) { - sender := offlineSession(t, 8) - slow := offlineSession(t, 1) - slow.out <- []byte("already queued") - - r := &room{ - code: "TESTRM", - seats: [maxPlayers]*seat{ - {id: "p1", nickname: "Chủ phòng", sess: sender}, - {id: "p2", nickname: "Khách", sess: slow}, - }, - owner: "p1", - } - - r.handleChat(chatInput{sess: sender, player: "p1", text: "bạn còn đó không"}) - - select { - case <-slow.ctx.Done(): - t.Fatal("a chat line closed the session of the player who could not keep up") - default: - } - if len(r.chat) != 1 { - t.Errorf("the room stored %d messages, want 1", len(r.chat)) - } - // The sender is unaffected: their own copy went out and nothing was - // refused. - for _, m := range queued(t, sender) { - if m.GetError() != nil { - t.Errorf("the sender was refused: %q", m.GetError().GetCode()) - } - } -} - // awaitNoRooms waits for the hub to forget every room it holds. func awaitNoRooms(t *testing.T, api *Server, what string) { t.Helper() @@ -2267,268 +607,3 @@ func awaitNoRooms(t *testing.T, api *Server, what string) { time.Sleep(20 * time.Millisecond) } } - -// TestResumeReclaimsALobbySeat: a room outlives its games now, so a player who -// refreshes between them has a lobby to come back to rather than a refusal. -func TestResumeReclaimsALobbySeat(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - - host := dial(t, url) - welcome := host.hello("Chủ phòng") - host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - code := host.await("room_state").GetRoomState().GetRoomCode() - - guest := dial(t, url) - guest.hello("Khách") - guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ - JoinRoom: &noituv1.JoinRoom{RoomCode: code}, - }}) - host.await("room_state") - guest.await("room_state") - - // The owner's tab reloads: same token, new socket. - _ = host.conn.Close(websocket.StatusAbnormalClosure, "") - guest.await("room_state") - - second := dial(t, url) - second.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ - ProtocolVersion: ProtocolVersion, - Nickname: "Chủ phòng", - ResumeToken: welcome.GetResumeToken(), - }}}) - second.await("welcome") - - back := second.await("room_state").GetRoomState() - if !mySlot(back).GetIsOwner() { - t.Errorf("the owner came back as a guest: %+v", back) - } - if otherSlot(back) == nil || back.GetRoomCode() != code { - t.Errorf("the resumed lobby is not the one they left: %+v", back) - } - - // And the room still works from both sides. - guest.setReady(true) - second.await("room_state") - second.startGame() - second.await("game_started") - guest.await("game_started") -} - -// TestOneConnectionCannotStrandRooms is the regression for rooms that outlived -// the only connection that could ever end them. attach overwrote the session's -// room pointer and nothing told the old room, so it parked in select forever -// holding a goroutine and a room code. -func TestOneConnectionCannotStrandRooms(t *testing.T) { - api, url := newTestServer(t, chainDict(), Config{}) - - c := dial(t, url) - c.hello("Người chơi") - - const rooms = 4 - for range rooms { - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) - c.await("room_state") - } - - _ = c.conn.Close(websocket.StatusNormalClosure, "") - - deadline := time.Now().Add(5 * time.Second) - for { - api.hub.mu.Lock() - left := len(api.hub.rooms) - api.hub.mu.Unlock() - - if left == 0 { - return - } - if time.Now().After(deadline) { - t.Fatalf("%d of %d rooms outlived the only connection that was ever in them", left, rooms) - } - time.Sleep(20 * time.Millisecond) - } -} - -// TestLobbyActionsAreRateLimited: every accepted action is broadcast to both -// seats, so an unbounded one lets a player fill the opponent's outbox until -// the server closes their session for falling behind. -func TestLobbyActionsAreRateLimited(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - _, guest, _ := pvpLobby(t, url) - - for i := range submitBurst + 5 { - guest.setReady(i%2 == 0) - } - - // The limiter answers before the room does, so a refusal has to appear in - // the stream rather than an unbroken run of room states. - for range 30 { - if payloadCase(guest.recv()) == "error" { - return - } - } - t.Error("a burst of lobby actions was never refused") -} - -// --- dead-end claims --------------------------------------------------- - -// TestClaimDeadEndEliminatesImmediately walks a player into a real dead end -// and has them claim it rather than wait out the clock. The outcome must be -// exactly what a timeout would have produced: NO_LEGAL_MOVE, no answerable -// suggestions. -func TestClaimDeadEndEliminatesImmediately(t *testing.T) { - // "b" starts nothing after "b c" is played, so whoever inherits "c" has no - // move at all. - _, url := newTestServer(t, newTestDict("a b", "b c"), Config{}) - host, guest, start := pvpRoom(t, url) - lead, stuck := host, guest - if !start.GetMyTurn() { - lead, stuck = guest, host - } - - lead.submit("b c", start.GetTurnSeq()) - lead.await("turn_update") - turn := stuck.await("turn_update").GetTurnUpdate() - if turn.GetCurrentSyllable() != "c" { - t.Fatalf("current syllable = %q, want %q", turn.GetCurrentSyllable(), "c") - } - if !turn.GetMyTurn() { - t.Fatal("the player left with the dead end should be on turn") - } - - stuck.claimDeadEnd() - - out := stuck.await("player_eliminated").GetPlayerEliminated() - if !out.GetIsMe() { - t.Error("the claimant should be the one eliminated") - } - if out.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE { - t.Errorf("reason = %v, want NO_LEGAL_MOVE", out.GetReason()) - } - if len(out.GetSuggestions()) != 0 { - t.Errorf("suggestions = %v, want none for a genuine dead end", out.GetSuggestions()) - } - - stuck.await("game_over") - lead.await("game_over") -} - -// TestClaimDeadEndRefusedWhenAMoveExists checks the false claim costs nothing -// but the answer: the clock is untouched, proven by the original turn_seq -// still being accepted afterwards. -func TestClaimDeadEndRefusedWhenAMoveExists(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, start := pvpRoom(t, url) - lead := host - if !start.GetMyTurn() { - lead = guest - } - - lead.claimDeadEnd() - if code := lead.await("error").GetError().GetCode(); code != "not_a_dead_end" { - t.Errorf("code = %q, want not_a_dead_end", code) - } - - // The turn_seq the claim was answered on is still the current one: a - // submission stamped with it is still accepted rather than refused as - // stale. - lead.submit("b c", start.GetTurnSeq()) - lead.await("turn_update") -} - -// TestClaimDeadEndOutOfTurnRefused: only the player to act may spend a claim, -// exactly as only they may spend a resignation. -func TestClaimDeadEndOutOfTurnRefused(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - host, guest, start := pvpRoom(t, url) - waits := guest - if !start.GetMyTurn() { - waits = host - } - - waits.claimDeadEnd() - if code := waits.await("error").GetError().GetCode(); code != "not_your_turn" { - t.Errorf("code = %q, want not_your_turn", code) - } -} - -// --- near-miss suggestions ---------------------------------------------- - -// TestNearMissSuggestionOnWire is the end-to-end proof that a diacritic typo -// carries a suggestion: the dictionary layer is unit-tested on its own, but -// only this shows the room actually wires MoveRejected.suggestion up. -func TestNearMissSuggestionOnWire(t *testing.T) { - _, url := newTestServer(t, newTestDict("ngôn ngữ", "ngữ pháp"), Config{}) - c := dial(t, url) - c.hello("Người chơi") - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ - StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, - }}) - started := c.await("game_started").GetGameStarted() - - // Typed with no diacritics at all, which the dictionary does not know as a - // word but which strips to exactly one real one. - c.submit("ngu phap", started.GetTurnSeq()) - - rejected := c.await("move_rejected").GetMoveRejected() - if rejected.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY { - t.Fatalf("reason = %v, want NOT_IN_DICTIONARY", rejected.GetReason()) - } - if rejected.GetSuggestion() != "ngữ pháp" { - t.Errorf("suggestion = %q, want %q", rejected.GetSuggestion(), "ngữ pháp") - } -} - -// --- word reports --------------------------------------------------------- - -// TestReportWordAcceptsAndEchoes needs no room at all: filing a report is a -// session-level fact, which is also what a direct wire client per the README -// would exercise. -func TestReportWordAcceptsAndEchoes(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Người chơi") - - c.reportWord("bình tâm") - got := c.await("word_reported").GetWordReported() - if got.GetWord() != "bình tâm" { - t.Errorf("echoed word = %q, want %q", got.GetWord(), "bình tâm") - } -} - -func TestReportWordRefusesOneSyllable(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{}) - c := dial(t, url) - c.hello("Người chơi") - - c.reportWord("một") - if code := c.await("error").GetError().GetCode(); code != "word_report_refused" { - t.Errorf("code = %q, want word_report_refused", code) - } -} - -// TestReportWordEnforcesPerSessionCap drives the cap directly against the -// session rather than through a real socket: 21 reports through the chat rate -// limiter (chatBurst=5) would be a test of two budgets fighting each other -// rather than of the cap itself. -func TestReportWordEnforcesPerSessionCap(t *testing.T) { - s := offlineSession(t, maxWordReportsPerSession+8) - - for i := range maxWordReportsPerSession { - s.handleReportWord(&noituv1.ReportWord{Word: fmt.Sprintf("từ số %d", i)}) - } - accepted := queued(t, s) - if len(accepted) != maxWordReportsPerSession { - t.Fatalf("got %d replies for %d distinct reports, want one each", len(accepted), maxWordReportsPerSession) - } - for _, m := range accepted { - if m.GetWordReported() == nil { - t.Errorf("a report inside the cap was refused: %+v", m) - } - } - - s.handleReportWord(&noituv1.ReportWord{Word: "một từ khác nữa"}) - overflow := queued(t, s) - if len(overflow) != 1 || overflow[0].GetError().GetCode() != "word_report_limit" { - t.Fatalf("the report past the cap = %+v, want a single word_report_limit error", overflow) - } -}