feat(online): make a room a lobby with an owner and a ready-up

A room used to be a wrapper around one game: joining started it, and the
room died with it unless both players accepted a rematch inside thirty
seconds. It is now a lobby that outlives its games.

Whoever created the room owns it and the other seat is the guest. The guest
readies and the owner starts; the owner has no readiness of their own,
because starting is the same statement. A finished game returns both to the
lobby, where the next one is agreed exactly as the last was — the readiness
that started a game is spent with it.

A guest takes their readiness back before leaving, which is deliberate
friction: a player the owner is waiting on should have to say so before
walking away. The owner can free the seat of a guest who is not ready, and
not of one who is — readiness is a commitment, not an inconvenience. An
owner who leaves hands the room to whoever is left, unreadied, because they
are the one who starts now.

Something has to bound a room that outlives its games: the last player out
closes it, as does ten minutes in a lobby nobody started a game in. A
dropped connection is still not a player leaving — the seat is held for the
reconnect window in the lobby as well as mid-game, so a refresh no longer
costs somebody their room, and a resume lands in the lobby it left.

The rematch handshake is retired, and RoomCreated and RoomJoined go with it.
All three described part of what RoomState now describes in full, and three
messages for one lobby is three ways for a client to hold a view of it the
server never had. One snapshot per recipient, broadcast from the one place
that knows an input is finished, so no handler can forget to send it.
This commit is contained in:
tiennm99 committed 2026-09-07 16:32:14 +07:00
1 parent ef638e52fd
commit d481a093ec
39 files changed
+2352 -1215

No files matched your search

+26 -12
View File
@@ -34,13 +34,21 @@ func startPvP(t *testing.T, url string) (host, guest *testClient, code string) {
host = dial(t, url)
host.hello("Chủ phòng")
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
code = host.await("room_created").GetRoomCreated().GetRoomCode()
code = host.await("room_state").GetRoomState().GetRoomCode()
guest = dial(t, url)
guest.hello("Khách")
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
}})
host.await("room_state")
guest.await("room_state")
// The lobby is where a game is agreed now: the guest readies and the owner
// starts it.
guest.setReady(true)
host.await("room_state")
host.startGame()
host.await("game_started")
guest.await("game_started")
return host, guest, code
@@ -114,7 +122,7 @@ func TestCannotJoinYourOwnRoom(t *testing.T) {
host := dial(t, url)
host.hello("Chủ phòng")
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
code := host.await("room_created").GetRoomCreated().GetRoomCode()
code := host.await("room_state").GetRoomState().GetRoomCode()
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
@@ -164,7 +172,7 @@ func TestAbandonedRoomIsEvicted(t *testing.T) {
host := dial(t, url)
host.hello("Chủ phòng")
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
host.await("room_created")
host.await("room_state")
_ = host.conn.Close(websocket.StatusGoingAway, "")
@@ -286,7 +294,7 @@ func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) {
host := dial(t, url)
host.hello("Chủ phòng")
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
code := host.await("room_created").GetRoomCreated().GetRoomCode()
code := host.await("room_state").GetRoomState().GetRoomCode()
hostile := " Kẻ" + string(nul) + " xấu" + string(zeroWidthSpace) + string(bidiOverride) +
" " + strings.Repeat("z", 40)
@@ -296,7 +304,7 @@ func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) {
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
}})
shown := host.await("room_joined").GetRoomJoined().GetOpponentName()
shown := host.await("room_state").GetRoomState().GetOpponentName()
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)
@@ -343,10 +351,11 @@ func TestStaleRejectionCarriesTheServerSequence(t *testing.T) {
}
}
// TestResumeAfterGameEndedSaysSo. GraceFor is longer than TurnLimit, so a
// player who drops on their own turn loses before the grace window closes.
// Their reconnect must be told that, not left holding a Welcome and silence.
func TestResumeAfterGameEndedSaysSo(t *testing.T) {
// TestResumeAfterGameEndedLandsInTheLobby. GraceFor is longer than TurnLimit,
// so a player who drops on their own turn loses before the grace window
// closes. Their reconnect must land somewhere real - the lobby the room went
// back to - rather than holding a Welcome and silence.
func TestResumeAfterGameEndedLandsInTheLobby(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{
TurnLimit: 200 * time.Millisecond,
GraceFor: 5 * time.Second,
@@ -355,13 +364,14 @@ func TestResumeAfterGameEndedSaysSo(t *testing.T) {
host := dial(t, url)
welcome := host.hello("Chủ phòng")
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
code := host.await("room_created").GetRoomCreated().GetRoomCode()
code := host.await("room_state").GetRoomState().GetRoomCode()
guest := dial(t, url)
guest.hello("Khách")
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
}})
readyAndStart(t, host, guest)
host.await("game_started")
guest.await("game_started")
@@ -377,8 +387,12 @@ func TestResumeAfterGameEndedSaysSo(t *testing.T) {
}}})
back.await("welcome")
if got := back.await("error").GetError().GetCode(); got != "game_already_over" {
t.Errorf("resume into a finished game returned %q", got)
state := back.await("room_state").GetRoomState()
if state.GetRoomCode() != code {
t.Errorf("resumed into room %q, want %q", state.GetRoomCode(), code)
}
if !state.GetOpponentPresent() {
t.Errorf("the player who stayed is missing from the resumed lobby: %+v", state)
}
}