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.
This commit is contained in:
tiennm99 committed 2026-09-05 12:07:57 +07:00
1 parent 48b3b3c8ac
commit 5935f5c879
15 files changed
+3486 -22

No files matched your search

+36
View File
@@ -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 |
@@ -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.
+29 -1
View File
@@ -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.
+156
View File
@@ -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
}
+1
View File
@@ -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
+2
View File
@@ -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=
+99
View File
@@ -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},
}}
}
+241
View File
@@ -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) }
+81
View File
@@ -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
}
+99
View File
@@ -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)
}
}
}
+378
View File
@@ -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)
}
}
}
+722
View File
@@ -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)
}
+162
View File
@@ -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)
}
}
}
+487
View File
@@ -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)
}
+913
View File
@@ -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()
}