Files
tiennm99 55abdb36c1 refactor(wsapi): dedupe routing and seating, fix ordering races
Route room inputs through one toRoom helper, iterate live seats with
connected(), and share seating and reconnect-window code. Declare every
error code once in errcodes.go, which the web vocabulary test now reads.

Fixes:
- a kicked player is released before being told, so their next action
  is answered not_in_a_room
- a rejected word is counted before move_rejected is sent
- room code draw and registration share one lock hold
- two concurrent resumes with one token can no longer both take a seat
- multiplayer tests ready guests one at a time instead of racing

Behaviour changes:
- opening or joining another room while a game is running is refused
  with already_in_a_game
- resigning in the lobby answers game_not_started
2026-09-28 15:19:59 +07:00

158 lines
5.7 KiB
Go

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.createRoom()
code := host.await("room_state").GetRoomState().GetRoomCode()
guest := dial(t, url)
guest.hello("Khách")
guest.joinRoom(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())
}
}