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