Files
noitu/server/internal/wsapi/multiplayer_test.go
T
tiennm99 4e2e9e1433 feat(online)!: seat two to four players in a room
A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.

Failing a turn eliminates that player rather than ending the game. The
syllable and the used words survive them, the turn passes to whoever is
next, and the last player standing wins. Two seats is that same rule seen
from close up, which is why there is one implementation of it and not two.

A dead end still costs the first player to face it their own clock, as
before: they get their turn, and lose it. Everyone behind them has already
seen that board, so they go out together rather than each sitting out a turn
limit they cannot use — which leaves the player who closed the position
standing, the same outcome two players get.

A player who is knocked out keeps their seat. They watch the rest of the
game, chat included, with only the word input gone, and everybody lands back
in the same lobby when it ends. The result screen is the whole table, ranked
by who outlasted whom, with each score reported beside the place rather than
deciding it.

The turn clock is deliberately not paused for a seat that has dropped, so a
player who loses their connection on their own turn loses it the way anybody
else would. Their reconnect window decides only whether they are still in the
game afterwards. Any number of windows can be open at once, settled by one
timer armed for the nearest of them.

Starting waits for every guest, not merely the first: a room of four that
began on one yes would have dealt three people a turn they never agreed to.
Kicking names a seat and is still refused on a player who is ready, and on
the owner's own — leaving is what an owner who wants out does, and it hands
the room on. Joining stays a lobby thing: a room with a game running turns a
latecomer away even with seats going spare, because there is no way to hand
somebody a game already in progress.

BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that
could only ever describe a second player, OpponentLeft is retired in favour
of presence on RoomState, and suggestions move to the new PlayerEliminated —
they describe the position that beat a player, which by the end of a longer
game is nobody else's position. ProtocolVersion goes to 2, so a client built
against 1 is refused with a readable error rather than decoding a frame that
now means something else.
2026-09-08 10:56:14 +07:00

313 lines
10 KiB
Go

