mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-13 02:14:48 +00:00
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
158 lines
5.7 KiB
Go
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())
|
|
}
|
|
}
|