From 5935f5c879f729c2a2422164770403f0d610cf6d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 5 Sep 2026 12:07:57 +0700 Subject: [PATCH] feat(wsapi): add websocket server with rooms, turn timers, and bot play One goroutine owns each room and its engine. The room goroutine starts before anyone is seated and seating is itself a message, so reading run() is a complete proof of the concurrency contract rather than a convention to uphold. The bot searches a frozen copy of the board instead of the live engine. Reads carry no deadline; liveness is ping-based, because a read timeout cannot distinguish a healthy player idling in the lobby from a dead socket. The hub no longer binds a joiner to a seat before the room decides whether to seat them. Anyone holding a room code could previously resign or play on a seated player's behalf, and the room code is the only credential online 1v1 has. cmd/noitu-server serves the API and, when NOITU_WEB_DIR is set, the built frontend, with unknown paths falling back to index.html for client routes. All configuration is environment-only and every variable has a working default. --- README.md | 36 + .../phase-05-go-websocket-server-and-rooms.md | 101 +- plans/260904-1125-noi-tu-web-game/plan.md | 30 +- server/cmd/noitu-server/main.go | 156 +++ server/go.mod | 1 + server/go.sum | 2 + server/internal/wsapi/codec.go | 99 ++ server/internal/wsapi/hub.go | 241 +++++ server/internal/wsapi/nickname.go | 81 ++ server/internal/wsapi/ratelimit.go | 99 ++ server/internal/wsapi/regression_test.go | 378 ++++++++ server/internal/wsapi/room.go | 722 ++++++++++++++ server/internal/wsapi/server.go | 162 ++++ server/internal/wsapi/session.go | 487 ++++++++++ server/internal/wsapi/wsapi_test.go | 913 ++++++++++++++++++ 15 files changed, 3486 insertions(+), 22 deletions(-) create mode 100644 server/cmd/noitu-server/main.go create mode 100644 server/internal/wsapi/codec.go create mode 100644 server/internal/wsapi/hub.go create mode 100644 server/internal/wsapi/nickname.go create mode 100644 server/internal/wsapi/ratelimit.go create mode 100644 server/internal/wsapi/regression_test.go create mode 100644 server/internal/wsapi/room.go create mode 100644 server/internal/wsapi/server.go create mode 100644 server/internal/wsapi/session.go create mode 100644 server/internal/wsapi/wsapi_test.go diff --git a/README.md b/README.md index 5026b2d..028ae69 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,42 @@ make run # build and start the server `make fetch-dict` is a one-time cost per machine. Neither database file is committed; both are build artifacts. See [`data/ATTRIBUTION.md`](./data/ATTRIBUTION.md). +## Running the server + +```sh +make dict # once, after fetch-dict +make run # builds and starts on :8080 +``` + +Configuration is environment-only; every variable has a working default. + +| Variable | Default | Meaning | +|---|---|---| +| `NOITU_ADDR` | `:8080` | Listen address | +| `NOITU_DB_PATH` | `data/noitu.db` | Derived dictionary, loaded read-only at startup | +| `NOITU_TURN_LIMIT` | `20s` | Turn deadline, identical for bot and PvP games | +| `NOITU_GRACE` | `30s` | How long a disconnected player's seat is held for a reconnect | +| `NOITU_ALLOWED_ORIGINS` | *(unset)* | Comma-separated origin allowlist. Unset means same-origin only | +| `NOITU_WEB_DIR` | *(unset)* | Built frontend to serve. Unset serves the API alone | + +An invalid duration is logged and ignored rather than silently changing the +rules of the game. + +Endpoints: `GET /ws` (Protobuf over binary WebSocket frames), `GET /healthz`, +and — when `NOITU_WEB_DIR` is set — the frontend on everything else, with +unknown paths falling back to `index.html` because deep links are client routes. + +### Smoke-testing without a frontend + +The whole game is playable over a raw WebSocket client. Frames are binary +protobuf, so a text tool like `websocat` cannot compose them by hand; the +practical path is a short Go client importing `server/gen/noitu/v1`, which is +exactly what `server/internal/wsapi` tests do in-process. + +Send `Hello{protocol_version: 1, nickname: "..."}` first — every other message +is refused until the handshake completes — then `StartBotGame` and reply to each +`TurnUpdate` with a `SubmitWord` carrying the `turn_seq` you were given. + ## Make targets | Target | Does | diff --git a/plans/260904-1125-noi-tu-web-game/phase-05-go-websocket-server-and-rooms.md b/plans/260904-1125-noi-tu-web-game/phase-05-go-websocket-server-and-rooms.md index d348d3b..668c709 100644 --- a/plans/260904-1125-noi-tu-web-game/phase-05-go-websocket-server-and-rooms.md +++ b/plans/260904-1125-noi-tu-web-game/phase-05-go-websocket-server-and-rooms.md @@ -1,6 +1,6 @@ --- title: "Phase 5: Go WebSocket Server and Rooms" -status: todo +status: done phase: 5 priority: P1 effort: "4d" @@ -19,19 +19,19 @@ the bot wired in as a virtual player. After this phase the whole game is playabl ## Requirements **Functional** -- [ ] `GET /ws` upgrades and runs the session loop; `GET /healthz` for liveness -- [ ] Bot rooms (1 human + bot) and PvP rooms (2 humans, joined by 6-char room code) -- [ ] Nicknames sanitized server-side and echoed back; opponents only ever see sanitized values -- [ ] Server-authoritative 20s turn timer (`NOITU_TURN_LIMIT`); expiry ends the game for the player on turn -- [ ] Every accepted move fans out a per-recipient `TurnUpdate` -- [ ] Bot moves scheduled on the room goroutine after a randomized thinking delay -- [ ] Session resume token issued in `Welcome`, honoured on reconnect within a grace window -- [ ] Serves the built frontend as static files so one binary is the whole deployment +- [x] `GET /ws` upgrades and runs the session loop; `GET /healthz` for liveness +- [x] Bot rooms (1 human + bot) and PvP rooms (2 humans, joined by 6-char room code) +- [x] Nicknames sanitized server-side and echoed back; opponents only ever see sanitized values +- [x] Server-authoritative 20s turn timer (`NOITU_TURN_LIMIT`); expiry ends the game for the player on turn +- [x] Every accepted move fans out a per-recipient `TurnUpdate` +- [x] Bot moves scheduled on the room goroutine after a randomized thinking delay +- [x] Session resume token issued in `Welcome`, honoured on reconnect within a grace window +- [x] Serves the built frontend as static files so one binary is the whole deployment **Non-functional** -- [ ] One goroutine owns each room's engine — no shared mutable game state (`-race` clean) -- [ ] Per-connection read limit, read/write deadlines, and rate limiting on `SubmitWord` -- [ ] Graceful shutdown drains rooms and tells clients why +- [x] One goroutine owns each room's engine — no shared mutable game state (`-race` clean) +- [x] Per-connection read limit, read/write deadlines, and rate limiting on `SubmitWord` +- [x] Graceful shutdown drains rooms and tells clients why ## Architecture @@ -122,15 +122,15 @@ open for `graceMs` (default 30s) and sends the opponent `OpponentLeft{can_reconn ## Success Criteria -- [ ] `go test ./internal/wsapi/... -race` green -- [ ] A full bot game is playable end to end over a raw WS client, no frontend involved -- [ ] Two in-process clients complete a PvP game with correct alternating turns and a correct winner -- [ ] Turn timeout ends the game server-side even if the client sends nothing -- [ ] Disconnect + resume within 30s restores the game; after 30s the opponent wins by `END_OPPONENT_LEFT` -- [ ] Read limit, rate limit, and origin check each covered by a test -- [ ] `sanitizeNickname` tested against: over-length, control chars, zero-width chars, whitespace-only, and empty input; opponents never receive an unsanitized string -- [ ] `go build ./cmd/noitu-server` yields one binary that serves both `/ws` and the static frontend -- [ ] Graceful shutdown notifies connected clients rather than dropping sockets silently +- [x] `go test ./internal/wsapi/... -race` green +- [x] A full bot game is playable end to end over a raw WS client, no frontend involved +- [x] Two in-process clients complete a PvP game with correct alternating turns and a correct winner +- [x] Turn timeout ends the game server-side even if the client sends nothing +- [x] Disconnect + resume within 30s restores the game; after 30s the opponent wins by `END_OPPONENT_LEFT` +- [x] Read limit, rate limit, and origin check each covered by a test +- [x] `sanitizeNickname` tested against: over-length, control chars, zero-width chars, whitespace-only, and empty input; opponents never receive an unsanitized string +- [x] `go build ./cmd/noitu-server` yields one binary that serves both `/ws` and the static frontend +- [x] Graceful shutdown notifies connected clients rather than dropping sockets silently ## Risk Assessment @@ -143,3 +143,62 @@ open for `graceMs` (default 30s) and sends the opponent `OpponentLeft{can_reconn | Restart drops all live games | Players lose in-progress matches on deploy | Accepted for v1 (documented in `plan.md`); graceful shutdown sends a typed error so the UI can explain it | | Offensive nicknames shown to strangers | Abuse reports | Sanitization caps length and strips control/zero-width characters; a denylist hook is already in `nickname.go` for a data-only fix if it becomes a real problem | | Reconnect replay diverges from actual state | Resumed client shows a stale board | Replay is a fresh snapshot from the engine, never a stored copy of past messages | + + +## Phase 5 Outcome (2026-09-05) + +Delivered. `go vet` and `go test ./... -race` green across every package; the whole game +is playable over a raw WebSocket with no frontend, and the binary was run against a +fixture dictionary to confirm startup, `/healthz`, the handshake, ping/pong, room +creation and the error keys end to end. + +**Two deviations from the spec above, both accepted before implementation** + +| Spec | Shipped | Why | +|---|---|---| +| Bot worker searches the live engine | Worker gets an immutable `frozenBoard` — legal moves, a copy of the used set, and the read-only dictionary | `bot.Board.Used` reads engine state, so handing a worker the live engine races every resign and disconnect the room processes while the bot thinks | +| Per-read `context.WithTimeout` on `conn.Read` | No read deadline; liveness is `conn.Ping` every 20s, 10s timeout, close after 2 misses | A read deadline cannot tell a healthy player idling in the lobby from a dead socket, and would disconnect the first | + +`NewServer` takes a `Dictionary` interface (the engine's contract plus +`RandomOpeningWord`) rather than `*dictionary.Store`, so tests play whole games against a +four-word graph whose outcome a reader can verify by eye. Tests live in `wsapi_test.go` +and `regression_test.go` rather than the three files sketched above. + +**Bugs found by writing the tests**, before review: + +| Defect | Consequence | +|---|---| +| Resume token unregistered the instant the socket dropped | A reconnect could never find its seat — the feature was entirely dead | +| `writeLoop` took its write deadline from the cancelled session context | Every refusal queued just before a close was silently discarded | +| `keyedLimiter.sweep` required a full bucket, but tokens refill lazily | Nothing was ever swept: unbounded map growth keyed by whoever connects | + +**Review found three more, all proven with repros and all fixed:** + +| Defect | Fix | +|---|---| +| The hub bound a joiner to seat `p2` *before* the room decided whether to seat them, and neither `Submit` nor `Resign` checked that the sender owned the seat. Anyone holding a room code — which is pasted into group chats by design — could resign or play on a seated player's behalf | Seating moved onto the room goroutine and happens only on success; `occupies` verifies the sending connection is the one in the seat | +| Cancelling the session context hard-closes the socket under `coder/websocket`, so the shutdown notice never left. Acceptance criterion (i) was failing | Read context separated from the teardown signal and rooted at `Background`, with a watcher that flushes before dropping the socket | +| A room whose game never started had no turn timer and no opponent, so nothing could ever end it; room creation was unlimited | A pre-game disconnect cancels the room; creation is rate-limited per session | + +Fixing those exposed a fourth, in `room.send`: a buffered channel and a cancelled context +are both ready, so `select` chose at random and half of all sends into a finished room +reported success. The caller then waited forever for a reply from a goroutine that had +stopped reading. The done-check is its own statement now. + +Also corrected: the join limiter keyed on the session id, which a client resets by +reconnecting — it keys on the client IP now, deliberately ignoring `X-Forwarded-For`, +which is attacker-controlled until phase 7 pins a trusted proxy. The static-file guard +compared string prefixes, so `/srv/webhooks` counted as inside `/srv/web`; it is a real +path-boundary check now. Room codes use rejection sampling instead of a modulo, which was +making the first eight letters 12% more likely. The handshake is one-shot. +`sanitizeNickname`'s seat parameter was dead — both players who sent an empty name became +"Người chơi 1" — so the collision is resolved in the room, where both names are known, +and now also covers two players who *choose* the same name. + +Every fix has a regression test, and the important ones were mutation-tested by +reintroducing the defect: the authorization guard fails 10/10 against the old code, the +shutdown guard 7/10 and the `room.send` guard 6/10 — both of those are timing races, so +detection is probabilistic by nature. + +**Deferred to phase 7:** the trusted-proxy policy for `X-Forwarded-For`, and a room +identity separate from the code if rematch is added, since codes are recycled on eviction. diff --git a/plans/260904-1125-noi-tu-web-game/plan.md b/plans/260904-1125-noi-tu-web-game/plan.md index eaef838..d404156 100644 --- a/plans/260904-1125-noi-tu-web-game/plan.md +++ b/plans/260904-1125-noi-tu-web-game/plan.md @@ -133,7 +133,7 @@ added, all non-`vi` languages and all definitions/translations dropped). | 2 | [Go Dictionary and Normalization](./phase-02-go-dictionary-and-normalization.md) | Complete | 1 | | 3 | [Go Game Engine and Bot AI](./phase-03-go-game-engine-and-bot-ai.md) | Complete | 2 | | 4 | [Protobuf Contract and Codegen](./phase-04-protobuf-contract-and-codegen.md) | Complete | 1 | -| 5 | [Go WebSocket Server and Rooms](./phase-05-go-websocket-server-and-rooms.md) | Pending | 3, 4 | +| 5 | [Go WebSocket Server and Rooms](./phase-05-go-websocket-server-and-rooms.md) | Complete | 3, 4 | | 6 | [SvelteKit Frontend](./phase-06-sveltekit-frontend.md) | Pending | 4, 5 | | 7 | [Online 1v1 and Release](./phase-07-online-1v1-and-release.md) | Pending | 5, 6 | @@ -296,8 +296,36 @@ breaking` baseline that cannot resolve on a PR checkout, a sync check blind to u files, and two tests whose oracles could drift with the code they checked. All four were fixed and then negative-tested by deliberately breaking each one. +## Phase 5 Outcome (2026-09-05) + +The transport layer is in: rooms, turn timers, the bot as a virtual player, reconnect, and +a single binary that serves both the API and the frontend. Detail in +[`phase-05`](./phase-05-go-websocket-server-and-rooms.md#phase-5-outcome-2026-09-05). + +The concurrency contract holds structurally rather than by convention: the room goroutine +starts before anyone is seated and seating itself is a message, so reading `run()` is a +complete proof that one goroutine owns each engine. The bot searches a frozen board rather +than the live engine, and reads carry no deadline — liveness is ping-based, because a read +timeout cannot distinguish a healthy player idling in the lobby from a dead socket. + +Seven defects were found and fixed: three by writing the tests, three more by review, and +a seventh while fixing those. The serious one was authorization — the hub bound a joiner +to a seat before the room decided whether to seat them, so anyone holding a room code +could resign or play on a seated player's behalf. Since the room code is the only +credential online 1v1 has, that was a phase-7 release blocker caught a phase early. + ## Open Questions 1. Does the losing player see the words the bot *could* have played (a teaching feature), or just the result? Plan currently assumes just the result. 2. Should the builder cap maximum syllables (e.g. reject 5+ syllable entries as phrases rather than words)? Plan currently applies no upper bound; the cap exists in the builder as a flag if playtesting says otherwise. 3. Domain name / TLS certificate source for the VPS deployment. +4. Should the turn clock pause while a seat is inside its reconnect grace window? As + built the grace window (30s) is longer than the turn limit (20s), so a player who + drops on their own turn always loses on time before the window closes — the grace is + unreachable for the player most likely to need it. Their reconnect is now told + `game_already_over` rather than left waiting, but the underlying policy is unresolved. +5. Should bot rooms be registered in the joinable room namespace at all? They have no code + to share, and excluding them removes a whole class of stranger interference for free. +6. Is closing the connection the right response to a full outbox mid-game? 32 queued + frames is not much on a lossy mobile link, and the opponent currently sees that player + as having left. diff --git a/server/cmd/noitu-server/main.go b/server/cmd/noitu-server/main.go new file mode 100644 index 0000000..7ee4420 --- /dev/null +++ b/server/cmd/noitu-server/main.go @@ -0,0 +1,156 @@ +// Command noitu-server runs the nối từ game server: the WebSocket API, the +// rooms behind it, and the built frontend, in one binary. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/tiennm99dev/noitu/server/internal/dictionary" + "github.com/tiennm99dev/noitu/server/internal/wsapi" +) + +const ( + defaultAddr = ":8080" + defaultDBPath = "data/noitu.db" + defaultTurnLimit = 20 * time.Second + defaultGrace = 30 * time.Second + + // shutdownGrace bounds how long in-flight requests get once a signal + // arrives. Rooms are told separately and immediately, so this only covers + // the HTTP side. + shutdownGrace = 10 * time.Second +) + +type config struct { + addr string + dbPath string + turnLimit time.Duration + grace time.Duration + allowedOrigins []string + webDir string +} + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + if err := run(); err != nil { + slog.Error("server exited", "err", err) + os.Exit(1) + } +} + +func run() error { + cfg := loadConfig() + + store, err := dictionary.Open(cfg.dbPath) + if err != nil { + return err + } + + // The store deliberately does not log — a library writing to the global + // logger fights the server's own handler — so the CC BY-SA 4.0 attribution + // that ships with the data surfaces here or nowhere. + slog.Info("dictionary loaded", + "path", cfg.dbPath, + "words", store.WordCount(), + "aliases", store.AliasCount(), + "license", store.License(), + ) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + api := wsapi.NewServer(ctx, store, wsapi.Config{ + TurnLimit: cfg.turnLimit, + GraceFor: cfg.grace, + AllowedOrigins: cfg.allowedOrigins, + WebDir: cfg.webDir, + }) + + srv := &http.Server{ + Addr: cfg.addr, + Handler: api, + ReadHeaderTimeout: 10 * time.Second, + } + + errc := make(chan error, 1) + go func() { + slog.Info("listening", "addr", cfg.addr, "turn_limit", cfg.turnLimit, "web_dir", cfg.webDir) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errc <- err + } + }() + + select { + case err := <-errc: + return err + case <-ctx.Done(): + } + + // Tell players why before the sockets go, rather than dropping them and + // leaving the UI to guess. + slog.Info("shutting down") + api.Shutdown() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace) + defer cancel() + return srv.Shutdown(shutdownCtx) +} + +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", ""), + } +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +// envDuration falls back loudly. A typo in a timing variable would otherwise +// silently change the rules of the game. +func envDuration(key string, fallback time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + slog.Warn("ignoring invalid duration", "key", key, "value", raw, "using", fallback) + return fallback + } + return d +} + +// envList returns nil for an unset variable, which coder/websocket reads as +// same-origin only. +func envList(key string) []string { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/server/go.mod b/server/go.mod index 3c5b235..e2c5218 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,6 +3,7 @@ module github.com/tiennm99dev/noitu/server go 1.25.0 require ( + github.com/coder/websocket v1.8.15 golang.org/x/text v0.41.0 google.golang.org/protobuf v1.36.12 modernc.org/sqlite v1.58.0 diff --git a/server/go.sum b/server/go.sum index d38bc7f..8c60622 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,5 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/server/internal/wsapi/codec.go b/server/internal/wsapi/codec.go new file mode 100644 index 0000000..b115a23 --- /dev/null +++ b/server/internal/wsapi/codec.go @@ -0,0 +1,99 @@ +package wsapi + +import ( + "errors" + "fmt" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "google.golang.org/protobuf/proto" +) + +// maxFrameBytes caps what a client may send. +// +// The largest legitimate client frame is a Hello carrying a 20-rune nickname, +// well under 200 bytes. 4 KiB leaves room for the protocol to grow while still +// making a memory-exhaustion attempt fail at the socket rather than in the +// decoder. +const maxFrameBytes = 4096 + +// ErrNotBinary rejects text frames. The protocol is protobuf over binary +// frames only; a text frame means the peer is speaking something else, and +// guessing at it is how a parser becomes an attack surface. +var ErrNotBinary = errors.New("wsapi: expected a binary frame") + +// Encode marshals a server message for the wire. +func Encode(m *noituv1.ServerMessage) ([]byte, error) { + raw, err := proto.Marshal(m) + if err != nil { + return nil, fmt.Errorf("wsapi: encode: %w", err) + } + return raw, nil +} + +// Decode parses a client frame. +// +// typ is checked here rather than at the call site so every entry point gets +// the same rejection. +func Decode(typ websocket.MessageType, raw []byte) (*noituv1.ClientMessage, error) { + if typ != websocket.MessageBinary { + return nil, ErrNotBinary + } + var m noituv1.ClientMessage + if err := proto.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("wsapi: decode: %w", err) + } + return &m, nil +} + +// Message-building helpers. Every server message is constructed through one of +// these, so the oneof wrapper is written once per variant instead of at each +// call site. + +func welcomeMsg(sessionID, resumeToken, nickname string) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Welcome{Welcome: &noituv1.Welcome{ + SessionId: sessionID, + ResumeToken: resumeToken, + ProtocolVersion: ProtocolVersion, + AcceptedNickname: nickname, + }}} +} + +func roomCreatedMsg(code string) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomCreated{ + RoomCreated: &noituv1.RoomCreated{RoomCode: code}, + }} +} + +func roomJoinedMsg(code, opponent string) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomJoined{ + RoomJoined: &noituv1.RoomJoined{RoomCode: code, OpponentName: opponent}, + }} +} + +func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_MoveRejected{ + MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq}, + }} +} + +func opponentLeftMsg(canReconnect bool, graceMs uint32) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_OpponentLeft{ + OpponentLeft: &noituv1.OpponentLeft{CanReconnect: canReconnect, GraceMs: graceMs}, + }} +} + +// errorMsg carries a UI key, never prose and never an internal error string. +// The Vietnamese copy lives in the frontend so all wording stays in one place, +// and a raw error would leak server internals to anyone with a socket. +func errorMsg(code string) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Error{ + Error: &noituv1.ServerError{Code: code, Message: code}, + }} +} + +func pongMsg(clientTimeMs, serverTimeMs int64) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Pong{ + Pong: &noituv1.Pong{ClientTimeMs: clientTimeMs, ServerTimeMs: serverTimeMs}, + }} +} diff --git a/server/internal/wsapi/hub.go b/server/internal/wsapi/hub.go new file mode 100644 index 0000000..be16f68 --- /dev/null +++ b/server/internal/wsapi/hub.go @@ -0,0 +1,241 @@ +package wsapi + +import ( + "context" + "crypto/rand" + "errors" + "sync" + "time" + + "github.com/tiennm99dev/noitu/server/internal/bot" + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// roomCodeAlphabet omits 0/O and 1/I/L. Players read these codes aloud and +// retype them from a screenshot, so the characters that get confused are worth +// more than the extra entropy they would add. +const roomCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" + +const roomCodeLen = 6 + +// codeAttempts bounds the retry loop on collision. With a 31-character +// alphabet over 6 places, exhausting this many draws means the room table is +// far past any load this server is built for. +const codeAttempts = 10 + +var ( + errRoomNotFound = errors.New("wsapi: no such room") + errNoRoomCode = errors.New("wsapi: could not allocate a room code") +) + +// hub owns the registries and nothing else. +// +// It never touches a game: rooms are handed out as pointers whose channels are +// the only way in. Keeping engine state out of the mutex is what stops the +// lock from becoming a bottleneck on every move, and what makes the +// one-goroutine-per-room rule enforceable by inspection. +type hub struct { + ctx context.Context + dict Dictionary + + turnLimit time.Duration + graceFor time.Duration + + mu sync.Mutex + rooms map[string]*room + sessions map[string]*session // by resume token + + joinLimiter *keyedLimiter +} + +func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor time.Duration) *hub { + return &hub{ + ctx: ctx, + dict: dict, + turnLimit: turnLimit, + graceFor: graceFor, + rooms: map[string]*room{}, + sessions: map[string]*session{}, + joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor), + } +} + +// register records a session so a later Hello can resume it. +func (h *hub) register(s *session) { + h.mu.Lock() + defer h.mu.Unlock() + h.sessions[s.resumeToken] = s +} + +// unregister drops a session's resume entry once its grace window has passed. +func (h *hub) unregister(token string) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.sessions, token) +} + +// expireToken drops a resume entry once its grace window has passed, so a +// disconnected player can reclaim their seat until then and the map does not +// grow for every connection ever made. +func (h *hub) expireToken(token string, after time.Duration) { + time.AfterFunc(after, func() { h.unregister(token) }) +} + +// resumable returns the prior session for a token, if it is still eligible. +func (h *hub) resumable(token string) (*session, bool) { + if token == "" { + return nil, false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.sessions[token] + return s, ok +} + +// startBotRoom creates a room already in play against the bot. +// +// The room goroutine starts before anyone is seated, and the seating itself is +// a message. That ordering is what lets the engine-ownership invariant be +// checked by reading run(), rather than by reasoning about which writes +// happened to precede a `go` statement. +func (h *hub) startBotRoom(s *session, difficulty bot.Difficulty) error { + r, err := h.newRegisteredRoom() + if err != nil { + return err + } + r.send(startBotInput{sess: s, difficulty: difficulty}) + return nil +} + +// createRoom opens an empty PvP room. The room announces its own code once the +// creator is seated, so a client can never receive the code before the seat +// behind it exists. +func (h *hub) createRoom(s *session) error { + r, err := h.newRegisteredRoom() + if err != nil { + return err + } + r.send(createInput{sess: s}) + return nil +} + +// joinRoom offers a second player to a room. Whether they are seated is the +// room's decision, not the hub's. +func (h *hub) joinRoom(code string, s *session) error { + h.mu.Lock() + r, ok := h.rooms[code] + h.mu.Unlock() + if !ok { + return errRoomNotFound + } + if !r.send(joinInput{sess: s}) { + return errRoomNotFound + } + return nil +} + +// newRegisteredRoom allocates a code, registers the room and starts it. +func (h *hub) newRegisteredRoom() (*room, error) { + code, err := h.reserveCode() + if err != nil { + return nil, err + } + + r := newRoom(h, code, h.turnLimit, h.graceFor) + + h.mu.Lock() + h.rooms[code] = r + h.mu.Unlock() + + go r.run() + return r, nil +} + +// evict removes a finished room. Called by the room goroutine as it exits, so +// a code is reusable the moment its game is done. +func (h *hub) evict(code string) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.rooms, code) +} + +// reserveCode draws an unused room code. +// +// crypto/rand, not math/rand: a predictable code lets someone walk into a +// stranger's private game, which is a guessing attack on a 6-character secret +// rather than a fairness question. +func (h *hub) reserveCode() (string, error) { + for range codeAttempts { + code := randomCode() + + h.mu.Lock() + _, taken := h.rooms[code] + h.mu.Unlock() + + if !taken { + return code, nil + } + } + return "", errNoRoomCode +} + +// randomCode draws a uniformly distributed room code. +// +// Rejection sampling rather than a modulo: 256 is not a multiple of 31, so +// `b % 31` would make the first eight letters 12% more likely than the rest. +// The bias is small, but the cost of removing it is one comparison, and it +// lets the entropy claim above be stated without an asterisk. +func randomCode() string { + const limit = 256 - (256 % len(roomCodeAlphabet)) // 248 + + out := make([]byte, 0, roomCodeLen) + buf := make([]byte, roomCodeLen) + for len(out) < roomCodeLen { + // rand.Read from crypto/rand cannot fail; it panics internally instead. + _, _ = rand.Read(buf) + for _, b := range buf { + if int(b) >= limit { + continue // would bias the low letters + } + out = append(out, roomCodeAlphabet[int(b)%len(roomCodeAlphabet)]) + if len(out) == roomCodeLen { + break + } + } + } + return string(out) +} + +// shutdown tells every live room to stop, so clients learn why rather than +// finding the socket gone. +func (h *hub) shutdown() { + h.mu.Lock() + rooms := make([]*room, 0, len(h.rooms)) + for _, r := range h.rooms { + rooms = append(rooms, r) + } + sessions := make([]*session, 0, len(h.sessions)) + for _, s := range h.sessions { + sessions = append(sessions, s) + } + h.mu.Unlock() + + for _, s := range sessions { + s.send(errorMsg("server_restarting")) + } + for _, r := range rooms { + r.cancel() + } +} + +// roomCount reports live rooms. Tests use it to assert eviction; nothing in +// the server depends on it. +func (h *hub) roomCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.rooms) +} + +// playerIDFor is the seat a session holds. Declared here because the mapping +// between a connection and a seat is registry knowledge, not game knowledge. +func playerIDFor(seatName string) game.PlayerID { return game.PlayerID(seatName) } diff --git a/server/internal/wsapi/nickname.go b/server/internal/wsapi/nickname.go new file mode 100644 index 0000000..40cc5ad --- /dev/null +++ b/server/internal/wsapi/nickname.go @@ -0,0 +1,81 @@ +package wsapi + +import ( + "strings" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +// maxNicknameRunes caps the display name. Runes, not bytes: a Vietnamese name +// is multi-byte, and a byte cap would cut it far shorter than a Latin one. +const maxNicknameRunes = 20 + +// denylisted is the hook for blocking names outright. It is deliberately a +// variable and deliberately empty: v1 has no abuse signal to tune a list +// against, and this way adding one later is a data change in one place rather +// than a new pass through the sanitizer. +var denylisted = func(string) bool { return false } + +// sanitizeNickname turns untrusted input into something safe to show a +// stranger. +// +// This is an input-validation boundary, not cosmetics: the result is rendered +// in another player's browser, so anything that could forge UI or hide +// characters has to be gone before it is stored. The order matters — +// normalizing first means the length cap and the character filters see the +// same form the client will render, rather than a decomposed variant that +// counts differently. +// +// When nothing usable survives, the caller gets the generic name. Making that +// unique is the room's job, not this function's: two players can just as +// easily both *choose* "Minh", so the collision has to be resolved where both +// names are known. +func sanitizeNickname(raw string) string { + s := norm.NFC.String(raw) + + // Drop anything non-printing. Format characters (Cf) are the important + // case: zero-width joiners and bidi overrides are invisible, so they can + // pad a name past a visual check or reverse how it renders. + s = strings.Map(func(r rune) rune { + switch { + case r == '\t' || r == '\n' || r == '\r': + return ' ' // collapsed below + case unicode.IsControl(r), unicode.Is(unicode.Cf, r): + return -1 + case !unicode.IsPrint(r): + return -1 + } + return r + }, s) + + // Collapse runs of whitespace so a name cannot be padded into a column of + // its own, then trim the edges. + s = strings.Join(strings.Fields(s), " ") + + if runes := []rune(s); len(runes) > maxNicknameRunes { + s = strings.TrimSpace(string(runes[:maxNicknameRunes])) + } + + if s == "" || denylisted(s) { + return defaultNickname + } + return s +} + +// defaultNickname is what an unusable name falls back to. +const defaultNickname = "Người chơi" + +// distinguish returns a name for the joining player that their opponent cannot +// be confused with. Nicknames are the only way to tell two strangers apart, so +// letting both sides render the same string defeats the point of having them. +func distinguish(name, taken string) string { + if name != taken { + return name + } + suffix := " 2" + if runes := []rune(name); len(runes)+len(suffix) > maxNicknameRunes { + name = strings.TrimSpace(string(runes[:maxNicknameRunes-len(suffix)])) + } + return name + suffix +} diff --git a/server/internal/wsapi/ratelimit.go b/server/internal/wsapi/ratelimit.go new file mode 100644 index 0000000..481f3f0 --- /dev/null +++ b/server/internal/wsapi/ratelimit.go @@ -0,0 +1,99 @@ +package wsapi + +import ( + "sync" + "time" +) + +// bucket is a token bucket. +// +// Time is a parameter rather than read from the clock inside, so the tests +// exercise refill behaviour directly instead of sleeping through it. +type bucket struct { + mu sync.Mutex + tokens float64 + capacity float64 + perSec float64 + last time.Time +} + +func newBucket(perSec float64, capacity int, now time.Time) *bucket { + return &bucket{ + tokens: float64(capacity), + capacity: float64(capacity), + perSec: perSec, + last: now, + } +} + +// allow spends a token if one is available. +func (b *bucket) allow(now time.Time) bool { + b.mu.Lock() + defer b.mu.Unlock() + + if elapsed := now.Sub(b.last); elapsed > 0 { + b.tokens = min(b.capacity, b.tokens+elapsed.Seconds()*b.perSec) + b.last = now + } + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +// keyedLimiter is one bucket per key, used for per-IP limits where the set of +// keys is open-ended and outlives any single connection. +// +// Idle buckets are swept rather than kept forever: without that, the map is a +// slow memory leak driven by whoever connects, which is exactly the wrong +// party to let control its size. +type keyedLimiter struct { + mu sync.Mutex + buckets map[string]*bucket + perSec float64 + capacity int + idleFor time.Duration +} + +func newKeyedLimiter(perSec float64, capacity int, idleFor time.Duration) *keyedLimiter { + return &keyedLimiter{ + buckets: map[string]*bucket{}, + perSec: perSec, + capacity: capacity, + idleFor: idleFor, + } +} + +func (l *keyedLimiter) allow(key string, now time.Time) bool { + l.mu.Lock() + b, ok := l.buckets[key] + if !ok { + b = newBucket(l.perSec, l.capacity, now) + l.buckets[key] = b + } + l.mu.Unlock() + + return b.allow(now) +} + +// sweep drops buckets untouched for idleFor. A full bucket carries no state +// worth keeping, so recreating it later is equivalent. +func (l *keyedLimiter) sweep(now time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + + for key, b := range l.buckets { + // Idleness alone is the test. Tokens refill lazily — only when allow + // is called — so a bucket that was ever used still reads as partly + // spent no matter how long ago that was. Requiring a full bucket here + // meant nothing was ever collected, which is precisely the leak this + // sweep exists to prevent. + b.mu.Lock() + idle := now.Sub(b.last) > l.idleFor + b.mu.Unlock() + if idle { + delete(l.buckets, key) + } + } +} diff --git a/server/internal/wsapi/regression_test.go b/server/internal/wsapi/regression_test.go new file mode 100644 index 0000000..88e6881 --- /dev/null +++ b/server/internal/wsapi/regression_test.go @@ -0,0 +1,378 @@ +package wsapi + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "google.golang.org/protobuf/proto" +) + +// Named so a reader can see what the sanitizer tests are sending: all three +// are invisible in source, which is exactly why they matter as input. +const ( + nul = rune(0) + zeroWidthSpace = rune(0x200B) + bidiOverride = rune(0x202E) +) + +// Regressions for defects found in review. Each one failed before its fix, so +// each is a guard against the same mistake returning rather than a restatement +// of behaviour already covered elsewhere. + +// startPvP runs two clients up to the point where the game is live. +func startPvP(t *testing.T, url string) (host, guest *testClient, code string) { + t.Helper() + host = dial(t, url) + host.hello("Chủ phòng") + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + code = host.await("room_created").GetRoomCreated().GetRoomCode() + + guest = dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + host.await("game_started") + guest.await("game_started") + return host, guest, code +} + +// silentFor asserts the client receives no message of the given kind within a +// short window. Used where the correct behaviour is that nothing happens. +func silentFor(t *testing.T, c *testClient, unwanted string, d time.Duration) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), d) + defer cancel() + + for { + _, raw, err := c.conn.Read(ctx) + if err != nil { + return // nothing arrived, which is the pass + } + var m noituv1.ServerMessage + if err := proto.Unmarshal(raw, &m); err != nil { + return + } + if payloadCase(&m) == unwanted { + t.Fatalf("received an unexpected %q", unwanted) + } + } +} + +// TestStrangerCannotResignForASeatedPlayer is the authorization case. A room +// code is pasted into group chats by design, so holding one must not be enough +// to act as a player who is already seated. +func TestStrangerCannotResignForASeatedPlayer(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second}) + _, guest, code := startPvP(t, url) + + stranger := dial(t, url) + stranger.hello("Kẻ lạ") + stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + if got := stranger.await("error").GetError().GetCode(); got != "room_full" { + t.Fatalf("third joiner got %q, want room_full", got) + } + + stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}}) + + silentFor(t, guest, "game_over", 400*time.Millisecond) +} + +// TestStrangerCannotSubmitForASeatedPlayer is the same hole via SubmitWord: +// turn_seq is a small integer, so its being unguessable is not a defence. +func TestStrangerCannotSubmitForASeatedPlayer(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second}) + host, _, code := startPvP(t, url) + + stranger := dial(t, url) + stranger.hello("Kẻ lạ") + stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + stranger.await("error") + + stranger.submit("b c", 1) + + silentFor(t, host, "turn_update", 400*time.Millisecond) +} + +// TestCannotJoinYourOwnRoom stops a creator occupying both seats, which would +// leave one connection playing itself. +func TestCannotJoinYourOwnRoom(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_created").GetRoomCreated().GetRoomCode() + + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + if got := host.await("error").GetError().GetCode(); got != "cannot_join_own_room" { + t.Errorf("self-join returned %q", got) + } +} + +// TestShutdownNotifiesPlayersMidGame is acceptance criterion (i). It races +// Shutdown against in-flight reads, which is where cancelling the read context +// used to destroy the socket before the notice could leave. +// +// Several connections rather than one: whether any single socket loses that +// race is timing, so one client would make this an intermittent guard against +// a defect that is entirely deterministic in its consequences. +func TestShutdownNotifiesPlayersMidGame(t *testing.T) { + api, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second}) + + const players = 8 + clients := make([]*testClient, 0, players) + for i := range players { + 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") + clients = append(clients, c) + _ = i + } + + api.Shutdown() + + for i, c := range clients { + if got := c.await("error").GetError().GetCode(); got != "server_restarting" { + t.Errorf("client %d shutdown notice = %q, want server_restarting", i, got) + } + } +} + +// TestAbandonedRoomIsEvicted covers the leak: a room whose game never started +// has no turn timer and no opponent, so nothing else could ever end it. +func TestAbandonedRoomIsEvicted(t *testing.T) { + api, 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{}}}) + host.await("room_created") + + _ = host.conn.Close(websocket.StatusGoingAway, "") + + 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("%d rooms still live after the only player left before the game began", api.hub.roomCount()) +} + +// TestRoomCreationIsRateLimited stops one connection minting goroutines, +// engines and registry entries without bound. +func TestRoomCreationIsRateLimited(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người thử") + + for range roomBurst + 3 { + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + } + + for range 40 { + m := c.recv() + if payloadCase(m) == "error" && m.GetError().GetCode() == "too_many_rooms" { + return + } + } + t.Error("one connection created rooms past the burst without being limited") +} + +// TestPvPGameRunsToAWinner plays a PvP game to its natural end, rather than +// stopping after the first move as the alternation test does. +func TestPvPGameRunsToAWinner(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second}) + host, guest, _ := startPvP(t, url) + + // a b (opening) -> b c -> c d -> d e, after which the guest has nothing. + host.submit("b c", 1) + guestTurn := awaitMyTurn(t, guest) + guest.submit("c d", guestTurn.GetTurnSeq()) + hostTurn := awaitMyTurn(t, host) + host.submit("d e", hostTurn.GetTurnSeq()) + + hostOver := host.await("game_over").GetGameOver() + guestOver := guest.await("game_over").GetGameOver() + + if hostOver.GetIWon() == guestOver.GetIWon() { + t.Fatalf("both players were told the same outcome: host=%v guest=%v", + hostOver.GetIWon(), guestOver.GetIWon()) + } + if !hostOver.GetIWon() { + t.Error("the player who left their opponent with no legal move should win") + } + if hostOver.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE { + t.Errorf("reason = %v, want NO_LEGAL_MOVE", hostOver.GetReason()) + } +} + +// awaitMyTurn reads turn updates until this client is the one on turn. Both +// players see every move, so the first update after a submission is the +// player's own echo. +func awaitMyTurn(t *testing.T, c *testClient) *noituv1.TurnUpdate { + t.Helper() + for range 10 { + if u := c.await("turn_update").GetTurnUpdate(); u.GetMyTurn() { + return u + } + } + t.Fatal("never became the player on turn") + return nil +} + +// TestOpponentNeverSeesAnUnsanitizedNickname is the boundary that matters: +// sanitization is only worth anything if the hostile string cannot reach the +// other player's screen. +func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) { + _, url := newTestServer(t, chainDict(), 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_created").GetRoomCreated().GetRoomCode() + + hostile := " Kẻ" + string(nul) + " xấu" + string(zeroWidthSpace) + string(bidiOverride) + + " " + strings.Repeat("z", 40) + guest := dial(t, url) + guest.hello(hostile) + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + + shown := host.await("room_joined").GetRoomJoined().GetOpponentName() + for _, r := range []rune{nul, zeroWidthSpace, bidiOverride} { + if strings.ContainsRune(shown, r) { + t.Errorf("opponent name %q still carries the invisible rune %U", shown, r) + } + } + if n := len([]rune(shown)); n > maxNicknameRunes { + t.Errorf("opponent name is %d runes, over the %d cap", n, maxNicknameRunes) + } +} + +// TestRepeatedHelloIsRejected keeps the handshake a one-shot transition: a +// second Hello would rewrite the nickname of a player already in a game. +func TestRepeatedHelloIsRejected(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người thử") + + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ + ProtocolVersion: ProtocolVersion, + Nickname: "Người khác", + }}}) + if got := c.await("error").GetError().GetCode(); got != "already_greeted" { + t.Errorf("second hello returned %q, want already_greeted", got) + } +} + +// TestStaleRejectionCarriesTheServerSequence lets a client resynchronise from +// the refusal instead of waiting for the next turn update to discover where +// the game actually is. +func TestStaleRejectionCarriesTheServerSequence(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.GetTurnSeq() != started.GetTurnSeq() { + t.Errorf("rejection carried seq %d, want the server's %d", got.GetTurnSeq(), started.GetTurnSeq()) + } +} + +// TestResumeAfterGameEndedSaysSo. GraceFor is longer than TurnLimit, so a +// player who drops on their own turn loses before the grace window closes. +// Their reconnect must be told that, not left holding a Welcome and silence. +func TestResumeAfterGameEndedSaysSo(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{ + TurnLimit: 200 * time.Millisecond, + 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_created").GetRoomCreated().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + host.await("game_started") + guest.await("game_started") + + // The host is on turn; drop and let the turn clock run out. + _ = host.conn.Close(websocket.StatusGoingAway, "") + guest.await("game_over") + + 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") + + if got := back.await("error").GetError().GetCode(); got != "game_already_over" { + t.Errorf("resume into a finished game returned %q", got) + } +} + +func TestUnderRootIsAPathBoundaryNotAPrefix(t *testing.T) { + root := filepath.Clean("/srv/web") + + if underRoot(root, filepath.Clean("/srv/webhooks/secret")) { + t.Error("a sibling directory sharing a name prefix must not count as inside the root") + } + if !underRoot(root, filepath.Clean("/srv/web/index.html")) { + t.Error("a real child was rejected") + } + if !underRoot(root, root) { + t.Error("the root itself should be allowed") + } +} + +// TestRoomCodesAreUnbiased checks the rejection sampling. A modulo over 256 +// would make the first eight letters of the alphabet ~12% more likely, which +// this tolerance catches and ordinary randomness does not trip. +func TestRoomCodesAreUnbiased(t *testing.T) { + const draws = 10000 + + counts := map[rune]int{} + for range draws { + for _, r := range randomCode() { + counts[r]++ + } + } + + expected := float64(draws*roomCodeLen) / float64(len(roomCodeAlphabet)) + for _, r := range roomCodeAlphabet { + got := float64(counts[r]) + if got < expected*0.9 || got > expected*1.1 { + t.Errorf("letter %q drawn %.0f times, expected ~%.0f", r, got, expected) + } + } +} diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go new file mode 100644 index 0000000..f32f08b --- /dev/null +++ b/server/internal/wsapi/room.go @@ -0,0 +1,722 @@ +package wsapi + +import ( + "context" + "iter" + "log/slog" + "math/rand/v2" + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/bot" + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// botPlayerID is the seat the bot occupies. It is a normal player to the +// engine, which is the whole point: the bot's moves go through the same +// validation as a human's, so there is one rule implementation rather than two. +const botPlayerID game.PlayerID = "bot" + +// minOpeningOutDegree keeps the first word from being a dead end. Opening on a +// syllable with two continuations makes for a game that ends before it starts. +const minOpeningOutDegree = 20 + +// roomInputCap buffers the room's inbox. A sender that finds it full is either +// flooding past the rate limiter or racing a room that is shutting down; +// neither is worth blocking a session goroutine for. +const roomInputCap = 32 + +// 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 +} + +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 +} + +type resignInput struct { + sess *session + player game.PlayerID +} + +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 +} + +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 + nickname string + sess *session // nil for the bot, or while a human is disconnected +} + +// room owns one game. +// +// Every field below is touched only by the room goroutine after start. The +// exceptions are inputs and ctx, which exist precisely to be used from outside. +type room struct { + code string + inputs chan any + ctx context.Context + cancel context.CancelFunc + + hub *hub + dict Dictionary + engine *game.Engine + opening string + strategy bot.Strategy + turnLimit time.Duration + graceFor time.Duration + + seats [2]*seat + + // turnSeq increments on every turn change. A client stamps its submission + // with the sequence it was answering, so a move that crosses the deadline + // is identifiable rather than silently applied to the next turn. + turnSeq uint32 + + // disconnected is the seat currently inside its reconnect grace window, + // or nil. Only one seat can be waiting: if the second also drops, there is + // nobody left to win and the room ends. + disconnected *seat +} + +// Dictionary is everything the transport layer needs from the wordlist: the +// engine's own contract, plus a way to pick an opening. +// +// An interface rather than *dictionary.Store so a test can play a whole game +// against a hand-built graph of a dozen words, where the expected outcome is +// something a reader can verify by eye. *dictionary.Store satisfies it as +// written. +type Dictionary interface { + game.Dictionary + RandomOpeningWord(minOutDegree int) (string, error) +} + +func newRoom(h *hub, code string, turnLimit, graceFor time.Duration) *room { + ctx, cancel := context.WithCancel(h.ctx) + return &room{ + code: code, + inputs: make(chan any, roomInputCap), + ctx: ctx, + cancel: cancel, + hub: h, + dict: h.dict, + turnLimit: turnLimit, + graceFor: graceFor, + } +} + +// send hands a message to the room without ever blocking the caller. +// +// A session goroutine must not be able to stall on a room: that would let one +// wedged game hold a connection open with no way out. A dropped message is +// recoverable — the client retries or the game times out — while a deadlock is +// not. +func (r *room) send(msg any) bool { + // Check for a finished room first, on its own. Folding this into the + // select below would make it a coin flip: the buffered channel and the + // done channel are both ready, so select picks at random and half the + // sends into a dead room report success. The caller then believes the + // message is on its way to a goroutine that stopped reading, and whoever + // was waiting for the reply waits forever. + select { + case <-r.ctx.Done(): + return false + default: + } + + select { + case r.inputs <- msg: + return true + case <-r.ctx.Done(): + return false + default: + slog.Warn("room inbox full, dropping message", "room", r.code) + return false + } +} + +// run is the room goroutine. It is the only place the engine is touched. +func (r *room) run() { + defer r.cancel() + defer r.hub.evict(r.code) + + var turnTimer, graceTimer *time.Timer + stop := func(t *time.Timer) { + if t != nil { + t.Stop() + } + } + defer func() { + stop(turnTimer) + stop(graceTimer) + }() + + // resetTurnTimer rebuilds the deadline timer after anything that changes + // whose turn it is. Recreating rather than resetting sidesteps the drain + // problem entirely: a stopped timer's stale fire can never reach the + // select because that channel is no longer the one being read. + resetTurnTimer := func() { + stop(turnTimer) + turnTimer = nil + if r.engine == nil || r.engine.Over() { + return + } + turnTimer = time.NewTimer(time.Until(r.engine.Deadline())) + } + + for { + var turnC, graceC <-chan time.Time + if turnTimer != nil { + turnC = turnTimer.C + } + if graceTimer != nil { + graceC = graceTimer.C + } + + select { + case <-r.ctx.Done(): + return + + case msg := <-r.inputs: + switch m := msg.(type) { + case createInput: + r.handleCreate(m) + case startBotInput: + r.handleStartBot(m) + resetTurnTimer() + case joinInput: + r.handleJoin(m) + resetTurnTimer() + case submitInput: + r.handleSubmit(m) + resetTurnTimer() + case botMoveInput: + r.handleBotMove(m) + resetTurnTimer() + case resignInput: + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + break + } + if r.engine != nil && r.engine.Resign(m.player) { + r.broadcastGameOver() + } + resetTurnTimer() + case disconnectInput: + if r.handleDisconnect(m) { + stop(graceTimer) + graceTimer = time.NewTimer(r.graceFor) + } + resetTurnTimer() + case resumeInput: + r.handleResume(m) + stop(graceTimer) + graceTimer = nil + resetTurnTimer() + } + + if r.engine != nil && r.engine.Over() { + return + } + + case <-turnC: + // The timer and every message land on the same select, so a move + // that arrives at the deadline is either strictly before or + // strictly after it. There is no window where both apply. + if r.engine != nil && r.engine.Timeout(time.Now()) { + r.broadcastGameOver() + return + } + resetTurnTimer() + + case <-graceC: + r.endForAbandonment() + return + } + } +} + +// handleCreate seats the room's creator and waits for an opponent. +func (r *room) handleCreate(m createInput) { + r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess} + m.sess.attach(r, "p1") + m.sess.send(roomCreatedMsg(r.code)) +} + +// 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} + r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"} + m.sess.attach(r, "p1") + + 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 the second human and starts the game. +// +// 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 seat "p2", and every later Submit or Resign it sent would be applied +// to the real player sitting there. +func (r *room) handleJoin(m joinInput) { + if r.seats[0] == nil || r.seats[1] != nil { + m.sess.send(errorMsg("room_full")) + return + } + if r.seats[0].sess == m.sess { + m.sess.send(errorMsg("cannot_join_own_room")) + return + } + r.seats[1] = &seat{ + id: "p2", + nickname: distinguish(m.sess.nickname(), r.seats[0].nickname), + sess: m.sess, + } + m.sess.attach(r, "p2") + + for i, s := range r.seats { + if s.sess != nil { + s.sess.send(roomJoinedMsg(r.code, r.seats[1-i].nickname)) + } + } + if err := r.beginGame(); err != nil { + slog.Error("could not start pvp game", "room", r.code, "err", err) + r.broadcastError("game_start_failed") + r.cancel() + } +} + +// 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 +// seated here — or was replaced by a reconnect — must not be able to act. +func (r *room) occupies(sess *session, p game.PlayerID) bool { + s := r.seatOf(p) + 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 + } + + engine, err := game.New(r.dict, []game.PlayerID{r.seats[0].id, r.seats[1].id}, opening, r.turnLimit, time.Now()) + if err != nil { + return err + } + r.engine = engine + r.opening = opening + r.turnSeq = 1 + + for _, s := range r.seats { + r.sendGameStarted(s) + } + r.maybeScheduleBot() + return nil +} + +// sendGameStarted renders the opening position for one seat. my_turn is +// per-recipient, which is why this is built per seat rather than broadcast. +func (r *room) sendGameStarted(s *seat) { + if s.sess == nil { + return + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameStarted{ + GameStarted: &noituv1.GameStarted{ + OpeningWord: r.opening, + CurrentSyllable: r.engine.Current(), + MyTurn: r.engine.Turn() == s.id, + DeadlineUnixMs: r.engine.Deadline().UnixMilli(), + TurnSeq: r.turnSeq, + TurnLimitMs: uint32(r.turnLimit.Milliseconds()), + }, + }}) +} + +// 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. + if m.turnSeq != r.turnSeq { + r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq)) + return + } + + move, reason := r.engine.Submit(m.player, m.word, time.Now()) + if reason != game.ReasonNone { + r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), m.word, m.turnSeq)) + // A rejection for an expired turn is also the end of the game. + if r.engine.Over() { + r.broadcastGameOver() + } + return + } + + r.turnSeq++ + r.broadcastTurn(move) + + if r.engine.Over() { + r.broadcastGameOver() + return + } + r.maybeScheduleBot() +} + +// 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 + } + + if m.err != nil { + // No legal move: the bot has lost by the same rule a human would. + r.engine.Resign(botPlayerID) + r.broadcastGameOver() + return + } + + move, reason := r.engine.Submit(botPlayerID, m.word, time.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) + r.broadcastGameOver() + return + } + + r.turnSeq++ + r.broadcastTurn(move) + if r.engine.Over() { + r.broadcastGameOver() + } +} + +// 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 move to both seats, rendered for each. +func (r *room) broadcastTurn(move game.Move) { + state := r.engine.Snapshot() + for i, s := range r.seats { + if s.sess == nil { + continue + } + opponent := r.seats[1-i] + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{ + TurnUpdate: &noituv1.TurnUpdate{ + Played: PlayedWord(move, move.Player == s.id), + CurrentSyllable: state.Current, + MyTurn: state.Turn == s.id, + DeadlineUnixMs: state.Deadline.UnixMilli(), + TurnSeq: r.turnSeq, + MyScore: uint32(state.Scores[s.id]), + OpponentScore: uint32(state.Scores[opponent.id]), + ChainLength: uint32(state.ChainLength), + }, + }}) + } +} + +// broadcastGameOver reports the result from each seat's point of view. +func (r *room) broadcastGameOver() { + state := r.engine.Snapshot() + for _, s := range r.seats { + if s.sess == nil { + continue + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ + GameOver: &noituv1.GameOver{ + IWon: state.Winner == s.id, + Reason: EndReason(state.EndReason), + MyScore: uint32(state.Scores[s.id]), + ChainLength: uint32(state.ChainLength), + }, + }}) + } +} + +// handleDisconnect holds the seat open, reporting whether a grace window +// should now run. +func (r *room) handleDisconnect(m disconnectInput) bool { + s := r.seatOf(m.player) + // A stale notice from a connection the player already replaced. Evicting + // on it would drop the seat the new socket is sitting in. + if s == nil || s.sess == nil || s.sess != m.sess { + return false + } + s.sess = nil + + // Both sides gone: nobody is left to win, so there is nothing to hold the + // room open for. + if r.disconnected != nil && r.disconnected != s { + r.cancel() + return false + } + r.disconnected = s + + // Before the game starts there is no turn timer and no opponent, so no + // clock can ever end this room. Without this it would sit in select + // forever, holding a goroutine and a room code for a game nobody is in. + if r.engine == nil { + r.cancel() + return false + } + + if other := r.opponentSeat(s.id); other != nil && other.sess != nil { + other.sess.send(opponentLeftMsg(true, uint32(r.graceFor.Milliseconds()))) + } + return r.engine != nil && !r.engine.Over() +} + +// 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 + } + if r.engine != nil && r.engine.Over() { + m.sess.send(errorMsg("game_already_over")) + return + } + s.sess = m.sess + // 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 their opponent's name. + if r.disconnected == s { + r.disconnected = nil + } + + // Resumed into a room whose game has not started: the seat is restored and + // the client waits for an opponent exactly as it was. + if r.engine == nil { + s.sess.send(roomCreatedMsg(r.code)) + return + } + r.sendGameStarted(s) + if state := r.engine.Snapshot(); len(state.History) > 0 { + last := state.History[len(state.History)-1] + opponent := r.opponentSeat(s.id) + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{ + TurnUpdate: &noituv1.TurnUpdate{ + Played: PlayedWord(last, last.Player == s.id), + CurrentSyllable: state.Current, + MyTurn: state.Turn == s.id, + DeadlineUnixMs: state.Deadline.UnixMilli(), + TurnSeq: r.turnSeq, + MyScore: uint32(state.Scores[s.id]), + OpponentScore: uint32(state.Scores[opponent.id]), + ChainLength: uint32(state.ChainLength), + }, + }}) + } +} + +// endForAbandonment awards the game to whoever stayed. +func (r *room) endForAbandonment() { + if r.engine == nil || r.engine.Over() || r.disconnected == nil { + return + } + // Resign on the absent player's behalf, then report the transport reason + // rather than the engine's: from the winner's side this is an opponent who + // left, not one who chose to give up. + r.engine.Resign(r.disconnected.id) + + state := r.engine.Snapshot() + for _, s := range r.seats { + if s.sess == nil { + continue + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ + GameOver: &noituv1.GameOver{ + IWon: state.Winner == s.id, + Reason: noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT, + MyScore: uint32(state.Scores[s.id]), + ChainLength: uint32(state.ChainLength), + }, + }}) + } +} + +func (r *room) broadcastError(code string) { + for _, s := range r.seats { + if s != nil && s.sess != nil { + s.sess.send(errorMsg(code)) + } + } +} + +func (r *room) sendTo(p game.PlayerID, msg *noituv1.ServerMessage) { + if s := r.seatOf(p); s != nil && s.sess != nil { + s.sess.send(msg) + } +} + +func (r *room) seatOf(p game.PlayerID) *seat { + for _, s := range r.seats { + if s != nil && s.id == p { + return s + } + } + return nil +} + +func (r *room) opponentSeat(p game.PlayerID) *seat { + for i, s := range r.seats { + if s != nil && s.id == p { + return r.seats[1-i] + } + } + 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/server.go b/server/internal/wsapi/server.go new file mode 100644 index 0000000..0affa75 --- /dev/null +++ b/server/internal/wsapi/server.go @@ -0,0 +1,162 @@ +package wsapi + +import ( + "context" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/coder/websocket" +) + +// Config is everything the transport layer needs to run. +type Config struct { + // TurnLimit is the same for bot and PvP games: one constant, one code + // path, no mode-specific timing to reason about. + TurnLimit time.Duration + // GraceFor is how long a disconnected seat is held open. + GraceFor time.Duration + // AllowedOrigins is matched by coder/websocket against the Origin header. + // Empty means same-origin only, which is the right default for a binary + // that also serves the frontend. + AllowedOrigins []string + // WebDir is the built frontend. Empty, or missing on disk, serves the API + // alone — which is the state until phase 6 produces a bundle. + WebDir string +} + +// Server wires the hub to an HTTP mux. +type Server struct { + hub *hub + mux *http.ServeMux + cancel context.CancelFunc + cfg Config +} + +// NewServer builds the handler tree. +func NewServer(ctx context.Context, dict Dictionary, cfg Config) *Server { + ctx, cancel := context.WithCancel(ctx) + + s := &Server{ + hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor), + mux: http.NewServeMux(), + cancel: cancel, + cfg: cfg, + } + + s.mux.HandleFunc("GET /ws", s.handleWS) + s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + s.mountStatic() + + go s.sweepLimiters(ctx) + return s +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } + +// Shutdown tells live games why they are ending, then stops the hub. +func (s *Server) Shutdown() { + s.hub.shutdown() + s.cancel() +} + +func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: s.cfg.AllowedOrigins, + }) + if err != nil { + // Accept has already written the rejection, including the origin + // refusal, so there is nothing to add to the response here. + slog.Debug("websocket accept rejected", "err", err, "origin", r.Header.Get("Origin")) + return + } + + sess := newSession(s.hub.ctx, conn, s.hub, clientIP(r)) + sess.run() + + // The token has to outlive the socket by exactly the grace window: that is + // what a reconnect presents to reclaim its seat. Dropping it here, as the + // connection ends, would make every resume fail to find its game. + s.hub.expireToken(sess.resumeToken, s.cfg.GraceFor) +} + +// mountStatic serves the built frontend so one binary is the whole deployment. +// +// Unknown paths fall back to index.html because the frontend is a single-page +// app: a deep link is a client route, not a server 404. +func (s *Server) mountStatic() { + if s.cfg.WebDir == "" { + return + } + index := filepath.Join(s.cfg.WebDir, "index.html") + if _, err := os.Stat(index); err != nil { + slog.Warn("no frontend to serve", "dir", s.cfg.WebDir) + return + } + + root := filepath.Clean(s.cfg.WebDir) + files := http.FileServer(http.Dir(root)) + + s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + clean := filepath.Join(root, filepath.Clean(r.URL.Path)) + + // A path boundary, not a string prefix: with a root of /srv/web, a + // prefix test would also accept /srv/webhooks. http.Dir re-anchors + // anyway, but the SPA fallback below stats paths directly, so this is + // the check that keeps it from being used to probe outside the bundle. + if !underRoot(root, clean) { + http.NotFound(w, r) + return + } + if info, err := os.Stat(clean); err == nil && !info.IsDir() { + files.ServeHTTP(w, r) + return + } + http.ServeFile(w, r, index) + }) +} + +// underRoot reports whether path is root itself or lies beneath it. +func underRoot(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)) +} + +// clientIP is the key the join limiter counts against. +// +// RemoteAddr is deliberately the only source. Behind the reverse proxy this +// deploys under, X-Forwarded-For is attacker-controlled unless the proxy is +// known to overwrite it, and trusting it unconditionally would let one client +// spend everyone else's budget by forging the header. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// sweepLimiters keeps the per-key rate limiter from growing without bound. +func (s *Server) sweepLimiters(ctx context.Context) { + ticker := time.NewTicker(limiterIdleFor) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + s.hub.joinLimiter.sweep(now) + } + } +} diff --git a/server/internal/wsapi/session.go b/server/internal/wsapi/session.go new file mode 100644 index 0000000..95d17ab --- /dev/null +++ b/server/internal/wsapi/session.go @@ -0,0 +1,487 @@ +package wsapi + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "log/slog" + "sync" + "time" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/game" +) + +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 + // than grown without bound. + outboxCap = 32 + + // pingEvery / pingTimeout are the liveness check. Reads carry no deadline + // of their own: a player idling in the lobby between games is healthy and + // silent, and a read timeout cannot tell that apart from a dead socket. + // A ping can. + pingEvery = 20 * time.Second + pingTimeout = 10 * time.Second + pingMisses = 2 + + // writeTimeout bounds a single frame write, and drainTimeout the final + // flush of whatever is still queued when the session ends. + writeTimeout = 10 * time.Second + drainTimeout = 2 * time.Second + + // submitsPerSecond bounds word submissions. Each one is a dictionary + // lookup and a possible engine mutation; a human types far below this. + submitsPerSecond = 5 + submitBurst = 10 + + joinsPerSecond = 1 + joinBurst = 5 + + // Room creation is far more expensive than a join: each one is a + // goroutine, an engine and a registry entry held until the game ends. + roomsPerSecond = 0.2 + roomBurst = 5 + limiterIdleFor = 5 * time.Minute +) + +var errHandshake = errors.New("wsapi: first message must be Hello") + +// session is one WebSocket connection. +// +// Exactly one reader goroutine and one writer goroutine touch the socket. The +// mutex below guards only the small mutable identity — nickname, room, seat — +// that the reader sets and the room goroutine reads. +type session struct { + id string + resumeToken string + conn *websocket.Conn + hub *hub + + // remoteIP keys the join limiter. A session id is minted per connection, + // so keying on it would let anyone brute-force room codes by reconnecting + // between attempts — the limiter has to outlive the socket. + remoteIP string + + // ctx is the teardown signal for everything except the read. + ctx context.Context + cancel context.CancelFunc + + // readCtx is cancelled only after the writer has finished flushing. + // + // coder/websocket arms a context.AfterFunc on the context passed to Read + // that hard-closes the underlying socket when it fires, so cancelling the + // read context is the same as destroying the connection. Every frame + // queued at teardown — the shutdown notice above all — would be written + // into a socket that is already gone. + // + // It is rooted at Background rather than at the server context on purpose. + // A child of the server context would be cancelled by Shutdown at the same + // instant as ctx, which is precisely the ordering this exists to prevent. + // Nothing leaks: run always signals ctx, and the goroutine watching it + // always cancels this one. + readCtx context.Context + cancelRead context.CancelFunc + + out chan []byte + + // flushed closes when the writer has drained, so teardown can wait for the + // last frames to leave before tearing the socket down. + flushed chan struct{} + + mu sync.Mutex + nick string + room *room + playerID game.PlayerID + + submitLimiter *bucket + roomLimiter *bucket + + // greeted marks the handshake done. It is a one-shot transition: a second + // Hello would re-register the session and rewrite its nickname mid-game. + greeted bool + + closeOnce sync.Once +} + +func newSession(ctx context.Context, conn *websocket.Conn, h *hub, remoteIP string) *session { + readCtx, cancelRead := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(ctx) + return &session{ + readCtx: readCtx, + cancelRead: cancelRead, + flushed: make(chan struct{}), + id: randomToken(), + resumeToken: randomToken(), + remoteIP: remoteIP, + conn: conn, + hub: h, + ctx: ctx, + cancel: cancel, + out: make(chan []byte, outboxCap), + submitLimiter: newBucket(submitsPerSecond, submitBurst, time.Now()), + roomLimiter: newBucket(roomsPerSecond, roomBurst, time.Now()), + } +} + +func (s *session) nickname() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.nick +} + +func (s *session) setNickname(n string) { + s.mu.Lock() + defer s.mu.Unlock() + s.nick = n +} + +// attach binds this connection to a room seat. +func (s *session) attach(r *room, seatName string) { + s.mu.Lock() + defer s.mu.Unlock() + s.room = r + s.playerID = playerIDFor(seatName) +} + +func (s *session) currentRoom() (*room, game.PlayerID) { + s.mu.Lock() + defer s.mu.Unlock() + return s.room, s.playerID +} + +// send queues a message for the writer goroutine. +// +// Never blocks: the room goroutine calls this, and one unresponsive client +// must not be able to stall the game its opponent is still playing. A full +// outbox closes the session instead. +func (s *session) send(m *noituv1.ServerMessage) { + raw, err := Encode(m) + if err != nil { + slog.Error("encode failed", "session", s.id, "err", err) + return + } + + select { + case s.out <- raw: + case <-s.ctx.Done(): + default: + slog.Warn("outbox full, closing session", "session", s.id) + s.close() + } +} + +// close signals teardown. It does not cancel the read context: that is done by +// run once the writer has flushed, so a client is told why it is being +// disconnected before the socket goes. +func (s *session) close() { + s.closeOnce.Do(func() { + s.cancel() + }) +} + +// run drives the connection until it closes. +func (s *session) run() { + defer s.close() + defer s.leaveRoom() + + s.conn.SetReadLimit(maxFrameBytes) + + var wg sync.WaitGroup + wg.Add(3) + go func() { defer wg.Done(); s.writeLoop() }() + go func() { defer wg.Done(); s.keepalive() }() + + // Teardown ordering lives in its own goroutine because readLoop below is + // blocked in Read and cannot run it. Whoever signals the close — a + // protocol error here, a dead peer in keepalive, or Shutdown cancelling + // the server context — gets the same sequence: flush, then drop the + // socket. + go func() { + defer wg.Done() + <-s.ctx.Done() + select { + case <-s.flushed: + case <-time.After(drainTimeout): + } + s.cancelRead() + }() + + err := s.readLoop() + s.close() + wg.Wait() + + status, reason := websocket.StatusNormalClosure, "" + if err != nil && !errors.Is(err, context.Canceled) { + status, reason = websocket.StatusPolicyViolation, "protocol error" + } + _ = s.conn.Close(status, reason) +} + +// readLoop is the only reader of the socket. +func (s *session) readLoop() error { + for { + typ, raw, err := s.conn.Read(s.readCtx) + if err != nil { + return err + } + + msg, err := Decode(typ, raw) + if err != nil { + s.send(errorMsg("bad_frame")) + return err + } + if err := s.dispatch(msg); err != nil { + return err + } + } +} + +// writeLoop is the only writer of the socket. A single owner keeps frame order +// deterministic even though coder/websocket tolerates concurrent writes. +func (s *session) writeLoop() { + defer close(s.flushed) + + for { + select { + case <-s.ctx.Done(): + s.drain() + return + case raw := <-s.out: + if !s.write(raw) { + return + } + } + } +} + +// write sends one frame. +// +// The deadline is its own, not derived from the session context. A frame that +// has already been dequeued must still reach the peer even when the session is +// ending — refusals are sent immediately before a close, and inheriting the +// cancelled context would fail every one of them. The timeout is what protects +// against a peer that has stopped reading. +func (s *session) write(raw []byte) bool { + ctx, cancel := context.WithTimeout(context.Background(), writeTimeout) + defer cancel() + + if err := s.conn.Write(ctx, websocket.MessageBinary, raw); err != nil { + s.close() + return false + } + return true +} + +// drain flushes what is already queued after the session is cancelled. +// +// Refusals are the reason this exists: the server answers a bad handshake with +// a UI key and then closes, and without this the close wins the race and the +// client is left to guess why it was dropped. The context is fresh because +// s.ctx is by definition already cancelled here, and bounded because a peer +// that is not reading must not delay teardown. +func (s *session) drain() { + for { + select { + case raw := <-s.out: + ctx, cancel := context.WithTimeout(context.Background(), drainTimeout) + err := s.conn.Write(ctx, websocket.MessageBinary, raw) + cancel() + if err != nil { + return + } + default: + return + } + } +} + +// keepalive is what actually detects a dead peer, since reads have no deadline. +func (s *session) keepalive() { + ticker := time.NewTicker(pingEvery) + defer ticker.Stop() + + misses := 0 + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(s.ctx, pingTimeout) + err := s.conn.Ping(ctx) + cancel() + + if err == nil { + misses = 0 + continue + } + if misses++; misses >= pingMisses { + slog.Info("peer unresponsive, closing", "session", s.id) + s.close() + return + } + } + } +} + +// 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: + difficulty, ok := Difficulty(p.StartBotGame.GetDifficulty()) + if !ok { + s.send(errorMsg("unknown_difficulty")) + return nil + } + if !s.roomLimiter.allow(time.Now()) { + s.send(errorMsg("too_many_rooms")) + return nil + } + if err := s.hub.startBotRoom(s, difficulty); err != nil { + slog.Error("start bot room", "session", s.id, "err", err) + s.send(errorMsg("room_start_failed")) + } + + 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(errorMsg("room_start_failed")) + } + + case *noituv1.ClientMessage_JoinRoom: + if !s.hub.joinLimiter.allow(s.remoteIP, time.Now()) { + s.send(errorMsg("too_many_attempts")) + return nil + } + if err := s.hub.joinRoom(p.JoinRoom.GetRoomCode(), s); err != nil { + s.send(errorMsg("room_not_found")) + } + + 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_Ping: + s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli())) + } + return nil +} + +// 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) { + r, id := prior.currentRoom() + if r == nil { + s.send(errorMsg("game_already_over")) + return + } + s.attach(r, string(id)) + if !r.send(resumeInput{player: id, sess: s}) { + s.send(errorMsg("game_already_over")) + return + } + + // The old connection is finished: its token is spent and its socket is + // either gone or about to be. Leaving it registered would let a third + // connection claim the same seat. + s.hub.unregister(prior.resumeToken) + prior.close() +} + +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")) + } +} + +// 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) + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go new file mode 100644 index 0000000..811466e --- /dev/null +++ b/server/internal/wsapi/wsapi_test.go @@ -0,0 +1,913 @@ +package wsapi + +import ( + "context" + "errors" + "iter" + "net/http/httptest" + "runtime" + "slices" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/game" + "google.golang.org/protobuf/proto" +) + +// testDict is a hand-built word graph. +// +// A dozen words with edges a reader can follow beats a real dictionary here: +// when a test says the bot must lose, the reason is visible in the graph +// rather than buried in 48,000 entries. +type testDict struct { + // words maps a canonical word to its first and last syllable. + words map[string][2]string + opening string +} + +func newTestDict(opening string, words ...string) *testDict { + d := &testDict{words: map[string][2]string{}, opening: opening} + for _, w := range append(words, opening) { + parts := strings.Fields(w) + d.words[w] = [2]string{parts[0], parts[len(parts)-1]} + } + return d +} + +func (d *testDict) Resolve(word string) (string, bool) { + _, ok := d.words[word] + return word, ok +} + +func (d *testDict) FirstSyllable(word string) (string, bool) { + e, ok := d.words[word] + return e[0], ok +} + +func (d *testDict) LastSyllable(word string) (string, bool) { + e, ok := d.words[word] + return e[1], ok +} + +func (d *testDict) WordsStartingWith(syllable string) iter.Seq[string] { + return func(yield func(string) bool) { + // Sorted so a failing test reproduces rather than depending on map + // iteration order. + for _, w := range slices.Sorted(maps(d.words)) { + if d.words[w][0] == syllable && !yield(w) { + return + } + } + } +} + +func (d *testDict) OutDegree(syllable string) (int, error) { + n := 0 + for range d.WordsStartingWith(syllable) { + n++ + } + return n, nil +} + +func (d *testDict) RandomOpeningWord(int) (string, error) { return d.opening, nil } + +func maps(m map[string][2]string) iter.Seq[string] { + return func(yield func(string) bool) { + for k := range m { + if !yield(k) { + return + } + } + } +} + +// chainDict builds a graph that is a single forced path, so the whole game is +// predictable: each word has exactly one legal continuation. +// +// a b -> b c -> c d -> d e (dead end) +func chainDict() *testDict { + return newTestDict("a b", "b c", "c d", "d e") +} + +// --- test client ----------------------------------------------------------- + +type testClient struct { + t *testing.T + conn *websocket.Conn + ctx context.Context +} + +func newTestServer(t *testing.T, dict Dictionary, cfg Config) (*Server, string) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if cfg.TurnLimit == 0 { + cfg.TurnLimit = 2 * time.Second + } + if cfg.GraceFor == 0 { + cfg.GraceFor = time.Second + } + + api := NewServer(ctx, dict, cfg) + hs := httptest.NewServer(api) + t.Cleanup(func() { + api.Shutdown() + hs.Close() + }) + return api, "ws" + strings.TrimPrefix(hs.URL, "http") +} + +func dial(t *testing.T, url string) *testClient { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + conn, _, err := websocket.Dial(ctx, url+"/ws", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "") }) + + return &testClient{t: t, conn: conn, ctx: ctx} +} + +func (c *testClient) send(m *noituv1.ClientMessage) { + c.t.Helper() + raw, err := proto.Marshal(m) + if err != nil { + c.t.Fatalf("marshal: %v", err) + } + if err := c.conn.Write(c.ctx, websocket.MessageBinary, raw); err != nil { + c.t.Fatalf("write: %v", err) + } +} + +func (c *testClient) recv() *noituv1.ServerMessage { + c.t.Helper() + ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second) + defer cancel() + + typ, raw, err := c.conn.Read(ctx) + if err != nil { + c.t.Fatalf("read: %v", err) + } + if typ != websocket.MessageBinary { + c.t.Fatalf("expected a binary frame, got %v", typ) + } + var m noituv1.ServerMessage + if err := proto.Unmarshal(raw, &m); err != nil { + c.t.Fatalf("unmarshal: %v", err) + } + return &m +} + +// await reads until a message of the wanted case arrives, so a test states the +// event it cares about rather than every frame that precedes it. +func (c *testClient) await(want string) *noituv1.ServerMessage { + c.t.Helper() + for range 20 { + m := c.recv() + if payloadCase(m) == want { + return m + } + } + c.t.Fatalf("never received a %q message", want) + return nil +} + +func payloadCase(m *noituv1.ServerMessage) string { + switch m.GetPayload().(type) { + case *noituv1.ServerMessage_Welcome: + return "welcome" + case *noituv1.ServerMessage_RoomCreated: + return "room_created" + case *noituv1.ServerMessage_RoomJoined: + return "room_joined" + case *noituv1.ServerMessage_GameStarted: + return "game_started" + case *noituv1.ServerMessage_TurnUpdate: + return "turn_update" + case *noituv1.ServerMessage_MoveRejected: + return "move_rejected" + case *noituv1.ServerMessage_GameOver: + return "game_over" + case *noituv1.ServerMessage_OpponentLeft: + return "opponent_left" + case *noituv1.ServerMessage_Error: + return "error" + case *noituv1.ServerMessage_Pong: + return "pong" + } + return "" +} + +func (c *testClient) hello(nickname string) *noituv1.Welcome { + c.t.Helper() + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{ + ProtocolVersion: ProtocolVersion, + Nickname: nickname, + }}}) + return c.await("welcome").GetWelcome() +} + +func (c *testClient) submit(word string, seq uint32) { + c.t.Helper() + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SubmitWord{ + SubmitWord: &noituv1.SubmitWord{Word: word, TurnSeq: seq}, + }}) +} + +// --- 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) + } +} + +// 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_created").GetRoomCreated().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + + hostStart := host.await("game_started").GetGameStarted() + guestStart := guest.await("game_started").GetGameStarted() + + if hostStart.GetMyTurn() == guestStart.GetMyTurn() { + t.Fatal("exactly one player must be on turn") + } + if !hostStart.GetMyTurn() { + t.Fatal("the room creator takes the first turn") + } + + host.submit("b c", hostStart.GetTurnSeq()) + + // The same move, rendered per recipient: by_me flips, my_turn flips. + hostUpdate := host.await("turn_update").GetTurnUpdate() + guestUpdate := guest.await("turn_update").GetTurnUpdate() + + if !hostUpdate.GetPlayed().GetByMe() { + t.Error("mover should see by_me = true") + } + if guestUpdate.GetPlayed().GetByMe() { + t.Error("opponent should see by_me = false") + } + if hostUpdate.GetMyTurn() { + t.Error("mover should not be on turn after moving") + } + if !guestUpdate.GetMyTurn() { + t.Error("opponent should now be on turn") + } + if hostUpdate.GetMyScore() != guestUpdate.GetOpponentScore() { + t.Errorf("scores disagree across recipients: %d vs %d", + hostUpdate.GetMyScore(), guestUpdate.GetOpponentScore()) + } +} + +// 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_created").GetRoomCreated().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + + host.await("game_started") + guest.await("game_started") + + over := guest.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_created").GetRoomCreated().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + hostStart := host.await("game_started").GetGameStarted() + guest.await("game_started") + + // 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. + host.submit("b a", hostStart.GetTurnSeq()) + guestTurn := guest.await("turn_update").GetTurnUpdate() + + guest.submit("a b", guestTurn.GetTurnSeq()) + + got := guest.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_created").GetRoomCreated().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + host.await("game_started") + guest.await("game_started") + + _ = host.conn.Close(websocket.StatusGoingAway, "") + + if left := guest.await("opponent_left").GetOpponentLeft(); !left.GetCanReconnect() { + t.Error("opponent should be told the seat is being 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() { + t.Error("the resumed player was on turn and should still be") + } +} + +// 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_created").GetRoomCreated().GetRoomCode() + + guest := dial(t, url) + guest.hello("Khách") + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: code}, + }}) + 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​nh‍", "Minh"}, + {"bidi override stripped", "Minh‮", "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 — two players who both send nothing — and the one it always could: +// two players who choose the same name. +func TestDistinguishSeparatesIdenticalNames(t *testing.T) { + if got := distinguish(defaultNickname, defaultNickname); got == defaultNickname { + t.Error("two unnamed players must not render identically") + } + if got := distinguish("Minh", "Thuý"); got != "Minh" { + t.Errorf("distinct names should be left alone, got %q", got) + } + + // The suffix must not push the name past the cap. + long := strings.Repeat("a", maxNicknameRunes) + if got := distinguish(long, 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) + + if !b.allow(now) || !b.allow(now) { + t.Fatal("burst should cover the first two") + } + 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) + 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("turn_update") + botReply := c.await("turn_update").GetTurnUpdate() + c.submit("d e", botReply.GetTurnSeq()) + c.await("game_over") + _ = c.conn.Close(websocket.StatusNormalClosure, "") +} + +// settle waits for teardown to finish. Rooms and sessions end asynchronously, +// so sampling immediately would count goroutines that are already on their way +// out. +func settle() { + for range 20 { + runtime.Gosched() + time.Sleep(25 * time.Millisecond) + } + runtime.GC() +}