package wsapi
import (
"testing"
"time"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
)
// What a room of more than two people does that a pair does not: seat up to
// four, refuse the fifth, wait for every guest before it starts, kick by name,
// and outlive the first player it knocks out.
// roomOf seats n players and leaves them in the lobby. The first is the owner.
//
// It returns the owner's last view of the room as well as the clients: every
// room_state the seating produced has been read by then, so a caller that
// awaited another would wait for one nothing is going to send.
func roomOf(t *testing.T, url string, n int) (clients []*testClient, state *noituv1.RoomState) {
t.Helper()
names := []string{"Chủ phòng", "Khách", "Người thứ ba", "Người thứ tư", "Người thứ năm"}
host := dial(t, url)
host.hello(names[0])
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
state = host.await("room_state").GetRoomState()
clients = append(clients, host)
for i := 1; i < n; i++ {
c := dial(t, url)
c.hello(names[i])
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: state.GetRoomCode()},
}})
clients = append(clients, c)
// Everybody seated sees the arrival, which is also what keeps each
// client's inbox drained before the next assertion reads from it.
for _, seated := range clients {
got := seated.await("room_state").GetRoomState()
if seated == host {
state = got
}
}
}
return clients, state
}
func TestARoomSeatsFourAndRefusesTheFifth(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
_, state := roomOf(t, url, maxPlayers)
if got := len(state.GetPlayers()); got != maxPlayers {
t.Fatalf("the room holds %d players, want %d", got, maxPlayers)
}
if state.GetMaxPlayers() != maxPlayers || state.GetMinPlayers() != minPlayers {
t.Errorf("limits = %d/%d, want %d/%d",
state.GetMinPlayers(), state.GetMaxPlayers(), minPlayers, maxPlayers)
}
// Exactly one row is the recipient's, and exactly one is the owner's.
me, owners := 0, 0
for _, p := range state.GetPlayers() {
if p.GetIsMe() {
me++
}
if p.GetIsOwner() {
owners++
}
}
if me != 1 || owners != 1 {
t.Errorf("the room has %d rows marked mine and %d marked owner, want 1 of each", me, owners)
}
fifth := dial(t, url)
fifth.hello("Người thứ năm")
fifth.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: state.GetRoomCode()},
}})
if got := fifth.await("error").GetError().GetCode(); got != "room_full" {
t.Errorf("the fifth joiner got %q, want room_full", got)
}
}
// Every guest, not merely the first: a room of four that starts on one yes has
// dealt three people a turn they never agreed to.
func TestStartWaitsForEveryGuest(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
clients, _ := roomOf(t, url, 3)
host, second, third := clients[0], clients[1], clients[2]
second.setReady(true)
for _, c := range clients {
c.await("room_state")
}
host.startGame()
if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" {
t.Fatalf("starting with one guest ready returned %q, want not_everyone_ready", got)
}
third.setReady(true)
for _, c := range clients {
state := c.await("room_state").GetRoomState()
if !state.GetCanStart() {
t.Errorf("can_start is false with everybody ready: %+v", state)
}
}
host.startGame()
for _, c := range clients {
start := c.await("game_started").GetGameStarted()
if got := len(start.GetPlayers()); got != 3 {
t.Errorf("the game was dealt to %d players, want 3", got)
}
if start.GetTurnPlayerId() != "p1" {
t.Errorf("turn_player_id = %q, want p1 — seat order is turn order", start.GetTurnPlayerId())
}
}
}
func TestKickNamesASeat(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
clients, _ := roomOf(t, url, 3)
host, second, third := clients[0], clients[1], clients[2]
// The owner cannot free their own seat: leaving is what an owner who wants
// out does, and it hands the room on rather than dropping it.
host.kickPlayer("p1")
if got := host.await("error").GetError().GetCode(); got != "cannot_kick_self" {
t.Errorf("kicking themselves returned %q, want cannot_kick_self", got)
}
// Readiness is a commitment, per target rather than per room.
second.setReady(true)
for _, c := range clients {
c.await("room_state")
}
host.kickPlayer("p2")
if got := host.await("error").GetError().GetCode(); got != "player_is_ready" {
t.Errorf("kicking a ready player returned %q, want player_is_ready", got)
}
// The unready one goes, and the ready one is untouched.
host.kickPlayer("p3")
if got := third.await("error").GetError().GetCode(); got != "kicked" {
t.Fatalf("the kicked player was told %q", got)
}
state := host.await("room_state").GetRoomState()
if got := len(state.GetPlayers()); got != 2 {
t.Fatalf("the room holds %d players after one kick, want 2", got)
}
if slotFor(state, "p3") != nil {
t.Error("the kicked seat is still occupied")
}
if seat := slotFor(state, "p2"); seat == nil || !seat.GetReady() {
t.Error("kicking one player disturbed another's readiness")
}
}
// The room outlives the first player it knocks out: the rest carry on from the
// same syllable, and the player who went out stays to watch.
func TestAGameOutlivesItsFirstElimination(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
clients, _ := roomOf(t, url, 3)
host, second, third := clients[0], clients[1], clients[2]
second.setReady(true)
third.setReady(true)
for _, c := range clients {
c.await("room_state")
}
host.startGame()
starts := map[*testClient]*noituv1.GameStarted{}
for _, c := range clients {
starts[c] = c.await("game_started").GetGameStarted()
}
// The player on turn gives up. Two are left, so the game does not end.
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
for _, c := range clients {
out := c.await("player_eliminated").GetPlayerEliminated()
if out.GetPlayerId() != "p1" {
t.Errorf("%q went out, want p1", out.GetPlayerId())
}
if got := out.GetIsMe(); got != (c == host) {
t.Errorf("is_me = %v for the wrong recipient", got)
}
if c != host && len(out.GetSuggestions()) != 0 {
t.Errorf("a player who is still in was sent suggestions %v", out.GetSuggestions())
}
}
// A turn update with no word: the position survived the player who left it.
update := second.await("turn_update").GetTurnUpdate()
if update.GetPlayed() != nil {
t.Error("an elimination reported a word as played")
}
if update.GetCurrentSyllable() != starts[second].GetCurrentSyllable() {
t.Errorf("the syllable moved on an elimination: %q -> %q",
starts[second].GetCurrentSyllable(), update.GetCurrentSyllable())
}
if update.GetTurnPlayerId() != "p2" || !update.GetMyTurn() {
t.Errorf("turn went to %q, want p2", update.GetTurnPlayerId())
}
if update.GetTurnSeq() == starts[second].GetTurnSeq() {
t.Error("the turn sequence did not move, so a stale submission could still land")
}
// The player who went out is still in the room and still being told what
// is happening in it.
watching := host.await("turn_update").GetTurnUpdate()
if watching.GetMyTurn() {
t.Error("an eliminated player was dealt a turn")
}
var eliminated bool
for _, p := range watching.GetPlayers() {
if p.GetPlayerId() == "p1" {
eliminated = p.GetEliminated()
}
}
if !eliminated {
t.Error("the table does not show the eliminated player as out")
}
// And they can still talk, which is the other half of staying in the room.
host.say("chúc may mắn")
if got := second.await("chat_message").GetChatMessage().GetText(); got != "chúc may mắn" {
t.Errorf("an eliminated player's message arrived as %q", got)
}
}
// The last elimination ends it, and everybody is shown the same table from
// their own side.
func TestStandingsReachEverySeat(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
clients, _ := roomOf(t, url, 3)
host, second, third := clients[0], clients[1], clients[2]
second.setReady(true)
third.setReady(true)
for _, c := range clients {
c.await("room_state")
}
host.startGame()
for _, c := range clients {
c.await("game_started")
}
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
for _, c := range clients {
c.await("player_eliminated")
}
second.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
winners := 0
for _, c := range clients {
over := c.await("game_over").GetGameOver()
if over.GetIWon() {
winners++
}
standings := over.GetStandings()
if len(standings) != 3 {
t.Fatalf("standings has %d rows, want 3", len(standings))
}
// Finishing order: the survivor, then the players who went out, latest
// first. Rank matches position, so the two cannot drift apart.
want := []string{"p3", "p2", "p1"}
mine := 0
for i, row := range standings {
if row.GetPlayerId() != want[i] {
t.Errorf("rank %d is %q, want %q", i+1, row.GetPlayerId(), want[i])
}
if int(row.GetRank()) != i+1 {
t.Errorf("%q has rank %d at position %d", row.GetPlayerId(), row.GetRank(), i+1)
}
if row.GetIsMe() {
mine++
}
}
if mine != 1 {
t.Errorf("%d standings rows are marked mine, want 1", mine)
}
}
if winners != 1 {
t.Errorf("%d players were told they won, want 1", winners)
}
}
// Two people dropping at once is two windows, not one. The room has to hold
// both seats and settle each on its own deadline.
func TestSeveralReconnectWindowsRunAtOnce(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{GraceFor: 300 * time.Millisecond})
clients, _ := roomOf(t, url, 3)
host, second, third := clients[0], clients[1], clients[2]
second.conn.CloseNow()
third.conn.CloseNow()
// Both seats are held first, then both are freed. The owner is the one
// still here to watch it happen.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
state := host.await("room_state").GetRoomState()
if len(state.GetPlayers()) == 1 {
return
}
}
t.Error("the room never freed the seats whose windows had run out")
}