mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-14 08:18:34 +00:00
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:
1 parent
48b3b3c8ac
commit
5935f5c879
15 files changed
+3486
-22
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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},
|
||||
}}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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", "Minh", "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()
|
||||
}
|
||||
Reference in new issue
Block a user