mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-12 20:15:50 +00:00
A held seat now remembers which connection it waits for, so a token from a kicked player is refused instead of landing in whoever took the seat. A room answers every input left in its inbox when it exits, disconnect notices no longer share the lossy inbox, quick-match no longer leaves autoStart armed after a pairing that never started, only lobby changes restart the idle window, and draining refuses new queue entries. Sockets that never send Hello close after ten seconds and the room budget is charged per address, so one client cannot hold the global caps. IPv6 limiter keys use the /64. Corpus log lines get a process-wide rate limit with a suppressed counter. Responses carry nosniff, frame-ancestors and referrer headers. Unicode spaces in a word become spaces rather than vanishing, and blank-rendering letters are dropped from names and chat. A resume into a lobby whose game ended during the absence is replayed that seat's GameOver. Unknown payloads get unknown_message.
529 lines
18 KiB
Go
529 lines
18 KiB
Go
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.joinRoom("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.createRoom()
|
|
code := host.await("room_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.joinRoom(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.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.joinRoom(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)
|
|
}
|
|
|
|
agreeAndStart(host, guest)
|
|
|
|
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.
|
|
agreeAndStart(host, guest)
|
|
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.joinRoom(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.joinRoom(code)
|
|
guest.await("room_state")
|
|
third.await("room_state")
|
|
agreeAndStart(guest, third)
|
|
|
|
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.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.createRoom()
|
|
c.await("room_state")
|
|
}
|
|
|
|
_ = c.conn.Close(websocket.StatusNormalClosure, "")
|
|
|
|
awaitNoRooms(t, api, "rooms that outlived the only connection that was ever in them")
|
|
}
|
|
|
|
// TestNoOtherRoomMidGame guards a player walking out of a running game by
|
|
// opening or joining another room: every room-opening message is refused with
|
|
// already_in_a_game, the player keeps their seat, and the same requests are
|
|
// allowed again once that game is over.
|
|
func TestNoOtherRoomMidGame(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, start := pvpRoom(t, url)
|
|
|
|
other := dial(t, url)
|
|
other.hello("Phòng bên")
|
|
other.createRoom()
|
|
otherCode := other.await("room_state").GetRoomState().GetRoomCode()
|
|
|
|
attempts := map[string]func(){
|
|
"create": host.createRoom,
|
|
"join": func() { host.joinRoom(otherCode) },
|
|
"quickMatch": host.quickMatch,
|
|
"botGame": func() {
|
|
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
|
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
|
}})
|
|
},
|
|
}
|
|
for name, attempt := range attempts {
|
|
attempt()
|
|
if got := host.await("error").GetError().GetCode(); got != "already_in_a_game" {
|
|
t.Errorf("%s mid-game returned %q, want already_in_a_game", name, got)
|
|
}
|
|
}
|
|
|
|
// Still seated: the game plays out to its end with the host in it.
|
|
resignAndSettle(t, host, guest, start)
|
|
|
|
host.createRoom()
|
|
if code := host.await("room_state").GetRoomState().GetRoomCode(); code == "" || code == otherCode {
|
|
t.Errorf("create after the game returned room %q, want a fresh room", code)
|
|
}
|
|
}
|
|
|
|
// TestResignInTheLobbyIsRefused guards a resignation sent before any game
|
|
// has started: it is answered with game_not_started, the way a dead-end claim
|
|
// there is, rather than dropped without a word.
|
|
func TestResignInTheLobbyIsRefused(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, _, _ := pvpLobby(t, url)
|
|
|
|
host.resign()
|
|
if got := host.await("error").GetError().GetCode(); got != "game_not_started" {
|
|
t.Errorf("resigning in the lobby returned %q, want game_not_started", got)
|
|
}
|
|
}
|