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