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.
This commit is contained in:
tiennm99 committed 2026-09-08 10:56:14 +07:00
1 parent 0b507c3537
commit 4e2e9e1433
54 files changed
+3692 -1129

No files matched your search

+27 -11
View File
@@ -109,8 +109,11 @@ func TestStrangerCannotResignForASeatedPlayer(t *testing.T) {
stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
}})
if got := stranger.await("error").GetError().GetCode(); got != "room_full" {
t.Fatalf("third joiner got %q, want room_full", got)
// Not room_full: a four-seat room with two people in it has seats going
// spare. Arriving in the middle of a game is what is refused, and a
// stranger holding the code is refused it like anybody else.
if got := stranger.await("error").GetError().GetCode(); got != "game_in_progress" {
t.Fatalf("third joiner got %q, want game_in_progress", got)
}
stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
@@ -244,6 +247,15 @@ func TestPvPGameRunsToAWinner(t *testing.T) {
hostTurn := awaitMyTurn(t, host)
host.submit("d e", hostTurn.GetTurnSeq())
// The elimination comes first and carries what the position had left, so
// it has to be read before the result it caused.
guestOut := guest.await("player_eliminated").GetPlayerEliminated()
hostOut := host.await("player_eliminated").GetPlayerEliminated()
if !guestOut.GetIsMe() || hostOut.GetIsMe() {
t.Error("is_me should be true only for the player who went out")
}
hostOver := host.await("game_over").GetGameOver()
guestOver := guest.await("game_over").GetGameOver()
@@ -258,12 +270,14 @@ func TestPvPGameRunsToAWinner(t *testing.T) {
t.Errorf("reason = %v, want NO_LEGAL_MOVE", hostOver.GetReason())
}
// Nothing could have been played, and the empty list is how the loser is
// told so. The winner is never sent one.
if got := guestOver.GetSuggestions(); len(got) != 0 {
// Nothing could have been played, and the empty list is how the player who
// was stuck is told so. It rides on the elimination rather than the
// result: it describes the position they were looking at, which by the end
// of a longer game is nobody else's position.
if got := guestOut.GetSuggestions(); len(got) != 0 {
t.Errorf("the losing player was offered %v out of a dead end, want nothing", got)
}
if got := hostOver.GetSuggestions(); len(got) != 0 {
if got := hostOut.GetSuggestions(); len(got) != 0 {
t.Errorf("the winner was sent suggestions %v, want none", got)
}
}
@@ -278,16 +292,18 @@ func TestLosingPlayerIsToldWhatCouldHaveBeenPlayed(t *testing.T) {
// The opening is "a b", so "b c" is still there to be played.
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
guestOut := guest.await("player_eliminated").GetPlayerEliminated()
hostOut := host.await("player_eliminated").GetPlayerEliminated()
guestOver := guest.await("game_over").GetGameOver()
hostOver := host.await("game_over").GetGameOver()
if guestOver.GetIWon() {
t.Fatal("the player who resigned was told they won")
}
if got := guestOver.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
if got := guestOut.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
t.Errorf("suggestions = %v, want [b c]", got)
}
if got := hostOver.GetSuggestions(); len(got) != 0 {
if got := hostOut.GetSuggestions(); len(got) != 0 {
t.Errorf("the winner was sent suggestions %v, want none", got)
}
}
@@ -325,7 +341,7 @@ func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) {
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
}})
shown := host.await("room_state").GetRoomState().GetOpponentName()
shown := otherSlot(host.await("room_state").GetRoomState()).GetName()
for _, r := range []rune{nul, zeroWidthSpace, bidiOverride} {
if strings.ContainsRune(shown, r) {
t.Errorf("opponent name %q still carries the invisible rune %U", shown, r)
@@ -412,7 +428,7 @@ func TestResumeAfterGameEndedLandsInTheLobby(t *testing.T) {
if state.GetRoomCode() != code {
t.Errorf("resumed into room %q, want %q", state.GetRoomCode(), code)
}
if !state.GetOpponentPresent() {
if otherSlot(state) == nil {
t.Errorf("the player who stayed is missing from the resumed lobby: %+v", state)
}
}