diff --git a/README.md b/README.md
index ea985ce..0ddf887 100644
--- a/README.md
+++ b/README.md
@@ -52,14 +52,26 @@ rather than against each other's assumptions. Regenerate the fixtures with
A player creates a room and gets a six-character code and an invite link. The
alphabet omits `0`/`O` and `1`/`I`/`L`, because these codes get read aloud. The
-other player types the code or opens the link, which joins on arrival.
+other player types the code or opens the link, which seats them in the room's
+lobby.
+
+The room is a lobby that outlives its games. Whoever created it owns it; the
+other seat is the guest. The guest readies, the owner starts — the owner has no
+readiness of their own, because starting is the same statement — and a finished
+game returns both to the lobby, where the next one is agreed the same way. A
+guest must take their readiness back before leaving, and the owner can free the
+seat of a guest who is not ready. An owner who leaves hands the room to whoever
+is left, and the last player out closes it, as does ten minutes with no game
+started.
+
+The whole lobby travels as one `RoomState` per recipient — roles, readiness,
+presence — so a client that missed a frame is correct again from the next one
+rather than from a stream of deltas it has to replay.
Both players see the other's server-sanitized nickname, never the raw input. A
disconnect holds the seat for a grace window and shows the opponent a countdown;
a return inside it resumes the same position, rebuilt from the engine rather
-than from a recorded stream. When a game ends, either player may ask for a
-rematch and the room restarts with a new opening word once both agree. Leaving
-is how a rematch is declined — there is no separate message for it.
+than from a recorded stream, or the lobby when no game is running.
### The frontend
diff --git a/docs/deployment.md b/docs/deployment.md
index 1ba83eb..8bc62c9 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -22,9 +22,10 @@ so the image runs with nothing set.
An invalid duration is logged and ignored rather than silently changing the
rules of the game.
-One timing is not configurable: a finished online room waits 30 seconds for
-both players to ask for a rematch, then closes. It is a fixed constant because
-nothing about a deployment should change how long two people have to agree.
+One timing is not configurable: an online room closes after 10 minutes in its
+lobby with no game started. It is a fixed constant because nothing about a
+deployment should change how long two people have to agree on a game, and a
+running game is bounded by the turn clock rather than by this.
The image sets `NOITU_ADDR`, `NOITU_DB_PATH` and `NOITU_WEB_DIR` for you.
diff --git a/plans/260907-1551-room-lobby-ready-system/plan.md b/plans/260907-1551-room-lobby-ready-system/plan.md
new file mode 100644
index 0000000..f290673
--- /dev/null
+++ b/plans/260907-1551-room-lobby-ready-system/plan.md
@@ -0,0 +1,70 @@
+# Room lobby with owner/guest roles and ready-up
+
+**Status:** delivered
+
+## Outcome
+
+An online room is a persistent lobby rather than a wrapper around one game. The
+first occupant is the owner, the second is the guest. The guest readies; the
+owner starts once they are; a finished game returns both to the lobby, where the
+guest must ready again. Players can leave, the owner can kick an unready guest,
+and an owner who leaves hands the room to whoever is left.
+
+## Decisions taken
+
+- **The owner has no ready flag.** Pressing Start *is* their readiness, so only
+ the guest can be ready, blocked from leaving, or kicked.
+- **Room lifetime:** closes when the last seat empties, or after 10 minutes in
+ the lobby with no game started. A running game is bounded by the turn clock.
+- **The rematch handshake is retired.** `RequestRematch`/`RematchState` stop
+ being sent and their proto tags are reserved; the lobby is the only place a
+ next game is negotiated.
+- **A kick only frees the seat.** The kicked player may rejoin with the code.
+
+## Constraints
+
+- Proto changes are additive; retired tags go to `reserved`, never reused.
+- The engine stays two-player; `seats [2]*seat` stays the room's shape.
+- Readiness, roles, start and kick are server state. The client renders one
+ snapshot message and derives nothing.
+- Only the room goroutine touches room state; new actions arrive as inputs.
+- Every lobby action authorizes against the seat, not the claimed id: a room
+ code is a shared secret by design.
+- Vietnamese copy stays in `web/src/lib/i18n/vi.js`; error codes stay UI keys.
+
+## Non-goals
+
+Bot games (start immediately, no readiness), spectators, more than two seats,
+kick bans, accounts, and resuming into a room whose game already finished.
+
+## Acceptance criteria
+
+1. A guest who joins sees a lobby, not a board. No engine exists until Start.
+2. Start is refused unless the guest is seated, connected and ready.
+3. Game over returns both to the lobby with the guest unreadied; a second game
+ needs a fresh ready.
+4. Leave while ready is refused; leave while unready frees the seat and the room
+ survives.
+5. Kicking a ready guest is refused; kicking an unready one frees the seat and
+ tells them why.
+6. An owner who leaves, or drops for good, promotes the other player, who is
+ unreadied and can start once someone joins.
+7. The last player out closes the room; an idle lobby closes on the TTL.
+8. A reconnect lands back in the lobby with roles and readiness intact.
+9. Start racing an unready or a leave, and a kick racing a ready, resolve to an
+ error for the loser and never a half-started game.
+
+## Phases
+
+1. **Wire contract.** `SetReady`, `StartGame`, `KickPlayer`, `LeaveRoom`,
+ `RoomState`; reserve the rematch tags; regenerate both trees; extend the
+ cross-language fixtures.
+2. **Room lifecycle.** Owner/guest roles, ready flag, start, kick, leave,
+ promotion, idle TTL, resume into a lobby, retire the rematch path. Route and
+ rate-limit the new messages in `session.go`.
+3. **Client.** A `lobby` phase in the game store driven by `RoomState`, a Lobby
+ component, retire `RematchPrompt`, copy, and the `/online` route wiring.
+4. **Tests.** Room tests for each acceptance criterion, store tests for the new
+ phase, e2e for join → ready → start → play → back to lobby, leave, kick and
+ promotion.
+5. **Docs.** The online-play section of the README.
diff --git a/proto/noitu/v1/game.proto b/proto/noitu/v1/game.proto
index 6d7b87d..1e02fe2 100644
--- a/proto/noitu/v1/game.proto
+++ b/proto/noitu/v1/game.proto
@@ -85,12 +85,28 @@ message SubmitWord {
message Resign {}
-// RequestRematch asks to play again in the same room after a game ends.
+// SetReady is the guest declaring themselves ready, or taking it back.
//
-// There is no matching decline: leaving the room is the decline, and the
-// server already learns about that from the socket closing. One message and
-// one timeout cover every way a rematch does not happen.
-message RequestRematch {}
+// Only the guest has a readiness to set. The owner's is implied by StartGame:
+// asking for the game to begin is the same statement, and a second flag they
+// would always have to set first buys nothing.
+message SetReady {
+ bool ready = 1;
+}
+
+// StartGame is the owner beginning the game the lobby has agreed on. It is
+// refused unless the guest is seated, connected and ready.
+message StartGame {}
+
+// KickPlayer is the owner freeing the guest's seat. Refused while the guest is
+// ready: readiness is a commitment, and a player who has made it is not
+// something the owner gets to overrule.
+message KickPlayer {}
+
+// LeaveRoom gives up a seat without dropping the connection, which is what
+// makes a room outlive one game rather than one visit. Refused while the
+// sender is ready — unreadying first is the deliberate friction.
+message LeaveRoom {}
// Ping echoes the client clock so Pong can expose the offset between the two.
message Ping {
@@ -98,6 +114,11 @@ message Ping {
}
message ClientMessage {
+ // 8 was RequestRematch, retired with the rematch handshake: the lobby is
+ // where a next game is agreed now. Reserved at message level because a
+ // oneof cannot hold the statement itself.
+ reserved 8;
+
oneof payload {
Hello hello = 1;
StartBotGame start_bot_game = 2;
@@ -106,7 +127,10 @@ message ClientMessage {
SubmitWord submit_word = 5;
Resign resign = 6;
Ping ping = 7;
- RequestRematch request_rematch = 8;
+ SetReady set_ready = 9;
+ StartGame start_game = 10;
+ KickPlayer kick_player = 11;
+ LeaveRoom leave_room = 12;
}
}
@@ -123,17 +147,6 @@ message Welcome {
string accepted_nickname = 4;
}
-message RoomCreated {
- string room_code = 1;
-}
-
-message RoomJoined {
- string room_code = 1;
- // Always server-sanitized. A client must never render another player's raw
- // input.
- string opponent_name = 2;
-}
-
// PlayedWord is one accepted move. word is the canonical spelling, which can
// differ from what the player typed; typed preserves the raw input so the UI
// can show that a correction happened instead of silently rewriting the text.
@@ -208,23 +221,43 @@ message Pong {
int64 server_time_ms = 2;
}
-// RematchState is sent to both players whenever either one asks for a rematch,
-// and once when the offer opens. Each recipient is told about their own side
-// and their opponent's, so neither client has to work out which acceptance is
-// whose.
-message RematchState {
- bool i_accepted = 1;
- bool opponent_accepted = 2;
- // How long is left to accept. The room closes when this runs out, which is
- // also what a player who simply leaves ends up doing.
- uint32 expires_in_ms = 3;
+// RoomState is the whole lobby, rendered for one recipient, and it is the only
+// thing the lobby screen is built from. Sent on every change a player could
+// see — a seat filled or freed, a readiness set, an owner promoted — and again
+// on resume, so a client that missed a frame recovers by being told the state
+// rather than by replaying the events that led to it.
+//
+// The seat that is absent is reported as an unoccupied opponent rather than by
+// omitting the field, so "alone in the room" and "opponent still loading" are
+// never the same frame.
+message RoomState {
+ string room_code = 1;
+ // True for the player who may start the game and kick the other.
+ bool i_am_owner = 2;
+ // Whether StartGame would be accepted right now. The server decides this
+ // because it owns every condition that feeds it.
+ bool can_start = 3;
+ // The recipient's own readiness. Always false for the owner, whose readiness
+ // is StartGame itself.
+ bool i_am_ready = 4;
+ // False when the other seat is empty; the fields below are then meaningless.
+ bool opponent_present = 5;
+ // Always server-sanitized, as everywhere else another player's name appears.
+ string opponent_name = 6;
+ bool opponent_ready = 7;
+ // False while the other player is inside their reconnect window.
+ bool opponent_connected = 8;
}
message ServerMessage {
+ // 2 was RoomCreated and 3 was RoomJoined: both said part of what RoomState
+ // now says in full, and three messages describing one lobby is three ways
+ // for a client to hold a different view of it. 11 was RematchState, retired
+ // with the rematch handshake.
+ reserved 2, 3, 11;
+
oneof payload {
Welcome welcome = 1;
- RoomCreated room_created = 2;
- RoomJoined room_joined = 3;
GameStarted game_started = 4;
TurnUpdate turn_update = 5;
MoveRejected move_rejected = 6;
@@ -232,6 +265,6 @@ message ServerMessage {
OpponentLeft opponent_left = 8;
ServerError error = 9;
Pong pong = 10;
- RematchState rematch_state = 11;
+ RoomState room_state = 12;
}
}
diff --git a/proto/testdata/client_kick_player.bin b/proto/testdata/client_kick_player.bin
new file mode 100644
index 0000000..839cc09
Binary files /dev/null and b/proto/testdata/client_kick_player.bin differ
diff --git a/proto/testdata/client_leave_room.bin b/proto/testdata/client_leave_room.bin
new file mode 100644
index 0000000..28eacf2
Binary files /dev/null and b/proto/testdata/client_leave_room.bin differ
diff --git a/proto/testdata/client_request_rematch.bin b/proto/testdata/client_request_rematch.bin
deleted file mode 100644
index 962fc92..0000000
Binary files a/proto/testdata/client_request_rematch.bin and /dev/null differ
diff --git a/proto/testdata/client_set_ready.bin b/proto/testdata/client_set_ready.bin
new file mode 100644
index 0000000..5e3650f
--- /dev/null
+++ b/proto/testdata/client_set_ready.bin
@@ -0,0 +1 @@
+J
\ No newline at end of file
diff --git a/proto/testdata/client_start_game.bin b/proto/testdata/client_start_game.bin
new file mode 100644
index 0000000..6b875dc
Binary files /dev/null and b/proto/testdata/client_start_game.bin differ
diff --git a/proto/testdata/server_rematch_state.bin b/proto/testdata/server_rematch_state.bin
deleted file mode 100644
index a329c3d..0000000
--- a/proto/testdata/server_rematch_state.bin
+++ /dev/null
@@ -1 +0,0 @@
-ZìÖ
\ No newline at end of file
diff --git a/proto/testdata/server_room_created.bin b/proto/testdata/server_room_created.bin
deleted file mode 100644
index 86b194a..0000000
--- a/proto/testdata/server_room_created.bin
+++ /dev/null
@@ -1,2 +0,0 @@
-
-K7QX
\ No newline at end of file
diff --git a/proto/testdata/server_room_joined.bin b/proto/testdata/server_room_joined.bin
deleted file mode 100644
index 4ea0b2c..0000000
--- a/proto/testdata/server_room_joined.bin
+++ /dev/null
@@ -1,2 +0,0 @@
-
-K7QXThuý
\ No newline at end of file
diff --git a/proto/testdata/server_room_state.bin b/proto/testdata/server_room_state.bin
new file mode 100644
index 0000000..bc36158
--- /dev/null
+++ b/proto/testdata/server_room_state.bin
@@ -0,0 +1,2 @@
+b
+K7QX(2Khách mời8@
\ No newline at end of file
diff --git a/server/gen/noitu/v1/game.pb.go b/server/gen/noitu/v1/game.pb.go
index 7dbc292..4a44635 100644
--- a/server/gen/noitu/v1/game.pb.go
+++ b/server/gen/noitu/v1/game.pb.go
@@ -493,31 +493,32 @@ func (*Resign) Descriptor() ([]byte, []int) {
return file_noitu_v1_game_proto_rawDescGZIP(), []int{5}
}
-// RequestRematch asks to play again in the same room after a game ends.
+// SetReady is the guest declaring themselves ready, or taking it back.
//
-// There is no matching decline: leaving the room is the decline, and the
-// server already learns about that from the socket closing. One message and
-// one timeout cover every way a rematch does not happen.
-type RequestRematch struct {
+// Only the guest has a readiness to set. The owner's is implied by StartGame:
+// asking for the game to begin is the same statement, and a second flag they
+// would always have to set first buys nothing.
+type SetReady struct {
state protoimpl.MessageState `protogen:"open.v1"`
+ Ready bool `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
-func (x *RequestRematch) Reset() {
- *x = RequestRematch{}
+func (x *SetReady) Reset() {
+ *x = SetReady{}
mi := &file_noitu_v1_game_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
-func (x *RequestRematch) String() string {
+func (x *SetReady) String() string {
return protoimpl.X.MessageStringOf(x)
}
-func (*RequestRematch) ProtoMessage() {}
+func (*SetReady) ProtoMessage() {}
-func (x *RequestRematch) ProtoReflect() protoreflect.Message {
+func (x *SetReady) ProtoReflect() protoreflect.Message {
mi := &file_noitu_v1_game_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@@ -529,11 +530,134 @@ func (x *RequestRematch) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
-// Deprecated: Use RequestRematch.ProtoReflect.Descriptor instead.
-func (*RequestRematch) Descriptor() ([]byte, []int) {
+// Deprecated: Use SetReady.ProtoReflect.Descriptor instead.
+func (*SetReady) Descriptor() ([]byte, []int) {
return file_noitu_v1_game_proto_rawDescGZIP(), []int{6}
}
+func (x *SetReady) GetReady() bool {
+ if x != nil {
+ return x.Ready
+ }
+ return false
+}
+
+// StartGame is the owner beginning the game the lobby has agreed on. It is
+// refused unless the guest is seated, connected and ready.
+type StartGame struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StartGame) Reset() {
+ *x = StartGame{}
+ mi := &file_noitu_v1_game_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StartGame) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StartGame) ProtoMessage() {}
+
+func (x *StartGame) ProtoReflect() protoreflect.Message {
+ mi := &file_noitu_v1_game_proto_msgTypes[7]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StartGame.ProtoReflect.Descriptor instead.
+func (*StartGame) Descriptor() ([]byte, []int) {
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{7}
+}
+
+// KickPlayer is the owner freeing the guest's seat. Refused while the guest is
+// ready: readiness is a commitment, and a player who has made it is not
+// something the owner gets to overrule.
+type KickPlayer struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *KickPlayer) Reset() {
+ *x = KickPlayer{}
+ mi := &file_noitu_v1_game_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *KickPlayer) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*KickPlayer) ProtoMessage() {}
+
+func (x *KickPlayer) ProtoReflect() protoreflect.Message {
+ mi := &file_noitu_v1_game_proto_msgTypes[8]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use KickPlayer.ProtoReflect.Descriptor instead.
+func (*KickPlayer) Descriptor() ([]byte, []int) {
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{8}
+}
+
+// LeaveRoom gives up a seat without dropping the connection, which is what
+// makes a room outlive one game rather than one visit. Refused while the
+// sender is ready — unreadying first is the deliberate friction.
+type LeaveRoom struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *LeaveRoom) Reset() {
+ *x = LeaveRoom{}
+ mi := &file_noitu_v1_game_proto_msgTypes[9]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *LeaveRoom) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*LeaveRoom) ProtoMessage() {}
+
+func (x *LeaveRoom) ProtoReflect() protoreflect.Message {
+ mi := &file_noitu_v1_game_proto_msgTypes[9]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use LeaveRoom.ProtoReflect.Descriptor instead.
+func (*LeaveRoom) Descriptor() ([]byte, []int) {
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{9}
+}
+
// Ping echoes the client clock so Pong can expose the offset between the two.
type Ping struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -544,7 +668,7 @@ type Ping struct {
func (x *Ping) Reset() {
*x = Ping{}
- mi := &file_noitu_v1_game_proto_msgTypes[7]
+ mi := &file_noitu_v1_game_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -556,7 +680,7 @@ func (x *Ping) String() string {
func (*Ping) ProtoMessage() {}
func (x *Ping) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[7]
+ mi := &file_noitu_v1_game_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -569,7 +693,7 @@ func (x *Ping) ProtoReflect() protoreflect.Message {
// Deprecated: Use Ping.ProtoReflect.Descriptor instead.
func (*Ping) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{7}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{10}
}
func (x *Ping) GetClientTimeMs() int64 {
@@ -590,7 +714,10 @@ type ClientMessage struct {
// *ClientMessage_SubmitWord
// *ClientMessage_Resign
// *ClientMessage_Ping
- // *ClientMessage_RequestRematch
+ // *ClientMessage_SetReady
+ // *ClientMessage_StartGame
+ // *ClientMessage_KickPlayer
+ // *ClientMessage_LeaveRoom
Payload isClientMessage_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -598,7 +725,7 @@ type ClientMessage struct {
func (x *ClientMessage) Reset() {
*x = ClientMessage{}
- mi := &file_noitu_v1_game_proto_msgTypes[8]
+ mi := &file_noitu_v1_game_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -610,7 +737,7 @@ func (x *ClientMessage) String() string {
func (*ClientMessage) ProtoMessage() {}
func (x *ClientMessage) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[8]
+ mi := &file_noitu_v1_game_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -623,7 +750,7 @@ func (x *ClientMessage) ProtoReflect() protoreflect.Message {
// Deprecated: Use ClientMessage.ProtoReflect.Descriptor instead.
func (*ClientMessage) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{8}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{11}
}
func (x *ClientMessage) GetPayload() isClientMessage_Payload {
@@ -696,10 +823,37 @@ func (x *ClientMessage) GetPing() *Ping {
return nil
}
-func (x *ClientMessage) GetRequestRematch() *RequestRematch {
+func (x *ClientMessage) GetSetReady() *SetReady {
if x != nil {
- if x, ok := x.Payload.(*ClientMessage_RequestRematch); ok {
- return x.RequestRematch
+ if x, ok := x.Payload.(*ClientMessage_SetReady); ok {
+ return x.SetReady
+ }
+ }
+ return nil
+}
+
+func (x *ClientMessage) GetStartGame() *StartGame {
+ if x != nil {
+ if x, ok := x.Payload.(*ClientMessage_StartGame); ok {
+ return x.StartGame
+ }
+ }
+ return nil
+}
+
+func (x *ClientMessage) GetKickPlayer() *KickPlayer {
+ if x != nil {
+ if x, ok := x.Payload.(*ClientMessage_KickPlayer); ok {
+ return x.KickPlayer
+ }
+ }
+ return nil
+}
+
+func (x *ClientMessage) GetLeaveRoom() *LeaveRoom {
+ if x != nil {
+ if x, ok := x.Payload.(*ClientMessage_LeaveRoom); ok {
+ return x.LeaveRoom
}
}
return nil
@@ -737,8 +891,20 @@ type ClientMessage_Ping struct {
Ping *Ping `protobuf:"bytes,7,opt,name=ping,proto3,oneof"`
}
-type ClientMessage_RequestRematch struct {
- RequestRematch *RequestRematch `protobuf:"bytes,8,opt,name=request_rematch,json=requestRematch,proto3,oneof"`
+type ClientMessage_SetReady struct {
+ SetReady *SetReady `protobuf:"bytes,9,opt,name=set_ready,json=setReady,proto3,oneof"`
+}
+
+type ClientMessage_StartGame struct {
+ StartGame *StartGame `protobuf:"bytes,10,opt,name=start_game,json=startGame,proto3,oneof"`
+}
+
+type ClientMessage_KickPlayer struct {
+ KickPlayer *KickPlayer `protobuf:"bytes,11,opt,name=kick_player,json=kickPlayer,proto3,oneof"`
+}
+
+type ClientMessage_LeaveRoom struct {
+ LeaveRoom *LeaveRoom `protobuf:"bytes,12,opt,name=leave_room,json=leaveRoom,proto3,oneof"`
}
func (*ClientMessage_Hello) isClientMessage_Payload() {}
@@ -755,7 +921,13 @@ func (*ClientMessage_Resign) isClientMessage_Payload() {}
func (*ClientMessage_Ping) isClientMessage_Payload() {}
-func (*ClientMessage_RequestRematch) isClientMessage_Payload() {}
+func (*ClientMessage_SetReady) isClientMessage_Payload() {}
+
+func (*ClientMessage_StartGame) isClientMessage_Payload() {}
+
+func (*ClientMessage_KickPlayer) isClientMessage_Payload() {}
+
+func (*ClientMessage_LeaveRoom) isClientMessage_Payload() {}
type Welcome struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -771,7 +943,7 @@ type Welcome struct {
func (x *Welcome) Reset() {
*x = Welcome{}
- mi := &file_noitu_v1_game_proto_msgTypes[9]
+ mi := &file_noitu_v1_game_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -783,7 +955,7 @@ func (x *Welcome) String() string {
func (*Welcome) ProtoMessage() {}
func (x *Welcome) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[9]
+ mi := &file_noitu_v1_game_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -796,7 +968,7 @@ func (x *Welcome) ProtoReflect() protoreflect.Message {
// Deprecated: Use Welcome.ProtoReflect.Descriptor instead.
func (*Welcome) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{9}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{12}
}
func (x *Welcome) GetSessionId() string {
@@ -827,104 +999,6 @@ func (x *Welcome) GetAcceptedNickname() string {
return ""
}
-type RoomCreated struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- RoomCode string `protobuf:"bytes,1,opt,name=room_code,json=roomCode,proto3" json:"room_code,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *RoomCreated) Reset() {
- *x = RoomCreated{}
- mi := &file_noitu_v1_game_proto_msgTypes[10]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *RoomCreated) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*RoomCreated) ProtoMessage() {}
-
-func (x *RoomCreated) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[10]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use RoomCreated.ProtoReflect.Descriptor instead.
-func (*RoomCreated) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{10}
-}
-
-func (x *RoomCreated) GetRoomCode() string {
- if x != nil {
- return x.RoomCode
- }
- return ""
-}
-
-type RoomJoined struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- RoomCode string `protobuf:"bytes,1,opt,name=room_code,json=roomCode,proto3" json:"room_code,omitempty"`
- // Always server-sanitized. A client must never render another player's raw
- // input.
- OpponentName string `protobuf:"bytes,2,opt,name=opponent_name,json=opponentName,proto3" json:"opponent_name,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *RoomJoined) Reset() {
- *x = RoomJoined{}
- mi := &file_noitu_v1_game_proto_msgTypes[11]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *RoomJoined) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*RoomJoined) ProtoMessage() {}
-
-func (x *RoomJoined) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[11]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use RoomJoined.ProtoReflect.Descriptor instead.
-func (*RoomJoined) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{11}
-}
-
-func (x *RoomJoined) GetRoomCode() string {
- if x != nil {
- return x.RoomCode
- }
- return ""
-}
-
-func (x *RoomJoined) GetOpponentName() string {
- if x != nil {
- return x.OpponentName
- }
- return ""
-}
-
// PlayedWord is one accepted move. word is the canonical spelling, which can
// differ from what the player typed; typed preserves the raw input so the UI
// can show that a correction happened instead of silently rewriting the text.
@@ -941,7 +1015,7 @@ type PlayedWord struct {
func (x *PlayedWord) Reset() {
*x = PlayedWord{}
- mi := &file_noitu_v1_game_proto_msgTypes[12]
+ mi := &file_noitu_v1_game_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -953,7 +1027,7 @@ func (x *PlayedWord) String() string {
func (*PlayedWord) ProtoMessage() {}
func (x *PlayedWord) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[12]
+ mi := &file_noitu_v1_game_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -966,7 +1040,7 @@ func (x *PlayedWord) ProtoReflect() protoreflect.Message {
// Deprecated: Use PlayedWord.ProtoReflect.Descriptor instead.
func (*PlayedWord) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{12}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{13}
}
func (x *PlayedWord) GetWord() string {
@@ -1022,7 +1096,7 @@ type GameStarted struct {
func (x *GameStarted) Reset() {
*x = GameStarted{}
- mi := &file_noitu_v1_game_proto_msgTypes[13]
+ mi := &file_noitu_v1_game_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1034,7 +1108,7 @@ func (x *GameStarted) String() string {
func (*GameStarted) ProtoMessage() {}
func (x *GameStarted) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[13]
+ mi := &file_noitu_v1_game_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1047,7 +1121,7 @@ func (x *GameStarted) ProtoReflect() protoreflect.Message {
// Deprecated: Use GameStarted.ProtoReflect.Descriptor instead.
func (*GameStarted) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{13}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{14}
}
func (x *GameStarted) GetOpeningWord() string {
@@ -1110,7 +1184,7 @@ type TurnUpdate struct {
func (x *TurnUpdate) Reset() {
*x = TurnUpdate{}
- mi := &file_noitu_v1_game_proto_msgTypes[14]
+ mi := &file_noitu_v1_game_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1122,7 +1196,7 @@ func (x *TurnUpdate) String() string {
func (*TurnUpdate) ProtoMessage() {}
func (x *TurnUpdate) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[14]
+ mi := &file_noitu_v1_game_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1135,7 +1209,7 @@ func (x *TurnUpdate) ProtoReflect() protoreflect.Message {
// Deprecated: Use TurnUpdate.ProtoReflect.Descriptor instead.
func (*TurnUpdate) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{14}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{15}
}
func (x *TurnUpdate) GetPlayed() *PlayedWord {
@@ -1205,7 +1279,7 @@ type MoveRejected struct {
func (x *MoveRejected) Reset() {
*x = MoveRejected{}
- mi := &file_noitu_v1_game_proto_msgTypes[15]
+ mi := &file_noitu_v1_game_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1217,7 +1291,7 @@ func (x *MoveRejected) String() string {
func (*MoveRejected) ProtoMessage() {}
func (x *MoveRejected) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[15]
+ mi := &file_noitu_v1_game_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1230,7 +1304,7 @@ func (x *MoveRejected) ProtoReflect() protoreflect.Message {
// Deprecated: Use MoveRejected.ProtoReflect.Descriptor instead.
func (*MoveRejected) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{15}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{16}
}
func (x *MoveRejected) GetReason() RejectReason {
@@ -1273,7 +1347,7 @@ type GameOver struct {
func (x *GameOver) Reset() {
*x = GameOver{}
- mi := &file_noitu_v1_game_proto_msgTypes[16]
+ mi := &file_noitu_v1_game_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1285,7 +1359,7 @@ func (x *GameOver) String() string {
func (*GameOver) ProtoMessage() {}
func (x *GameOver) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[16]
+ mi := &file_noitu_v1_game_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1298,7 +1372,7 @@ func (x *GameOver) ProtoReflect() protoreflect.Message {
// Deprecated: Use GameOver.ProtoReflect.Descriptor instead.
func (*GameOver) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{16}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{17}
}
func (x *GameOver) GetIWon() bool {
@@ -1346,7 +1420,7 @@ type OpponentLeft struct {
func (x *OpponentLeft) Reset() {
*x = OpponentLeft{}
- mi := &file_noitu_v1_game_proto_msgTypes[17]
+ mi := &file_noitu_v1_game_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1358,7 +1432,7 @@ func (x *OpponentLeft) String() string {
func (*OpponentLeft) ProtoMessage() {}
func (x *OpponentLeft) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[17]
+ mi := &file_noitu_v1_game_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1371,7 +1445,7 @@ func (x *OpponentLeft) ProtoReflect() protoreflect.Message {
// Deprecated: Use OpponentLeft.ProtoReflect.Descriptor instead.
func (*OpponentLeft) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{17}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{18}
}
func (x *OpponentLeft) GetCanReconnect() bool {
@@ -1400,7 +1474,7 @@ type ServerError struct {
func (x *ServerError) Reset() {
*x = ServerError{}
- mi := &file_noitu_v1_game_proto_msgTypes[18]
+ mi := &file_noitu_v1_game_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1412,7 +1486,7 @@ func (x *ServerError) String() string {
func (*ServerError) ProtoMessage() {}
func (x *ServerError) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[18]
+ mi := &file_noitu_v1_game_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1425,7 +1499,7 @@ func (x *ServerError) ProtoReflect() protoreflect.Message {
// Deprecated: Use ServerError.ProtoReflect.Descriptor instead.
func (*ServerError) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{18}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{19}
}
func (x *ServerError) GetCode() string {
@@ -1452,7 +1526,7 @@ type Pong struct {
func (x *Pong) Reset() {
*x = Pong{}
- mi := &file_noitu_v1_game_proto_msgTypes[19]
+ mi := &file_noitu_v1_game_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1464,7 +1538,7 @@ func (x *Pong) String() string {
func (*Pong) ProtoMessage() {}
func (x *Pong) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[19]
+ mi := &file_noitu_v1_game_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1477,7 +1551,7 @@ func (x *Pong) ProtoReflect() protoreflect.Message {
// Deprecated: Use Pong.ProtoReflect.Descriptor instead.
func (*Pong) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{19}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{20}
}
func (x *Pong) GetClientTimeMs() int64 {
@@ -1494,36 +1568,52 @@ func (x *Pong) GetServerTimeMs() int64 {
return 0
}
-// RematchState is sent to both players whenever either one asks for a rematch,
-// and once when the offer opens. Each recipient is told about their own side
-// and their opponent's, so neither client has to work out which acceptance is
-// whose.
-type RematchState struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- IAccepted bool `protobuf:"varint,1,opt,name=i_accepted,json=iAccepted,proto3" json:"i_accepted,omitempty"`
- OpponentAccepted bool `protobuf:"varint,2,opt,name=opponent_accepted,json=opponentAccepted,proto3" json:"opponent_accepted,omitempty"`
- // How long is left to accept. The room closes when this runs out, which is
- // also what a player who simply leaves ends up doing.
- ExpiresInMs uint32 `protobuf:"varint,3,opt,name=expires_in_ms,json=expiresInMs,proto3" json:"expires_in_ms,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+// RoomState is the whole lobby, rendered for one recipient, and it is the only
+// thing the lobby screen is built from. Sent on every change a player could
+// see — a seat filled or freed, a readiness set, an owner promoted — and again
+// on resume, so a client that missed a frame recovers by being told the state
+// rather than by replaying the events that led to it.
+//
+// The seat that is absent is reported as an unoccupied opponent rather than by
+// omitting the field, so "alone in the room" and "opponent still loading" are
+// never the same frame.
+type RoomState struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ RoomCode string `protobuf:"bytes,1,opt,name=room_code,json=roomCode,proto3" json:"room_code,omitempty"`
+ // True for the player who may start the game and kick the other.
+ IAmOwner bool `protobuf:"varint,2,opt,name=i_am_owner,json=iAmOwner,proto3" json:"i_am_owner,omitempty"`
+ // Whether StartGame would be accepted right now. The server decides this
+ // because it owns every condition that feeds it.
+ CanStart bool `protobuf:"varint,3,opt,name=can_start,json=canStart,proto3" json:"can_start,omitempty"`
+ // The recipient's own readiness. Always false for the owner, whose readiness
+ // is StartGame itself.
+ IAmReady bool `protobuf:"varint,4,opt,name=i_am_ready,json=iAmReady,proto3" json:"i_am_ready,omitempty"`
+ // False when the other seat is empty; the fields below are then meaningless.
+ OpponentPresent bool `protobuf:"varint,5,opt,name=opponent_present,json=opponentPresent,proto3" json:"opponent_present,omitempty"`
+ // Always server-sanitized, as everywhere else another player's name appears.
+ OpponentName string `protobuf:"bytes,6,opt,name=opponent_name,json=opponentName,proto3" json:"opponent_name,omitempty"`
+ OpponentReady bool `protobuf:"varint,7,opt,name=opponent_ready,json=opponentReady,proto3" json:"opponent_ready,omitempty"`
+ // False while the other player is inside their reconnect window.
+ OpponentConnected bool `protobuf:"varint,8,opt,name=opponent_connected,json=opponentConnected,proto3" json:"opponent_connected,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
-func (x *RematchState) Reset() {
- *x = RematchState{}
- mi := &file_noitu_v1_game_proto_msgTypes[20]
+func (x *RoomState) Reset() {
+ *x = RoomState{}
+ mi := &file_noitu_v1_game_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
-func (x *RematchState) String() string {
+func (x *RoomState) String() string {
return protoimpl.X.MessageStringOf(x)
}
-func (*RematchState) ProtoMessage() {}
+func (*RoomState) ProtoMessage() {}
-func (x *RematchState) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[20]
+func (x *RoomState) ProtoReflect() protoreflect.Message {
+ mi := &file_noitu_v1_game_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1534,30 +1624,65 @@ func (x *RematchState) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
-// Deprecated: Use RematchState.ProtoReflect.Descriptor instead.
-func (*RematchState) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{20}
+// Deprecated: Use RoomState.ProtoReflect.Descriptor instead.
+func (*RoomState) Descriptor() ([]byte, []int) {
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{21}
}
-func (x *RematchState) GetIAccepted() bool {
+func (x *RoomState) GetRoomCode() string {
if x != nil {
- return x.IAccepted
+ return x.RoomCode
+ }
+ return ""
+}
+
+func (x *RoomState) GetIAmOwner() bool {
+ if x != nil {
+ return x.IAmOwner
}
return false
}
-func (x *RematchState) GetOpponentAccepted() bool {
+func (x *RoomState) GetCanStart() bool {
if x != nil {
- return x.OpponentAccepted
+ return x.CanStart
}
return false
}
-func (x *RematchState) GetExpiresInMs() uint32 {
+func (x *RoomState) GetIAmReady() bool {
if x != nil {
- return x.ExpiresInMs
+ return x.IAmReady
}
- return 0
+ return false
+}
+
+func (x *RoomState) GetOpponentPresent() bool {
+ if x != nil {
+ return x.OpponentPresent
+ }
+ return false
+}
+
+func (x *RoomState) GetOpponentName() string {
+ if x != nil {
+ return x.OpponentName
+ }
+ return ""
+}
+
+func (x *RoomState) GetOpponentReady() bool {
+ if x != nil {
+ return x.OpponentReady
+ }
+ return false
+}
+
+func (x *RoomState) GetOpponentConnected() bool {
+ if x != nil {
+ return x.OpponentConnected
+ }
+ return false
}
type ServerMessage struct {
@@ -1565,8 +1690,6 @@ type ServerMessage struct {
// Types that are valid to be assigned to Payload:
//
// *ServerMessage_Welcome
- // *ServerMessage_RoomCreated
- // *ServerMessage_RoomJoined
// *ServerMessage_GameStarted
// *ServerMessage_TurnUpdate
// *ServerMessage_MoveRejected
@@ -1574,7 +1697,7 @@ type ServerMessage struct {
// *ServerMessage_OpponentLeft
// *ServerMessage_Error
// *ServerMessage_Pong
- // *ServerMessage_RematchState
+ // *ServerMessage_RoomState
Payload isServerMessage_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -1582,7 +1705,7 @@ type ServerMessage struct {
func (x *ServerMessage) Reset() {
*x = ServerMessage{}
- mi := &file_noitu_v1_game_proto_msgTypes[21]
+ mi := &file_noitu_v1_game_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1594,7 +1717,7 @@ func (x *ServerMessage) String() string {
func (*ServerMessage) ProtoMessage() {}
func (x *ServerMessage) ProtoReflect() protoreflect.Message {
- mi := &file_noitu_v1_game_proto_msgTypes[21]
+ mi := &file_noitu_v1_game_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1607,7 +1730,7 @@ func (x *ServerMessage) ProtoReflect() protoreflect.Message {
// Deprecated: Use ServerMessage.ProtoReflect.Descriptor instead.
func (*ServerMessage) Descriptor() ([]byte, []int) {
- return file_noitu_v1_game_proto_rawDescGZIP(), []int{21}
+ return file_noitu_v1_game_proto_rawDescGZIP(), []int{22}
}
func (x *ServerMessage) GetPayload() isServerMessage_Payload {
@@ -1626,24 +1749,6 @@ func (x *ServerMessage) GetWelcome() *Welcome {
return nil
}
-func (x *ServerMessage) GetRoomCreated() *RoomCreated {
- if x != nil {
- if x, ok := x.Payload.(*ServerMessage_RoomCreated); ok {
- return x.RoomCreated
- }
- }
- return nil
-}
-
-func (x *ServerMessage) GetRoomJoined() *RoomJoined {
- if x != nil {
- if x, ok := x.Payload.(*ServerMessage_RoomJoined); ok {
- return x.RoomJoined
- }
- }
- return nil
-}
-
func (x *ServerMessage) GetGameStarted() *GameStarted {
if x != nil {
if x, ok := x.Payload.(*ServerMessage_GameStarted); ok {
@@ -1707,10 +1812,10 @@ func (x *ServerMessage) GetPong() *Pong {
return nil
}
-func (x *ServerMessage) GetRematchState() *RematchState {
+func (x *ServerMessage) GetRoomState() *RoomState {
if x != nil {
- if x, ok := x.Payload.(*ServerMessage_RematchState); ok {
- return x.RematchState
+ if x, ok := x.Payload.(*ServerMessage_RoomState); ok {
+ return x.RoomState
}
}
return nil
@@ -1724,14 +1829,6 @@ type ServerMessage_Welcome struct {
Welcome *Welcome `protobuf:"bytes,1,opt,name=welcome,proto3,oneof"`
}
-type ServerMessage_RoomCreated struct {
- RoomCreated *RoomCreated `protobuf:"bytes,2,opt,name=room_created,json=roomCreated,proto3,oneof"`
-}
-
-type ServerMessage_RoomJoined struct {
- RoomJoined *RoomJoined `protobuf:"bytes,3,opt,name=room_joined,json=roomJoined,proto3,oneof"`
-}
-
type ServerMessage_GameStarted struct {
GameStarted *GameStarted `protobuf:"bytes,4,opt,name=game_started,json=gameStarted,proto3,oneof"`
}
@@ -1760,16 +1857,12 @@ type ServerMessage_Pong struct {
Pong *Pong `protobuf:"bytes,10,opt,name=pong,proto3,oneof"`
}
-type ServerMessage_RematchState struct {
- RematchState *RematchState `protobuf:"bytes,11,opt,name=rematch_state,json=rematchState,proto3,oneof"`
+type ServerMessage_RoomState struct {
+ RoomState *RoomState `protobuf:"bytes,12,opt,name=room_state,json=roomState,proto3,oneof"`
}
func (*ServerMessage_Welcome) isServerMessage_Payload() {}
-func (*ServerMessage_RoomCreated) isServerMessage_Payload() {}
-
-func (*ServerMessage_RoomJoined) isServerMessage_Payload() {}
-
func (*ServerMessage_GameStarted) isServerMessage_Payload() {}
func (*ServerMessage_TurnUpdate) isServerMessage_Payload() {}
@@ -1784,7 +1877,7 @@ func (*ServerMessage_Error) isServerMessage_Payload() {}
func (*ServerMessage_Pong) isServerMessage_Payload() {}
-func (*ServerMessage_RematchState) isServerMessage_Payload() {}
+func (*ServerMessage_RoomState) isServerMessage_Payload() {}
var File_noitu_v1_game_proto protoreflect.FileDescriptor
@@ -1807,10 +1900,15 @@ const file_noitu_v1_game_proto_rawDesc = "" +
"SubmitWord\x12\x12\n" +
"\x04word\x18\x01 \x01(\tR\x04word\x12\x19\n" +
"\bturn_seq\x18\x02 \x01(\rR\aturnSeq\"\b\n" +
- "\x06Resign\"\x10\n" +
- "\x0eRequestRematch\",\n" +
+ "\x06Resign\" \n" +
+ "\bSetReady\x12\x14\n" +
+ "\x05ready\x18\x01 \x01(\bR\x05ready\"\v\n" +
+ "\tStartGame\"\f\n" +
+ "\n" +
+ "KickPlayer\"\v\n" +
+ "\tLeaveRoom\",\n" +
"\x04Ping\x12$\n" +
- "\x0eclient_time_ms\x18\x01 \x01(\x03R\fclientTimeMs\"\xbf\x03\n" +
+ "\x0eclient_time_ms\x18\x01 \x01(\x03R\fclientTimeMs\"\xd8\x04\n" +
"\rClientMessage\x12'\n" +
"\x05hello\x18\x01 \x01(\v2\x0f.noitu.v1.HelloH\x00R\x05hello\x12>\n" +
"\x0estart_bot_game\x18\x02 \x01(\v2\x16.noitu.v1.StartBotGameH\x00R\fstartBotGame\x127\n" +
@@ -1820,21 +1918,22 @@ const file_noitu_v1_game_proto_rawDesc = "" +
"\vsubmit_word\x18\x05 \x01(\v2\x14.noitu.v1.SubmitWordH\x00R\n" +
"submitWord\x12*\n" +
"\x06resign\x18\x06 \x01(\v2\x10.noitu.v1.ResignH\x00R\x06resign\x12$\n" +
- "\x04ping\x18\a \x01(\v2\x0e.noitu.v1.PingH\x00R\x04ping\x12C\n" +
- "\x0frequest_rematch\x18\b \x01(\v2\x18.noitu.v1.RequestRematchH\x00R\x0erequestRematchB\t\n" +
- "\apayload\"\xa3\x01\n" +
+ "\x04ping\x18\a \x01(\v2\x0e.noitu.v1.PingH\x00R\x04ping\x121\n" +
+ "\tset_ready\x18\t \x01(\v2\x12.noitu.v1.SetReadyH\x00R\bsetReady\x124\n" +
+ "\n" +
+ "start_game\x18\n" +
+ " \x01(\v2\x13.noitu.v1.StartGameH\x00R\tstartGame\x127\n" +
+ "\vkick_player\x18\v \x01(\v2\x14.noitu.v1.KickPlayerH\x00R\n" +
+ "kickPlayer\x124\n" +
+ "\n" +
+ "leave_room\x18\f \x01(\v2\x13.noitu.v1.LeaveRoomH\x00R\tleaveRoomB\t\n" +
+ "\apayloadJ\x04\b\b\x10\t\"\xa3\x01\n" +
"\aWelcome\x12\x1d\n" +
"\n" +
"session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" +
"\fresume_token\x18\x02 \x01(\tR\vresumeToken\x12)\n" +
"\x10protocol_version\x18\x03 \x01(\rR\x0fprotocolVersion\x12+\n" +
- "\x11accepted_nickname\x18\x04 \x01(\tR\x10acceptedNickname\"*\n" +
- "\vRoomCreated\x12\x1b\n" +
- "\troom_code\x18\x01 \x01(\tR\broomCode\"N\n" +
- "\n" +
- "RoomJoined\x12\x1b\n" +
- "\troom_code\x18\x01 \x01(\tR\broomCode\x12#\n" +
- "\ropponent_name\x18\x02 \x01(\tR\fopponentName\"\x81\x01\n" +
+ "\x11accepted_nickname\x18\x04 \x01(\tR\x10acceptedNickname\"\x81\x01\n" +
"\n" +
"PlayedWord\x12\x12\n" +
"\x04word\x18\x01 \x01(\tR\x04word\x12\x13\n" +
@@ -1877,17 +1976,20 @@ const file_noitu_v1_game_proto_rawDesc = "" +
"\amessage\x18\x02 \x01(\tR\amessage\"R\n" +
"\x04Pong\x12$\n" +
"\x0eclient_time_ms\x18\x01 \x01(\x03R\fclientTimeMs\x12$\n" +
- "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"~\n" +
- "\fRematchState\x12\x1d\n" +
+ "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xa7\x02\n" +
+ "\tRoomState\x12\x1b\n" +
+ "\troom_code\x18\x01 \x01(\tR\broomCode\x12\x1c\n" +
"\n" +
- "i_accepted\x18\x01 \x01(\bR\tiAccepted\x12+\n" +
- "\x11opponent_accepted\x18\x02 \x01(\bR\x10opponentAccepted\x12\"\n" +
- "\rexpires_in_ms\x18\x03 \x01(\rR\vexpiresInMs\"\xf8\x04\n" +
+ "i_am_owner\x18\x02 \x01(\bR\biAmOwner\x12\x1b\n" +
+ "\tcan_start\x18\x03 \x01(\bR\bcanStart\x12\x1c\n" +
+ "\n" +
+ "i_am_ready\x18\x04 \x01(\bR\biAmReady\x12)\n" +
+ "\x10opponent_present\x18\x05 \x01(\bR\x0fopponentPresent\x12#\n" +
+ "\ropponent_name\x18\x06 \x01(\tR\fopponentName\x12%\n" +
+ "\x0eopponent_ready\x18\a \x01(\bR\ropponentReady\x12-\n" +
+ "\x12opponent_connected\x18\b \x01(\bR\x11opponentConnected\"\x8c\x04\n" +
"\rServerMessage\x12-\n" +
"\awelcome\x18\x01 \x01(\v2\x11.noitu.v1.WelcomeH\x00R\awelcome\x12:\n" +
- "\froom_created\x18\x02 \x01(\v2\x15.noitu.v1.RoomCreatedH\x00R\vroomCreated\x127\n" +
- "\vroom_joined\x18\x03 \x01(\v2\x14.noitu.v1.RoomJoinedH\x00R\n" +
- "roomJoined\x12:\n" +
"\fgame_started\x18\x04 \x01(\v2\x15.noitu.v1.GameStartedH\x00R\vgameStarted\x127\n" +
"\vturn_update\x18\x05 \x01(\v2\x14.noitu.v1.TurnUpdateH\x00R\n" +
"turnUpdate\x12=\n" +
@@ -1896,9 +1998,10 @@ const file_noitu_v1_game_proto_rawDesc = "" +
"\ropponent_left\x18\b \x01(\v2\x16.noitu.v1.OpponentLeftH\x00R\fopponentLeft\x12-\n" +
"\x05error\x18\t \x01(\v2\x15.noitu.v1.ServerErrorH\x00R\x05error\x12$\n" +
"\x04pong\x18\n" +
- " \x01(\v2\x0e.noitu.v1.PongH\x00R\x04pong\x12=\n" +
- "\rrematch_state\x18\v \x01(\v2\x16.noitu.v1.RematchStateH\x00R\frematchStateB\t\n" +
- "\apayload*i\n" +
+ " \x01(\v2\x0e.noitu.v1.PongH\x00R\x04pong\x124\n" +
+ "\n" +
+ "room_state\x18\f \x01(\v2\x13.noitu.v1.RoomStateH\x00R\troomStateB\t\n" +
+ "\apayloadJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\v\x10\f*i\n" +
"\n" +
"Difficulty\x12\x1a\n" +
"\x16DIFFICULTY_UNSPECIFIED\x10\x00\x12\x13\n" +
@@ -1934,33 +2037,34 @@ func file_noitu_v1_game_proto_rawDescGZIP() []byte {
}
var file_noitu_v1_game_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
-var file_noitu_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 22)
+var file_noitu_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 23)
var file_noitu_v1_game_proto_goTypes = []any{
- (Difficulty)(0), // 0: noitu.v1.Difficulty
- (RejectReason)(0), // 1: noitu.v1.RejectReason
- (GameEndReason)(0), // 2: noitu.v1.GameEndReason
- (*Hello)(nil), // 3: noitu.v1.Hello
- (*StartBotGame)(nil), // 4: noitu.v1.StartBotGame
- (*CreateRoom)(nil), // 5: noitu.v1.CreateRoom
- (*JoinRoom)(nil), // 6: noitu.v1.JoinRoom
- (*SubmitWord)(nil), // 7: noitu.v1.SubmitWord
- (*Resign)(nil), // 8: noitu.v1.Resign
- (*RequestRematch)(nil), // 9: noitu.v1.RequestRematch
- (*Ping)(nil), // 10: noitu.v1.Ping
- (*ClientMessage)(nil), // 11: noitu.v1.ClientMessage
- (*Welcome)(nil), // 12: noitu.v1.Welcome
- (*RoomCreated)(nil), // 13: noitu.v1.RoomCreated
- (*RoomJoined)(nil), // 14: noitu.v1.RoomJoined
- (*PlayedWord)(nil), // 15: noitu.v1.PlayedWord
- (*GameStarted)(nil), // 16: noitu.v1.GameStarted
- (*TurnUpdate)(nil), // 17: noitu.v1.TurnUpdate
- (*MoveRejected)(nil), // 18: noitu.v1.MoveRejected
- (*GameOver)(nil), // 19: noitu.v1.GameOver
- (*OpponentLeft)(nil), // 20: noitu.v1.OpponentLeft
- (*ServerError)(nil), // 21: noitu.v1.ServerError
- (*Pong)(nil), // 22: noitu.v1.Pong
- (*RematchState)(nil), // 23: noitu.v1.RematchState
- (*ServerMessage)(nil), // 24: noitu.v1.ServerMessage
+ (Difficulty)(0), // 0: noitu.v1.Difficulty
+ (RejectReason)(0), // 1: noitu.v1.RejectReason
+ (GameEndReason)(0), // 2: noitu.v1.GameEndReason
+ (*Hello)(nil), // 3: noitu.v1.Hello
+ (*StartBotGame)(nil), // 4: noitu.v1.StartBotGame
+ (*CreateRoom)(nil), // 5: noitu.v1.CreateRoom
+ (*JoinRoom)(nil), // 6: noitu.v1.JoinRoom
+ (*SubmitWord)(nil), // 7: noitu.v1.SubmitWord
+ (*Resign)(nil), // 8: noitu.v1.Resign
+ (*SetReady)(nil), // 9: noitu.v1.SetReady
+ (*StartGame)(nil), // 10: noitu.v1.StartGame
+ (*KickPlayer)(nil), // 11: noitu.v1.KickPlayer
+ (*LeaveRoom)(nil), // 12: noitu.v1.LeaveRoom
+ (*Ping)(nil), // 13: noitu.v1.Ping
+ (*ClientMessage)(nil), // 14: noitu.v1.ClientMessage
+ (*Welcome)(nil), // 15: noitu.v1.Welcome
+ (*PlayedWord)(nil), // 16: noitu.v1.PlayedWord
+ (*GameStarted)(nil), // 17: noitu.v1.GameStarted
+ (*TurnUpdate)(nil), // 18: noitu.v1.TurnUpdate
+ (*MoveRejected)(nil), // 19: noitu.v1.MoveRejected
+ (*GameOver)(nil), // 20: noitu.v1.GameOver
+ (*OpponentLeft)(nil), // 21: noitu.v1.OpponentLeft
+ (*ServerError)(nil), // 22: noitu.v1.ServerError
+ (*Pong)(nil), // 23: noitu.v1.Pong
+ (*RoomState)(nil), // 24: noitu.v1.RoomState
+ (*ServerMessage)(nil), // 25: noitu.v1.ServerMessage
}
var file_noitu_v1_game_proto_depIdxs = []int32{
0, // 0: noitu.v1.StartBotGame.difficulty:type_name -> noitu.v1.Difficulty
@@ -1970,27 +2074,28 @@ var file_noitu_v1_game_proto_depIdxs = []int32{
6, // 4: noitu.v1.ClientMessage.join_room:type_name -> noitu.v1.JoinRoom
7, // 5: noitu.v1.ClientMessage.submit_word:type_name -> noitu.v1.SubmitWord
8, // 6: noitu.v1.ClientMessage.resign:type_name -> noitu.v1.Resign
- 10, // 7: noitu.v1.ClientMessage.ping:type_name -> noitu.v1.Ping
- 9, // 8: noitu.v1.ClientMessage.request_rematch:type_name -> noitu.v1.RequestRematch
- 15, // 9: noitu.v1.TurnUpdate.played:type_name -> noitu.v1.PlayedWord
- 1, // 10: noitu.v1.MoveRejected.reason:type_name -> noitu.v1.RejectReason
- 2, // 11: noitu.v1.GameOver.reason:type_name -> noitu.v1.GameEndReason
- 12, // 12: noitu.v1.ServerMessage.welcome:type_name -> noitu.v1.Welcome
- 13, // 13: noitu.v1.ServerMessage.room_created:type_name -> noitu.v1.RoomCreated
- 14, // 14: noitu.v1.ServerMessage.room_joined:type_name -> noitu.v1.RoomJoined
- 16, // 15: noitu.v1.ServerMessage.game_started:type_name -> noitu.v1.GameStarted
- 17, // 16: noitu.v1.ServerMessage.turn_update:type_name -> noitu.v1.TurnUpdate
- 18, // 17: noitu.v1.ServerMessage.move_rejected:type_name -> noitu.v1.MoveRejected
- 19, // 18: noitu.v1.ServerMessage.game_over:type_name -> noitu.v1.GameOver
- 20, // 19: noitu.v1.ServerMessage.opponent_left:type_name -> noitu.v1.OpponentLeft
- 21, // 20: noitu.v1.ServerMessage.error:type_name -> noitu.v1.ServerError
- 22, // 21: noitu.v1.ServerMessage.pong:type_name -> noitu.v1.Pong
- 23, // 22: noitu.v1.ServerMessage.rematch_state:type_name -> noitu.v1.RematchState
- 23, // [23:23] is the sub-list for method output_type
- 23, // [23:23] is the sub-list for method input_type
- 23, // [23:23] is the sub-list for extension type_name
- 23, // [23:23] is the sub-list for extension extendee
- 0, // [0:23] is the sub-list for field type_name
+ 13, // 7: noitu.v1.ClientMessage.ping:type_name -> noitu.v1.Ping
+ 9, // 8: noitu.v1.ClientMessage.set_ready:type_name -> noitu.v1.SetReady
+ 10, // 9: noitu.v1.ClientMessage.start_game:type_name -> noitu.v1.StartGame
+ 11, // 10: noitu.v1.ClientMessage.kick_player:type_name -> noitu.v1.KickPlayer
+ 12, // 11: noitu.v1.ClientMessage.leave_room:type_name -> noitu.v1.LeaveRoom
+ 16, // 12: noitu.v1.TurnUpdate.played:type_name -> noitu.v1.PlayedWord
+ 1, // 13: noitu.v1.MoveRejected.reason:type_name -> noitu.v1.RejectReason
+ 2, // 14: noitu.v1.GameOver.reason:type_name -> noitu.v1.GameEndReason
+ 15, // 15: noitu.v1.ServerMessage.welcome:type_name -> noitu.v1.Welcome
+ 17, // 16: noitu.v1.ServerMessage.game_started:type_name -> noitu.v1.GameStarted
+ 18, // 17: noitu.v1.ServerMessage.turn_update:type_name -> noitu.v1.TurnUpdate
+ 19, // 18: noitu.v1.ServerMessage.move_rejected:type_name -> noitu.v1.MoveRejected
+ 20, // 19: noitu.v1.ServerMessage.game_over:type_name -> noitu.v1.GameOver
+ 21, // 20: noitu.v1.ServerMessage.opponent_left:type_name -> noitu.v1.OpponentLeft
+ 22, // 21: noitu.v1.ServerMessage.error:type_name -> noitu.v1.ServerError
+ 23, // 22: noitu.v1.ServerMessage.pong:type_name -> noitu.v1.Pong
+ 24, // 23: noitu.v1.ServerMessage.room_state:type_name -> noitu.v1.RoomState
+ 24, // [24:24] is the sub-list for method output_type
+ 24, // [24:24] is the sub-list for method input_type
+ 24, // [24:24] is the sub-list for extension type_name
+ 24, // [24:24] is the sub-list for extension extendee
+ 0, // [0:24] is the sub-list for field type_name
}
func init() { file_noitu_v1_game_proto_init() }
@@ -1998,7 +2103,7 @@ func file_noitu_v1_game_proto_init() {
if File_noitu_v1_game_proto != nil {
return
}
- file_noitu_v1_game_proto_msgTypes[8].OneofWrappers = []any{
+ file_noitu_v1_game_proto_msgTypes[11].OneofWrappers = []any{
(*ClientMessage_Hello)(nil),
(*ClientMessage_StartBotGame)(nil),
(*ClientMessage_CreateRoom)(nil),
@@ -2006,12 +2111,13 @@ func file_noitu_v1_game_proto_init() {
(*ClientMessage_SubmitWord)(nil),
(*ClientMessage_Resign)(nil),
(*ClientMessage_Ping)(nil),
- (*ClientMessage_RequestRematch)(nil),
+ (*ClientMessage_SetReady)(nil),
+ (*ClientMessage_StartGame)(nil),
+ (*ClientMessage_KickPlayer)(nil),
+ (*ClientMessage_LeaveRoom)(nil),
}
- file_noitu_v1_game_proto_msgTypes[21].OneofWrappers = []any{
+ file_noitu_v1_game_proto_msgTypes[22].OneofWrappers = []any{
(*ServerMessage_Welcome)(nil),
- (*ServerMessage_RoomCreated)(nil),
- (*ServerMessage_RoomJoined)(nil),
(*ServerMessage_GameStarted)(nil),
(*ServerMessage_TurnUpdate)(nil),
(*ServerMessage_MoveRejected)(nil),
@@ -2019,7 +2125,7 @@ func file_noitu_v1_game_proto_init() {
(*ServerMessage_OpponentLeft)(nil),
(*ServerMessage_Error)(nil),
(*ServerMessage_Pong)(nil),
- (*ServerMessage_RematchState)(nil),
+ (*ServerMessage_RoomState)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
@@ -2027,7 +2133,7 @@ func file_noitu_v1_game_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_noitu_v1_game_proto_rawDesc), len(file_noitu_v1_game_proto_rawDesc)),
NumEnums: 3,
- NumMessages: 22,
+ NumMessages: 23,
NumExtensions: 0,
NumServices: 0,
},
diff --git a/server/internal/wsapi/codec.go b/server/internal/wsapi/codec.go
index b115a23..3c05380 100644
--- a/server/internal/wsapi/codec.go
+++ b/server/internal/wsapi/codec.go
@@ -59,18 +59,6 @@ func welcomeMsg(sessionID, resumeToken, nickname string) *noituv1.ServerMessage
}}}
}
-func roomCreatedMsg(code string) *noituv1.ServerMessage {
- return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomCreated{
- RoomCreated: &noituv1.RoomCreated{RoomCode: code},
- }}
-}
-
-func roomJoinedMsg(code, opponent string) *noituv1.ServerMessage {
- return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomJoined{
- RoomJoined: &noituv1.RoomJoined{RoomCode: code, OpponentName: opponent},
- }}
-}
-
func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *noituv1.ServerMessage {
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_MoveRejected{
MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq},
diff --git a/server/internal/wsapi/hub.go b/server/internal/wsapi/hub.go
index 249bab8..3b0ae92 100644
--- a/server/internal/wsapi/hub.go
+++ b/server/internal/wsapi/hub.go
@@ -38,9 +38,9 @@ type hub struct {
ctx context.Context
dict Dictionary
- turnLimit time.Duration
- graceFor time.Duration
- rematchAfter time.Duration
+ turnLimit time.Duration
+ graceFor time.Duration
+ idleFor time.Duration
mu sync.Mutex
rooms map[string]*room
@@ -49,16 +49,16 @@ type hub struct {
joinLimiter *keyedLimiter
}
-func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor, rematchAfter time.Duration) *hub {
+func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor, idleFor time.Duration) *hub {
return &hub{
- ctx: ctx,
- dict: dict,
- turnLimit: turnLimit,
- graceFor: graceFor,
- rematchAfter: rematchAfter,
- rooms: map[string]*room{},
- sessions: map[string]*session{},
- joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor),
+ ctx: ctx,
+ dict: dict,
+ turnLimit: turnLimit,
+ graceFor: graceFor,
+ idleFor: idleFor,
+ rooms: map[string]*room{},
+ sessions: map[string]*session{},
+ joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor),
}
}
@@ -143,7 +143,7 @@ func (h *hub) newRegisteredRoom() (*room, error) {
return nil, err
}
- r := newRoom(h, code, h.turnLimit, h.graceFor, h.rematchAfter)
+ r := newRoom(h, code, h.turnLimit, h.graceFor, h.idleFor)
h.mu.Lock()
h.rooms[code] = r
diff --git a/server/internal/wsapi/regression_test.go b/server/internal/wsapi/regression_test.go
index 6520552..a94eab4 100644
--- a/server/internal/wsapi/regression_test.go
+++ b/server/internal/wsapi/regression_test.go
@@ -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)
}
}
diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go
index 8d5d713..d375669 100644
--- a/server/internal/wsapi/room.go
+++ b/server/internal/wsapi/room.go
@@ -31,10 +31,15 @@ const maxSuggestions = 3
// neither is worth blocking a session goroutine for.
const roomInputCap = 32
-// defaultRematchWindow is how long a finished room waits for both players to
-// ask for another game when nothing else is configured. It bounds how long a
-// room outlives its game.
-const defaultRematchWindow = 30 * time.Second
+// defaultIdleWindow is how long a lobby nobody starts a game in stays open
+// when nothing else is configured.
+//
+// A room now outlives its games, so something has to bound it: without this
+// one open tab holds a goroutine and a room code for the life of the process.
+// Long enough to read an invite and talk about it, short enough that abandoned
+// rooms do not accumulate. A running game needs no such bound — the turn clock
+// already ends it.
+const defaultIdleWindow = 10 * time.Minute
// Room input messages. Everything that can change a game arrives as one of
// these on a single channel, which is what makes the engine safe without a
@@ -69,10 +74,27 @@ type submitInput struct {
turnSeq uint32
}
-// rematchInput is one player asking to play the same room again.
-type rematchInput struct {
+// lobbyAction is one thing a player does to the room rather than to a game.
+type lobbyAction uint8
+
+const (
+ lobbyReady lobbyAction = iota
+ lobbyStart
+ lobbyKick
+ lobbyLeave
+)
+
+// lobbyInput is one lobby action. They share a type because they share every
+// authorization step — the seat, the room's mode, and whether a game is
+// running — and splitting them would mean four copies of those checks.
+type lobbyInput struct {
sess *session
player game.PlayerID
+ action lobbyAction
+ // ready is the value a lobbyReady is setting. Explicit rather than a
+ // toggle: a toggle applied to a state the client is a frame behind on sets
+ // the opposite of what the player clicked.
+ ready bool
}
type resignInput struct {
@@ -110,9 +132,10 @@ type seat struct {
id game.PlayerID
nickname string
sess *session // nil for the bot, or while a human is disconnected
- // wantsRematch is this seat's answer to the offer that opens when a game
- // ends. Cleared whenever a new game starts.
- wantsRematch bool
+ // ready is this seat's declaration that it wants the next game to start.
+ // Only ever set on the guest's seat: the owner's readiness is StartGame
+ // itself. Cleared whenever a game begins, so every game is agreed again.
+ ready bool
}
// room owns one game.
@@ -125,17 +148,23 @@ type room struct {
ctx context.Context
cancel context.CancelFunc
- hub *hub
- dict Dictionary
- engine *game.Engine
- opening string
- strategy bot.Strategy
- turnLimit time.Duration
- graceFor time.Duration
- rematchAfter time.Duration
+ hub *hub
+ dict Dictionary
+ engine *game.Engine
+ opening string
+ strategy bot.Strategy
+ turnLimit time.Duration
+ graceFor time.Duration
+ idleFor time.Duration
seats [2]*seat
+ // owner is the seat that may start a game and free the other one. It is a
+ // field rather than "seats[0]" because the role outlives the player who
+ // held it: an owner who leaves hands it to whoever is still here, and the
+ // seat they vacate is then filled by an ordinary guest.
+ owner game.PlayerID
+
// turnSeq increments on every turn change. A client stamps its submission
// with the sequence it was answering, so a move that crosses the deadline
// is identifiable rather than silently applied to the next turn.
@@ -146,10 +175,12 @@ type room struct {
// nobody left to win and the room ends.
disconnected *seat
- // rematchUntil is when the offer that follows a finished game expires, or
- // the zero time when no offer is open. It is the one flag that keeps a
- // room alive past its game.
- rematchUntil time.Time
+ // lobbyChanged marks that something a player can see about the room's
+ // occupants has changed: a seat filled or freed, a readiness set, an owner
+ // promoted, a game finished. The run loop turns it into exactly one
+ // RoomState broadcast per input, which is why no handler has to remember
+ // to send one.
+ lobbyChanged bool
}
// Dictionary is everything the transport layer needs from the wordlist: the
@@ -164,21 +195,21 @@ type Dictionary interface {
RandomOpeningWord(minOutDegree int) (string, error)
}
-func newRoom(h *hub, code string, turnLimit, graceFor, rematchAfter time.Duration) *room {
- if rematchAfter <= 0 {
- rematchAfter = defaultRematchWindow
+func newRoom(h *hub, code string, turnLimit, graceFor, idleFor time.Duration) *room {
+ if idleFor <= 0 {
+ idleFor = defaultIdleWindow
}
ctx, cancel := context.WithCancel(h.ctx)
return &room{
- code: code,
- inputs: make(chan any, roomInputCap),
- ctx: ctx,
- cancel: cancel,
- hub: h,
- dict: h.dict,
- turnLimit: turnLimit,
- graceFor: graceFor,
- rematchAfter: rematchAfter,
+ code: code,
+ inputs: make(chan any, roomInputCap),
+ ctx: ctx,
+ cancel: cancel,
+ hub: h,
+ dict: h.dict,
+ turnLimit: turnLimit,
+ graceFor: graceFor,
+ idleFor: idleFor,
}
}
@@ -217,7 +248,7 @@ func (r *room) run() {
defer r.cancel()
defer r.hub.evict(r.code)
- var turnTimer, graceTimer, rematchTimer *time.Timer
+ var turnTimer, graceTimer, idleTimer *time.Timer
stop := func(t *time.Timer) {
if t != nil {
t.Stop()
@@ -226,7 +257,7 @@ func (r *room) run() {
defer func() {
stop(turnTimer)
stop(graceTimer)
- stop(rematchTimer)
+ stop(idleTimer)
}()
// resetTurnTimer rebuilds the deadline timer after anything that changes
@@ -242,16 +273,28 @@ func (r *room) run() {
turnTimer = time.NewTimer(time.Until(r.engine.Deadline()))
}
+ // resetIdleTimer restarts the lobby's own deadline. It runs only while no
+ // game does: a game is bounded by the turn clock, and a room that is being
+ // played in is not idle.
+ resetIdleTimer := func() {
+ stop(idleTimer)
+ idleTimer = nil
+ if r.strategy != nil || !r.inLobby() {
+ return
+ }
+ idleTimer = time.NewTimer(r.idleFor)
+ }
+
for {
- var turnC, graceC, rematchC <-chan time.Time
+ var turnC, graceC, idleC <-chan time.Time
if turnTimer != nil {
turnC = turnTimer.C
}
if graceTimer != nil {
graceC = graceTimer.C
}
- if rematchTimer != nil {
- rematchC = rematchTimer.C
+ if idleTimer != nil {
+ idleC = idleTimer.C
}
select {
@@ -274,6 +317,9 @@ func (r *room) run() {
case botMoveInput:
r.handleBotMove(m)
resetTurnTimer()
+ case lobbyInput:
+ r.handleLobby(m)
+ resetTurnTimer()
case resignInput:
if !r.occupies(m.sess, m.player) {
m.sess.send(errorMsg("not_your_seat"))
@@ -284,21 +330,15 @@ func (r *room) run() {
}
resetTurnTimer()
case disconnectInput:
- // Leaving is how a rematch is declined, so a player who drops
- // while the offer is open ends the room rather than leaving
- // the other one watching a countdown that cannot resolve.
+ // A dropped connection is not a player leaving: the seat is
+ // held for the reconnect window whether a game is running or
+ // the room is sitting in its lobby, so a refresh does not cost
+ // somebody their room.
//
// handleDisconnect returning false means the notice was stale —
// from a connection the seat no longer holds — and acting on
- // that would end a room whose players are both still here.
- if r.offeringRematch() {
- if applied, _ := r.handleDisconnect(m); applied {
- r.abandonRematch()
- return
- }
- break
- }
- if _, live := r.handleDisconnect(m); live {
+ // that would evict a seat its new socket is sitting in.
+ if r.handleDisconnect(m) {
stop(graceTimer)
graceTimer = time.NewTimer(r.graceFor)
}
@@ -308,11 +348,6 @@ func (r *room) run() {
stop(graceTimer)
graceTimer = nil
resetTurnTimer()
- case rematchInput:
- r.handleRematch(m, time.Now())
- // A rematch that both sides accepted has already started a new
- // game, so the turn clock has to come back with it.
- resetTurnTimer()
}
case <-turnC:
@@ -325,38 +360,51 @@ func (r *room) run() {
resetTurnTimer()
case <-graceC:
- r.endForAbandonment()
- return
+ graceTimer = nil
+ r.handleGraceExpiry()
+ resetTurnTimer()
- case <-rematchC:
- // Nobody, or only one of them, asked in time.
- r.abandonRematch()
+ case <-idleC:
+ // A lobby nobody started a game in. Whoever is still sitting in it
+ // is told why it closed rather than watching their buttons stop
+ // working.
+ r.broadcastError("room_idle_closed")
return
}
- // Every way a game can end arrives here: a move, a resignation, a
- // disconnection, or the turn clock. A finished game closes the room
- // unless both players are still present to be asked for another. The
- // offer check keeps this from firing again while one is already open.
- if r.engine != nil && r.engine.Over() && !r.offeringRematch() {
- if !r.offerRematch(time.Now()) {
- return
- }
- stop(rematchTimer)
- rematchTimer = time.NewTimer(r.rematchAfter)
+ // One broadcast per input, from the one place that knows the input is
+ // finished. A kick, a grace window running out and a game ending all
+ // leave the room in the same state — a lobby — and this is where that
+ // state goes out.
+ if r.strategy == nil && r.lobbyChanged {
+ r.lobbyChanged = false
+ r.broadcastRoomState()
}
- if !r.offeringRematch() {
- stop(rematchTimer)
- rematchTimer = nil
+
+ // A bot room is its game: there is no lobby to return to and nobody to
+ // wait for, so it closes with the last move.
+ if r.strategy != nil && r.engine != nil && r.engine.Over() {
+ return
}
+ // Everyone has left, or the last reconnect window ran out. Nothing is
+ // coming that could fill the room again — a joiner needs a code the
+ // hub is about to forget.
+ if !r.occupied() {
+ return
+ }
+ resetIdleTimer()
}
}
-// handleCreate seats the room's creator and waits for an opponent.
+// handleCreate seats the room's creator, who owns it, and opens the lobby.
+//
+// The code goes out in the RoomState the run loop broadcasts, so a client can
+// never be handed a code before the seat behind it exists.
func (r *room) handleCreate(m createInput) {
r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess}
+ r.owner = "p1"
m.sess.attach(r, "p1")
- m.sess.send(roomCreatedMsg(r.code))
+ r.lobbyChanged = true
}
// handleStartBot seats a bot opposite the player and begins immediately.
@@ -371,6 +419,7 @@ func (r *room) handleStartBot(m startBotInput) {
r.strategy = strategy
r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess}
r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"}
+ r.owner = "p1"
m.sess.attach(r, "p1")
if err := r.beginGame(); err != nil {
@@ -380,37 +429,130 @@ func (r *room) handleStartBot(m startBotInput) {
}
}
-// handleJoin seats the second human and starts the game.
+// handleJoin seats a second human in the lobby. It no longer starts anything:
+// the owner does that, once this player says they are ready.
//
// The seat is bound here, on the room goroutine, and only on success. Binding
// it in the hub before this decision would leave a refused joiner still
-// holding seat "p2", and every later Submit or Resign it sent would be applied
-// to the real player sitting there.
+// holding a seat, and every later Submit or Resign it sent would be applied to
+// the real player sitting there.
func (r *room) handleJoin(m joinInput) {
- if r.seats[0] == nil || r.seats[1] != nil {
+ free := r.freeSeat()
+ if free < 0 || !r.occupied() {
m.sess.send(errorMsg("room_full"))
return
}
- if r.seats[0].sess == m.sess {
- m.sess.send(errorMsg("cannot_join_own_room"))
+ // A game in progress fills both seats, so this only catches a room whose
+ // seat was freed by the very disconnect that ended the game — for the
+ // moment before the room notices.
+ if !r.inLobby() {
+ m.sess.send(errorMsg("room_full"))
return
}
- r.seats[1] = &seat{
- id: "p2",
- nickname: distinguish(m.sess.nickname(), r.seats[0].nickname),
- sess: m.sess,
- }
- m.sess.attach(r, "p2")
-
- for i, s := range r.seats {
- if s.sess != nil {
- s.sess.send(roomJoinedMsg(r.code, r.seats[1-i].nickname))
+ for _, s := range r.seats {
+ if s != nil && s.sess == m.sess {
+ m.sess.send(errorMsg("cannot_join_own_room"))
+ return
}
}
- if err := r.beginGame(); err != nil {
- slog.Error("could not start pvp game", "room", r.code, "err", err)
- r.broadcastError("game_start_failed")
- r.cancel()
+
+ id := seatIDs[free]
+ r.seats[free] = &seat{
+ id: id,
+ nickname: distinguish(m.sess.nickname(), r.otherNickname(id)),
+ sess: m.sess,
+ }
+ m.sess.attach(r, string(id))
+ r.lobbyChanged = true
+}
+
+// handleLobby applies one lobby action.
+//
+// Every refusal answers with a reason. A lobby button that silently does
+// nothing is indistinguishable from one that is broken, and the player cannot
+// see the state that refused them.
+func (r *room) handleLobby(m lobbyInput) {
+ if !r.occupies(m.sess, m.player) {
+ m.sess.send(errorMsg("not_your_seat"))
+ return
+ }
+ if r.strategy != nil {
+ // A bot room has no lobby: one player, no readiness, nobody to kick.
+ m.sess.send(errorMsg("not_in_a_room"))
+ return
+ }
+ if !r.inLobby() {
+ m.sess.send(errorMsg("game_in_progress"))
+ return
+ }
+
+ mine := r.seatOf(m.player)
+ isOwner := m.player == r.owner
+
+ switch m.action {
+ case lobbyReady:
+ if isOwner {
+ // The owner's readiness is StartGame. A flag of their own would
+ // only be something they had to set before every single start.
+ m.sess.send(errorMsg("owner_needs_no_ready"))
+ return
+ }
+ mine.ready = m.ready
+ r.lobbyChanged = true
+
+ case lobbyStart:
+ if !isOwner {
+ m.sess.send(errorMsg("not_the_owner"))
+ return
+ }
+ guest := r.guestSeat()
+ switch {
+ case guest == nil:
+ m.sess.send(errorMsg("need_two_players"))
+ return
+ case guest.sess == nil:
+ m.sess.send(errorMsg("opponent_offline"))
+ return
+ case !guest.ready:
+ m.sess.send(errorMsg("not_everyone_ready"))
+ return
+ }
+ if err := r.beginGame(); err != nil {
+ slog.Error("could not start pvp game", "room", r.code, "err", err)
+ r.broadcastError("game_start_failed")
+ }
+
+ case lobbyKick:
+ if !isOwner {
+ m.sess.send(errorMsg("not_the_owner"))
+ return
+ }
+ guest := r.guestSeat()
+ if guest == nil {
+ m.sess.send(errorMsg("no_one_to_kick"))
+ return
+ }
+ // Readiness is a commitment, and the owner does not get to overrule
+ // one: a guest who is ready is waiting on the owner, not in the way.
+ if guest.ready {
+ m.sess.send(errorMsg("player_is_ready"))
+ return
+ }
+ if guest.sess != nil {
+ guest.sess.send(errorMsg("kicked"))
+ }
+ r.vacate(guest)
+ r.lobbyChanged = true
+
+ case lobbyLeave:
+ // Unreadying first is deliberate friction: a player the other one is
+ // waiting on should have to take that back before walking away.
+ if mine.ready {
+ m.sess.send(errorMsg("must_unready_first"))
+ return
+ }
+ r.vacate(mine)
+ r.lobbyChanged = true
}
}
@@ -440,10 +582,11 @@ func (r *room) beginGame() error {
// submission still in flight from the previous game would otherwise be
// able to match a turn in this one and be applied to it.
r.turnSeq++
+ // Every game is agreed on its own. The readiness that started this one is
+ // spent, so the lobby they come back to asks again.
for _, s := range r.seats {
- s.wantsRematch = false
+ s.ready = false
}
- r.rematchUntil = time.Time{}
for _, s := range r.seats {
r.sendGameStarted(s)
@@ -616,6 +759,9 @@ func (r *room) broadcastGameOver() {
}
s.sess.send(r.gameOverFor(state, s.id, EndReason(state.EndReason)))
}
+ // A finished game is a return to the lobby, and the run loop reports the
+ // state they are returning to.
+ r.lobbyChanged = true
}
// gameOverFor renders a finished game for one seat.
@@ -643,45 +789,50 @@ func (r *room) gameOverFor(state game.State, id game.PlayerID, reason noituv1.Ga
}}
}
-// handleDisconnect holds the seat open, reporting whether a grace window
-// should now run.
-// handleDisconnect reports two separate things, because the caller needs both
-// and they are not the same question: whether the notice actually applied to
-// the seat, and whether a game is still running that the player could come
-// back to. A disconnection during a rematch offer applies and is not live.
-func (r *room) handleDisconnect(m disconnectInput) (applied, live bool) {
+// handleDisconnect holds the seat open and reports whether the notice applied.
+//
+// A dropped connection is not a player leaving. The seat is kept for the
+// reconnect window whether a game is running or the room is sitting in its
+// lobby, so refreshing the page does not cost somebody the room they are in.
+func (r *room) handleDisconnect(m disconnectInput) bool {
s := r.seatOf(m.player)
// A stale notice from a connection the player already replaced. Evicting
// on it would drop the seat the new socket is sitting in.
if s == nil || s.sess == nil || s.sess != m.sess {
- return false, false
+ return false
}
s.sess = nil
- // Both sides gone: nobody is left to win, so there is nothing to hold the
- // room open for.
+ // A second seat dropping means nobody is here: during a game there is
+ // nobody left to win, and in a lobby nobody left to play. The room ends
+ // rather than waiting out a window with no winner to declare.
if r.disconnected != nil && r.disconnected != s {
r.cancel()
- return true, false
+ return false
}
r.disconnected = s
+ r.lobbyChanged = true
- // Before the game starts there is no turn timer and no opponent, so no
- // clock can ever end this room. Without this it would sit in select
- // forever, holding a goroutine and a room code for a game nobody is in.
- if r.engine == nil {
- r.cancel()
- return true, false
+ // A player mid-game is told their opponent may be coming back, with how
+ // long they have. In a lobby the same fact is part of the room's state and
+ // travels with the rest of it, so there is nothing extra to send.
+ live := r.engine != nil && !r.engine.Over()
+ if other := r.opponentSeat(s.id); live && other != nil && other.sess != nil {
+ other.sess.send(opponentLeftMsg(true, uint32(r.graceFor.Milliseconds())))
}
+ return true
+}
- live = !r.engine.Over()
- if other := r.opponentSeat(s.id); other != nil && other.sess != nil {
- // can_reconnect only means something while there is a game to come
- // back to. Promising it after the final move contradicts the frame
- // that follows it.
- other.sess.send(opponentLeftMsg(live, uint32(r.graceFor.Milliseconds())))
+// handleGraceExpiry decides what a reconnect window running out means.
+func (r *room) handleGraceExpiry() {
+ if r.disconnected == nil {
+ return
}
- return true, live
+ // A live game is awarded first: once the seat is gone there is no opponent
+ // left to award it against.
+ r.endForAbandonment()
+ r.vacate(r.disconnected)
+ r.lobbyChanged = true
}
// handleResume rebinds a seat to a new connection and replays the position.
@@ -695,12 +846,6 @@ func (r *room) handleResume(m resumeInput) {
m.sess.send(errorMsg("session_not_resumable"))
return
}
- // A finished game has no seat to take, including one still waiting on a
- // rematch answer. The old connection stays exactly as it was.
- if r.engine != nil && r.engine.Over() {
- m.sess.send(errorMsg("game_already_over"))
- return
- }
// Accepted. Only now is the old connection finished: its token is spent and
// its socket is either gone or about to be, and leaving it registered would
@@ -718,17 +863,14 @@ func (r *room) handleResume(m resumeInput) {
r.disconnected = nil
}
- // Tell the other player their opponent is back. Without this the seat is
- // restored but the waiting player is left watching a disconnect banner for
- // somebody who is already playing again.
- if other := r.opponentSeat(s.id); other != nil && other.sess != nil {
- other.sess.send(roomJoinedMsg(r.code, s.nickname))
- }
+ // Both players need the room's state again: this one to render the lobby
+ // it came back to, the other to stop watching a disconnect banner for
+ // somebody who is already back. The run loop sends it to both.
+ r.lobbyChanged = true
- // Resumed into a room whose game has not started: the seat is restored and
- // the client waits for an opponent exactly as it was.
- if r.engine == nil {
- s.sess.send(roomCreatedMsg(r.code))
+ // Resumed between games, or before the first one. The lobby state above is
+ // the whole answer; there is no position to replay.
+ if r.inLobby() {
return
}
r.sendGameStarted(s)
@@ -750,7 +892,9 @@ func (r *room) handleResume(m resumeInput) {
}
}
-// endForAbandonment awards the game to whoever stayed.
+// endForAbandonment awards a live game to whoever stayed. The room itself
+// survives: the winner is still sitting in it, and it is theirs to hand on or
+// leave.
func (r *room) endForAbandonment() {
if r.engine == nil || r.engine.Over() || r.disconnected == nil {
return
@@ -767,89 +911,142 @@ func (r *room) endForAbandonment() {
}
s.sess.send(r.gameOverFor(state, s.id, noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT))
}
+ r.lobbyChanged = true
}
-// offerRematch decides what a finished game means for the room, and reports
-// whether the room should keep running.
-//
-// Only a room with two connected humans can offer one. A bot room has nothing
-// to negotiate — the client simply asks for another game — and a room whose
-// opponent has already gone has nobody to ask.
-func (r *room) offerRematch(now time.Time) bool {
- if r.strategy != nil {
+// inLobby reports whether the room is between games. Everything a lobby
+// allows is refused while a game is running, and the engine is the authority
+// on that.
+func (r *room) inLobby() bool { return r.engine == nil || r.engine.Over() }
+
+// occupied reports whether anybody still holds a seat, including a player
+// inside their reconnect window. An empty room has nothing left to wait for.
+func (r *room) occupied() bool {
+ for _, s := range r.seats {
+ if s != nil {
+ return true
+ }
+ }
+ return false
+}
+
+// freeSeat returns the index a joiner would take, or -1 when the room is full.
+func (r *room) freeSeat() int {
+ for i, s := range r.seats {
+ if s == nil {
+ return i
+ }
+ }
+ return -1
+}
+
+// seatIDs are the two engine seat names, indexed by position. An id says which
+// seat a player is in and nothing about their role: an owner who leaves hands
+// that on, and the seat they vacate is refilled by an ordinary guest.
+var seatIDs = [2]game.PlayerID{"p1", "p2"}
+
+func (r *room) ownerSeat() *seat { return r.seatOf(r.owner) }
+
+// guestSeat is the seat that is not the owner's, or nil when nobody else is
+// here.
+func (r *room) guestSeat() *seat {
+ for _, s := range r.seats {
+ if s != nil && s.id != r.owner {
+ return s
+ }
+ }
+ return nil
+}
+
+// otherNickname is the name already taken in this room, so a joiner can be
+// distinguished from it.
+func (r *room) otherNickname(mine game.PlayerID) string {
+ for _, s := range r.seats {
+ if s != nil && s.id != mine {
+ return s.nickname
+ }
+ }
+ return ""
+}
+
+// canStart reports whether StartGame would be accepted. The server answers
+// this rather than the client because it owns every condition that feeds it.
+func (r *room) canStart() bool {
+ if r.strategy != nil || !r.inLobby() {
return false
}
- for _, s := range r.seats {
- if s == nil || s.sess == nil {
- return false
+ owner, guest := r.ownerSeat(), r.guestSeat()
+ return owner != nil && owner.sess != nil &&
+ guest != nil && guest.sess != nil && guest.ready
+}
+
+// vacate frees a seat for good - the player left, was kicked, or never came
+// back - and hands the room on when the seat was the owner's.
+func (r *room) vacate(s *seat) {
+ if s == nil {
+ return
+ }
+ if s.sess != nil {
+ // The connection stays open; it is simply no longer in this room, so
+ // anything else it sends here is refused rather than applied to a seat
+ // somebody else may now be sitting in.
+ s.sess.release(r)
+ s.sess = nil
+ }
+ for i, existing := range r.seats {
+ if existing == s {
+ r.seats[i] = nil
}
- s.wantsRematch = false
}
-
- r.rematchUntil = now.Add(r.rematchAfter)
- r.broadcastRematchState(now)
- return true
-}
-
-// offeringRematch reports whether an offer is currently open.
-func (r *room) offeringRematch() bool { return !r.rematchUntil.IsZero() }
-
-// handleRematch records one player's answer and starts the next game once both
-// have given it.
-func (r *room) handleRematch(m rematchInput, now time.Time) {
- if !r.occupies(m.sess, m.player) {
- m.sess.send(errorMsg("not_your_seat"))
- return
+ if r.disconnected == s {
+ r.disconnected = nil
}
- if !r.offeringRematch() {
- m.sess.send(errorMsg("no_rematch_offered"))
- return
- }
-
- s := r.seatOf(m.player)
- s.wantsRematch = true
-
- if !r.seats[0].wantsRematch || !r.seats[1].wantsRematch {
- r.broadcastRematchState(now)
- return
- }
-
- // beginGame clears the offer and the acceptances, so the state broadcast
- // above is not repeated here: GameStarted is the answer.
- if err := r.beginGame(); err != nil {
- slog.Error("could not start rematch", "room", r.code, "err", err)
- r.broadcastError("game_start_failed")
- r.cancel()
+ if r.owner == s.id {
+ r.promote()
}
}
-// broadcastRematchState tells each player where both answers stand. It is
-// built per recipient because "mine" and "theirs" are different for each.
-func (r *room) broadcastRematchState(now time.Time) {
- left := uint32(max(0, r.rematchUntil.Sub(now).Milliseconds()))
+// promote hands the room to whoever is left.
+func (r *room) promote() {
+ for _, s := range r.seats {
+ if s != nil {
+ r.owner = s.id
+ // The new owner starts games, and starting is their readiness. A
+ // flag they set as a guest would sit there meaning nothing.
+ s.ready = false
+ return
+ }
+ }
+ r.owner = ""
+}
+
+// broadcastRoomState sends the whole lobby to each occupant.
+//
+// Built per recipient because every field in it is relative to who is being
+// told: their role, their readiness, and the other player. One snapshot rather
+// than a stream of deltas is what lets a client that missed a frame - or has
+// just reconnected - be correct again from the next one.
+func (r *room) broadcastRoomState() {
+ canStart := r.canStart()
for i, s := range r.seats {
if s == nil || s.sess == nil {
continue
}
- s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RematchState{
- RematchState: &noituv1.RematchState{
- IAccepted: s.wantsRematch,
- OpponentAccepted: r.seats[1-i].wantsRematch,
- ExpiresInMs: left,
- },
- }})
- }
-}
-
-// abandonRematch tells whoever is still here that the offer is dead, so their
-// countdown resolves into an answer instead of just running out.
-func (r *room) abandonRematch() {
- for _, s := range r.seats {
- if s == nil || s.sess == nil {
- continue
+ other := r.seats[1-i]
+ state := &noituv1.RoomState{
+ RoomCode: r.code,
+ IAmOwner: s.id == r.owner,
+ CanStart: canStart,
+ IAmReady: s.ready,
+ OpponentPresent: other != nil,
}
- s.sess.send(opponentLeftMsg(false, 0))
+ if other != nil {
+ state.OpponentName = other.nickname
+ state.OpponentReady = other.ready
+ state.OpponentConnected = other.sess != nil
+ }
+ s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomState{RoomState: state}})
}
}
diff --git a/server/internal/wsapi/server.go b/server/internal/wsapi/server.go
index 8a6b91c..74f0c1e 100644
--- a/server/internal/wsapi/server.go
+++ b/server/internal/wsapi/server.go
@@ -20,9 +20,9 @@ type Config struct {
TurnLimit time.Duration
// GraceFor is how long a disconnected seat is held open.
GraceFor time.Duration
- // RematchFor is how long a finished room waits for both players to ask for
- // another game. Zero falls back to a built-in default.
- RematchFor time.Duration
+ // IdleFor is how long a room sits in its lobby with no game started before
+ // it closes. Zero falls back to a built-in default.
+ IdleFor time.Duration
// AllowedOrigins is matched by coder/websocket against the Origin header.
// Empty means same-origin only, which is the right default for a binary
// that also serves the frontend.
@@ -45,7 +45,7 @@ func NewServer(ctx context.Context, dict Dictionary, cfg Config) *Server {
ctx, cancel := context.WithCancel(ctx)
s := &Server{
- hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.RematchFor),
+ hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.IdleFor),
mux: http.NewServeMux(),
cancel: cancel,
cfg: cfg,
diff --git a/server/internal/wsapi/session.go b/server/internal/wsapi/session.go
index 13f2020..113b338 100644
--- a/server/internal/wsapi/session.go
+++ b/server/internal/wsapi/session.go
@@ -157,6 +157,20 @@ func (s *session) attach(r *room, seatName string) {
}
}
+// release forgets a room this connection is no longer seated in, because it
+// left or was kicked. The connection itself stays open.
+//
+// Guarded by identity: a release from a room the connection has already moved
+// on from must not detach it from the one it is sitting in now.
+func (s *session) release(r *room) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.room == r {
+ s.room = nil
+ s.playerID = ""
+ }
+}
+
func (s *session) currentRoom() (*room, game.PlayerID) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -401,23 +415,17 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
s.send(errorMsg("not_in_a_game"))
}
- case *noituv1.ClientMessage_RequestRematch:
- // Rate-limited like a submission: every accepted request is broadcast
- // to both seats, so an unbounded one lets a player flood the opponent's
- // outbox until their session is closed for falling behind.
- if !s.submitLimiter.allow(time.Now()) {
- s.send(errorMsg("too_fast"))
- return nil
- }
- // A dropped request leaves the player watching a countdown that will
- // never resolve, so it is worth an explicit refusal.
- if r, id := s.currentRoom(); r != nil {
- if !r.send(rematchInput{sess: s, player: id}) {
- s.send(errorMsg("game_already_over"))
- }
- } else {
- s.send(errorMsg("not_in_a_game"))
- }
+ case *noituv1.ClientMessage_SetReady:
+ s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()})
+
+ case *noituv1.ClientMessage_StartGame:
+ s.toRoom(lobbyInput{sess: s, action: lobbyStart})
+
+ case *noituv1.ClientMessage_KickPlayer:
+ s.toRoom(lobbyInput{sess: s, action: lobbyKick})
+
+ case *noituv1.ClientMessage_LeaveRoom:
+ s.toRoom(lobbyInput{sess: s, action: lobbyLeave})
case *noituv1.ClientMessage_Ping:
s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli()))
@@ -425,6 +433,28 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
return nil
}
+// toRoom forwards one lobby action to the room this connection is seated in.
+//
+// Rate-limited like a submission: every accepted action is broadcast to both
+// seats, so an unbounded one lets a player flood the other's outbox until
+// their session is closed for falling behind. A dropped action would leave a
+// button that did nothing and no reason why, so every failure answers.
+func (s *session) toRoom(in lobbyInput) {
+ if !s.submitLimiter.allow(time.Now()) {
+ s.send(errorMsg("too_fast"))
+ return
+ }
+ r, id := s.currentRoom()
+ if r == nil {
+ s.send(errorMsg("not_in_a_room"))
+ return
+ }
+ in.player = id
+ if !r.send(in) {
+ s.send(errorMsg("not_in_a_room"))
+ }
+}
+
// handleHello completes the handshake, resuming a prior game when the client
// presents a token that is still live.
func (s *session) handleHello(h *noituv1.Hello) error {
diff --git a/server/internal/wsapi/wire_test.go b/server/internal/wsapi/wire_test.go
index 920445b..cdc54d0 100644
--- a/server/internal/wsapi/wire_test.go
+++ b/server/internal/wsapi/wire_test.go
@@ -47,9 +47,12 @@ func clientVariants() map[string]*noituv1.ClientMessage {
"client_ping": {Payload: &noituv1.ClientMessage_Ping{Ping: &noituv1.Ping{
ClientTimeMs: 1756998000123,
}}},
- "client_request_rematch": {Payload: &noituv1.ClientMessage_RequestRematch{
- RequestRematch: &noituv1.RequestRematch{},
- }},
+ "client_set_ready": {Payload: &noituv1.ClientMessage_SetReady{SetReady: &noituv1.SetReady{
+ Ready: true,
+ }}},
+ "client_start_game": {Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}},
+ "client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{}}},
+ "client_leave_room": {Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}},
}
}
@@ -62,13 +65,6 @@ func serverVariants() map[string]*noituv1.ServerMessage {
ProtocolVersion: 1,
AcceptedNickname: "Người chơi ẩn danh",
}}},
- "server_room_created": {Payload: &noituv1.ServerMessage_RoomCreated{RoomCreated: &noituv1.RoomCreated{
- RoomCode: "K7QX",
- }}},
- "server_room_joined": {Payload: &noituv1.ServerMessage_RoomJoined{RoomJoined: &noituv1.RoomJoined{
- RoomCode: "K7QX",
- OpponentName: "Thuý",
- }}},
"server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{
OpeningWord: "hòa bình",
CurrentSyllable: "bình",
@@ -122,13 +118,18 @@ func serverVariants() map[string]*noituv1.ServerMessage {
// Asymmetric on purpose: equal booleans would not catch the two fields
// being swapped, which is exactly the mistake that shows one player
// their opponent's answer as their own.
- "server_rematch_state": {Payload: &noituv1.ServerMessage_RematchState{
- RematchState: &noituv1.RematchState{
- IAccepted: true,
- OpponentAccepted: false,
- ExpiresInMs: 27500,
- },
- }},
+ "server_room_state": {Payload: &noituv1.ServerMessage_RoomState{RoomState: &noituv1.RoomState{
+ RoomCode: "K7QX",
+ // An owner looking at a guest who is here, ready, and connected:
+ // the one combination in which every boolean is load-bearing.
+ IAmOwner: true,
+ CanStart: true,
+ IAmReady: false,
+ OpponentPresent: true,
+ OpponentName: "Khách mời",
+ OpponentReady: true,
+ OpponentConnected: true,
+ }}},
}
}
diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go
index 9baa69f..84b8c91 100644
--- a/server/internal/wsapi/wsapi_test.go
+++ b/server/internal/wsapi/wsapi_test.go
@@ -112,8 +112,10 @@ func newTestServer(t *testing.T, dict Dictionary, cfg Config) (*Server, string)
if cfg.GraceFor == 0 {
cfg.GraceFor = time.Second
}
- if cfg.RematchFor == 0 {
- cfg.RematchFor = time.Second
+ // Long enough that no test loses its lobby to the idle clock. The one
+ // test that exercises that clock sets its own.
+ if cfg.IdleFor == 0 {
+ cfg.IdleFor = 30 * time.Second
}
api := NewServer(ctx, dict, cfg)
@@ -187,10 +189,6 @@ func payloadCase(m *noituv1.ServerMessage) string {
switch m.GetPayload().(type) {
case *noituv1.ServerMessage_Welcome:
return "welcome"
- case *noituv1.ServerMessage_RoomCreated:
- return "room_created"
- case *noituv1.ServerMessage_RoomJoined:
- return "room_joined"
case *noituv1.ServerMessage_GameStarted:
return "game_started"
case *noituv1.ServerMessage_TurnUpdate:
@@ -205,8 +203,8 @@ func payloadCase(m *noituv1.ServerMessage) string {
return "error"
case *noituv1.ServerMessage_Pong:
return "pong"
- case *noituv1.ServerMessage_RematchState:
- return "rematch_state"
+ case *noituv1.ServerMessage_RoomState:
+ return "room_state"
}
// Named rather than empty: a missing arm here makes every await for that
// message time out with nothing to say about why.
@@ -286,13 +284,14 @@ func TestPvPGameAlternatesTurns(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()
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)
hostStart := host.await("game_started").GetGameStarted()
guestStart := guest.await("game_started").GetGameStarted()
@@ -336,13 +335,14 @@ func TestTurnTimeoutEndsGameServerSide(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()
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")
@@ -405,13 +405,14 @@ func TestReplayingAWordIsRejected(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()
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)
hostStart := host.await("game_started").GetGameStarted()
guest.await("game_started")
@@ -455,13 +456,14 @@ func TestResumeWithinGraceRestoresGame(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")
@@ -496,13 +498,14 @@ func TestGraceExpiryAwardsTheGame(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()
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")
@@ -920,143 +923,422 @@ func settle() {
runtime.GC()
}
-// --- rematch ---------------------------------------------------------------
+// --- the lobby -------------------------------------------------------------
-// pvpRoom seats two players and returns them with the opening position, so a
-// rematch test can get to a finished game without restating the setup.
+// pvpRoom seats two players and plays them into a game, so a test about what
+// happens next does not restate the whole handshake.
func pvpRoom(t *testing.T, url string) (host, guest *testClient, start *noituv1.GameStarted) {
t.Helper()
- 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()
-
- guest = dial(t, url)
- guest.hello("Khách")
- guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
- JoinRoom: &noituv1.JoinRoom{RoomCode: code},
- }})
+ host, guest, _ = pvpLobby(t, url)
+ guest.setReady(true)
+ host.await("room_state")
+ host.startGame()
start = host.await("game_started").GetGameStarted()
guest.await("game_started")
return host, guest, start
}
-func (c *testClient) requestRematch() {
+// pvpLobby seats two players and stops there: the room exists, nobody is ready
+// and no game has been started.
+func pvpLobby(t *testing.T, url string) (host, guest *testClient, code string) {
+ t.Helper()
+
+ host = dial(t, url)
+ host.hello("Chủ phòng")
+ host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
+ 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")
+ return host, guest, code
+}
+
+// readyAndStart takes a seated pair from their lobby into a game, which is
+// what every test that is about the game itself needs to get past.
+func readyAndStart(t *testing.T, host, guest *testClient) {
+ t.Helper()
+ host.await("room_state")
+ guest.await("room_state")
+ guest.setReady(true)
+ host.await("room_state")
+ host.startGame()
+}
+
+func (c *testClient) setReady(ready bool) {
c.t.Helper()
- c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_RequestRematch{
- RequestRematch: &noituv1.RequestRematch{},
+ c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SetReady{
+ SetReady: &noituv1.SetReady{Ready: ready},
}})
}
-// resignAndSettle ends the game and drains the offer that follows, returning
-// the rematch state each player was shown.
-func resignAndSettle(t *testing.T, host, guest *testClient) (hostState, guestState *noituv1.RematchState) {
+func (c *testClient) startGame() {
+ c.t.Helper()
+ c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}})
+}
+
+func (c *testClient) kickPlayer() {
+ c.t.Helper()
+ c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{}}})
+}
+
+func (c *testClient) leaveRoom() {
+ c.t.Helper()
+ c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}})
+}
+
+// resignAndSettle ends the game and returns the lobby each player lands back
+// in.
+func resignAndSettle(t *testing.T, host, guest *testClient) (hostState, guestState *noituv1.RoomState) {
t.Helper()
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
host.await("game_over")
guest.await("game_over")
- return host.await("rematch_state").GetRematchState(),
- guest.await("rematch_state").GetRematchState()
+ return host.await("room_state").GetRoomState(),
+ guest.await("room_state").GetRoomState()
}
-// TestRematchOfferFollowsAFinishedPvPGame checks the room outlives its game.
-// Before rematch existed the room goroutine returned the moment the engine was
-// over, so there was nothing left to ask.
-func TestRematchOfferFollowsAFinishedPvPGame(t *testing.T) {
+// 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{})
- host, guest, _ := pvpRoom(t, url)
- hostState, guestState := resignAndSettle(t, host, guest)
+ // 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
+ code := host.await("room_state").GetRoomState().GetRoomCode()
- for name, s := range map[string]*noituv1.RematchState{"host": hostState, "guest": guestState} {
- if s.GetIAccepted() || s.GetOpponentAccepted() {
- t.Errorf("%s: the opening offer should have nobody accepted yet, got %+v", name, s)
- }
- if s.GetExpiresInMs() == 0 {
- t.Errorf("%s: the offer must carry the time left to answer", name)
- }
+ guest := dial(t, url)
+ guest.hello("Khách")
+ guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
+ JoinRoom: &noituv1.JoinRoom{RoomCode: code},
+ }})
+
+ hostState := host.await("room_state").GetRoomState()
+ guestState := guest.await("room_state").GetRoomState()
+
+ if !hostState.GetIAmOwner() {
+ t.Error("the player who created the room does not own it")
}
+ if guestState.GetIAmOwner() {
+ t.Error("the player who joined was made owner")
+ }
+ if hostState.GetCanStart() || guestState.GetCanStart() {
+ t.Error("a game can start with nobody ready")
+ }
+ if !hostState.GetOpponentPresent() || hostState.GetOpponentName() == "" {
+ 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)
}
-// TestRematchStateIsRenderedPerRecipient is the guard against the two booleans
-// being swapped, which would show a player their opponent's answer as theirs.
-func TestRematchStateIsRenderedPerRecipient(t *testing.T) {
+// 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, _ := pvpRoom(t, url)
- resignAndSettle(t, host, guest)
+ host, guest, _ := pvpLobby(t, url)
- host.requestRematch()
+ guest.setReady(true)
- hostState := host.await("rematch_state").GetRematchState()
- guestState := guest.await("rematch_state").GetRematchState()
+ guestState := guest.await("room_state").GetRoomState()
+ hostState := host.await("room_state").GetRoomState()
- if !hostState.GetIAccepted() || hostState.GetOpponentAccepted() {
- t.Errorf("the asker should see only their own acceptance, got %+v", hostState)
+ if !guestState.GetIAmReady() || guestState.GetOpponentReady() {
+ t.Errorf("the guest should see only their own readiness, got %+v", guestState)
}
- if guestState.GetIAccepted() || !guestState.GetOpponentAccepted() {
- t.Errorf("the other player should see only the opponent's acceptance, got %+v", guestState)
+ if hostState.GetIAmReady() || !hostState.GetOpponentReady() {
+ 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")
}
}
-// TestRematchStartsANewGameWhenBothAccept covers the whole point of the
-// feature, and the two properties a fresh game has to have.
-func TestRematchStartsANewGameWhenBothAccept(t *testing.T) {
+// 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
+ code := host.await("room_state").GetRoomState().GetRoomCode()
+
+ // Alone in the room.
+ host.startGame()
+ if got := host.await("error").GetError().GetCode(); got != "need_two_players" {
+ t.Errorf("starting alone returned %q, want need_two_players", got)
+ }
+
+ 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")
+
+ // 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()
+ 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)
- resignAndSettle(t, host, guest)
- host.requestRematch()
- host.await("rematch_state")
- guest.requestRematch()
+ hostState, guestState := resignAndSettle(t, host, guest)
+ if hostState.GetOpponentReady() || guestState.GetIAmReady() {
+ 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)
+ }
+
+ guest.setReady(true)
+ host.await("room_state")
+ host.startGame()
second := host.await("game_started").GetGameStarted()
guest.await("game_started")
if second.GetTurnSeq() <= first.GetTurnSeq() {
- t.Errorf("turn_seq must keep rising across a rematch: %d then %d, so a submission "+
- "still in flight from the first game could be applied to the second",
+ 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("a rematch needs its own opening word")
+ t.Error("the second game needs its own opening word")
}
}
-// TestRematchIsRefusedWhileTheGameIsLive keeps the offer from being a way to
-// abandon a game in progress.
-func TestRematchIsRefusedWhileTheGameIsLive(t *testing.T) {
- _, url := newTestServer(t, chainDict(), Config{})
- host, _, _ := pvpRoom(t, url)
-
- host.requestRematch()
-
- if got := host.await("error").GetError().GetCode(); got != "no_rematch_offered" {
- t.Errorf("asking mid-game returned %q, want no_rematch_offered", got)
- }
-}
-
-// TestLeavingDeclinesTheRematch: there is no decline message, so the socket
-// closing has to be the one, and the other player must be told rather than
-// left watching a countdown that cannot resolve.
-func TestLeavingDeclinesTheRematch(t *testing.T) {
+// TestLobbyActionsAreRefusedDuringAGame keeps the lobby from being a way out
+// of a game in progress.
+func TestLobbyActionsAreRefusedDuringAGame(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
host, guest, _ := pvpRoom(t, url)
- resignAndSettle(t, host, guest)
- _ = guest.conn.Close(websocket.StatusNormalClosure, "")
-
- left := host.await("opponent_left").GetOpponentLeft()
- if left.GetCanReconnect() {
- t.Error("a player who left during the rematch offer is not coming back")
+ 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()
+ if got := host.await("error").GetError().GetCode(); got != "game_in_progress" {
+ t.Errorf("kicking mid-game returned %q, want game_in_progress", got)
+ }
+ guest.leaveRoom()
+ if got := guest.await("error").GetError().GetCode(); got != "game_in_progress" {
+ t.Errorf("leaving mid-game returned %q, want game_in_progress", got)
}
}
-// TestBotRoomDoesNotOfferARematch: a bot has nothing to negotiate, and the
-// client simply asks for another game. Keeping the room alive would leave one
-// goroutine and one engine per finished bot game.
-func TestBotRoomDoesNotOfferARematch(t *testing.T) {
+// 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 alone.GetOpponentPresent() {
+ t.Errorf("the owner still sees a guest who left: %+v", alone)
+ }
+ if !alone.GetIAmOwner() || 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()
+ 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()
+
+ 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(); got.GetOpponentPresent() {
+ t.Errorf("the kicked seat is still occupied: %+v", got)
+ }
+
+ // The seat is free, and a kick is not a ban.
+ guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
+ JoinRoom: &noituv1.JoinRoom{RoomCode: code},
+ }})
+ if got := guest.await("room_state").GetRoomState(); !got.GetOpponentPresent() {
+ 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 !promoted.GetIAmOwner() {
+ t.Errorf("the player left behind was not promoted: %+v", promoted)
+ }
+ if promoted.GetOpponentPresent() {
+ 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
+ JoinRoom: &noituv1.JoinRoom{RoomCode: code},
+ }})
+ guest.await("room_state")
+ third.await("room_state")
+ third.setReady(true)
+ guest.await("room_state")
+ guest.startGame()
+
+ 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 promoted.GetIAmReady() {
+ 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.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)
@@ -1069,80 +1351,57 @@ func TestBotRoomDoesNotOfferARematch(t *testing.T) {
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
c.await("game_over")
- // game_already_over is the room reporting that it has stopped reading,
- // which is the evidence wanted here: the goroutine and engine are gone
- // rather than parked waiting for an answer no bot can give.
- c.requestRematch()
- if got := c.await("error").GetError().GetCode(); got != "game_already_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)
}
}
-// TestRematchOfferExpires bounds how long a room outlives its game.
-func TestRematchOfferExpires(t *testing.T) {
- _, url := newTestServer(t, chainDict(), Config{RematchFor: 150 * time.Millisecond})
- host, guest, _ := pvpRoom(t, url)
- resignAndSettle(t, host, guest)
+// awaitNoRooms waits for the hub to forget every room it holds.
+func awaitNoRooms(t *testing.T, api *Server, what string) {
+ t.Helper()
- // Only one side asks, so the offer can only end by running out.
- host.requestRematch()
- host.await("rematch_state")
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ api.hub.mu.Lock()
+ left := len(api.hub.rooms)
+ api.hub.mu.Unlock()
- if got := host.await("opponent_left").GetOpponentLeft(); got.GetCanReconnect() {
- t.Error("an expired offer is final, not a reconnect window")
+ if left == 0 {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("%s was never evicted from the hub", what)
+ }
+ time.Sleep(20 * time.Millisecond)
}
}
-// TestRematchIsOfferedAfterATimeout is the regression for a rematch that could
-// only follow some endings. The offer was opened from the message arm of the
-// room loop, so the turn clock — the most common way a game actually ends —
-// closed the room with nothing to accept, while the client still showed the
-// button.
-func TestRematchIsOfferedAfterATimeout(t *testing.T) {
- _, url := newTestServer(t, chainDict(), Config{TurnLimit: 200 * time.Millisecond})
- host, guest, _ := pvpRoom(t, url)
-
- // Neither player moves, so the only thing that can end this is the clock.
- host.await("game_over")
- guest.await("game_over")
-
- if got := host.await("rematch_state").GetRematchState(); got.GetExpiresInMs() == 0 {
- t.Error("a game that ended on the clock should still offer a rematch")
- }
- guest.await("rematch_state")
-
- host.requestRematch()
- host.await("rematch_state")
- guest.requestRematch()
-
- host.await("game_started")
- guest.await("game_started")
-}
-
-// TestRefusedResumeLeavesTheLiveGameAlone is the regression for a resume that
-// retired the connection it was replacing before the room had agreed to the
-// swap. During a rematch offer the room refuses, so a second connection
-// presenting the same token used to disconnect the player who was still there
-// and take the room down with them.
-func TestRefusedResumeLeavesTheLiveGameAlone(t *testing.T) {
+// TestResumeReclaimsALobbySeat: a room outlives its games now, so a player who
+// refreshes between them has a lobby to come back to rather than a refusal.
+func TestResumeReclaimsALobbySeat(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
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},
}})
- host.await("game_started")
- guest.await("game_started")
+ host.await("room_state")
+ guest.await("room_state")
- resignAndSettle(t, host, guest)
+ // The owner's tab reloads: same token, new socket.
+ _ = host.conn.Close(websocket.StatusAbnormalClosure, "")
+ guest.await("room_state")
- // A duplicated tab carries the same token and tries to reclaim the seat.
second := dial(t, url)
second.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
ProtocolVersion: ProtocolVersion,
@@ -1151,18 +1410,20 @@ func TestRefusedResumeLeavesTheLiveGameAlone(t *testing.T) {
}}})
second.await("welcome")
- if got := second.await("error").GetError().GetCode(); got != "game_already_over" {
- t.Errorf("resume into a finished game returned %q", got)
+ back := second.await("room_state").GetRoomState()
+ if !back.GetIAmOwner() {
+ t.Errorf("the owner came back as a guest: %+v", back)
+ }
+ if !back.GetOpponentPresent() || back.GetRoomCode() != code {
+ t.Errorf("the resumed lobby is not the one they left: %+v", back)
}
- // The original connection is untouched: the offer it is holding still
- // works, which it would not if the room had closed underneath it.
- host.requestRematch()
- if got := host.await("rematch_state"); !got.GetRematchState().GetIAccepted() {
- t.Error("the player who never left should still be able to accept the rematch")
- }
- guest.requestRematch()
- host.await("game_started")
+ // And the room still works from both sides.
+ guest.setReady(true)
+ second.await("room_state")
+ second.startGame()
+ second.await("game_started")
+ guest.await("game_started")
}
// TestOneConnectionCannotStrandRooms is the regression for rooms that outlived
@@ -1178,7 +1439,7 @@ func TestOneConnectionCannotStrandRooms(t *testing.T) {
const rooms = 4
for range rooms {
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
- c.await("room_created")
+ c.await("room_state")
}
_ = c.conn.Close(websocket.StatusNormalClosure, "")
@@ -1199,24 +1460,23 @@ func TestOneConnectionCannotStrandRooms(t *testing.T) {
}
}
-// TestRematchRequestsAreRateLimited: every accepted request is broadcast to
-// both seats, so an unbounded one lets a player fill the opponent's outbox
-// until the server closes their session for falling behind.
-func TestRematchRequestsAreRateLimited(t *testing.T) {
+// TestLobbyActionsAreRateLimited: every accepted action is broadcast to both
+// seats, so an unbounded one lets a player fill the opponent's outbox until
+// the server closes their session for falling behind.
+func TestLobbyActionsAreRateLimited(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
- host, guest, _ := pvpRoom(t, url)
- resignAndSettle(t, host, guest)
+ _, guest, _ := pvpLobby(t, url)
- for range submitBurst + 5 {
- host.requestRematch()
+ for i := range submitBurst + 5 {
+ guest.setReady(i%2 == 0)
}
// The limiter answers before the room does, so a refusal has to appear in
- // the stream rather than an unbroken run of rematch states.
+ // the stream rather than an unbroken run of room states.
for range 30 {
- if payloadCase(host.recv()) == "error" {
+ if payloadCase(guest.recv()) == "error" {
return
}
}
- t.Error("a burst of rematch requests was never refused")
+ t.Error("a burst of lobby actions was never refused")
}
diff --git a/web/e2e/helpers.js b/web/e2e/helpers.js
index 87db4f2..912e312 100644
--- a/web/e2e/helpers.js
+++ b/web/e2e/helpers.js
@@ -65,6 +65,21 @@ export async function playLegalMove(page, used) {
return /** @type {string} */ (word);
}
+/**
+ * Takes a seated pair from their lobby into a game: the guest readies, the
+ * owner starts. Nothing begins on its own now, so every online test that is
+ * about a game goes through here.
+ *
+ * @param {import('@playwright/test').Page} owner
+ * @param {import('@playwright/test').Page} guest
+ */
+export async function readyAndStart(owner, guest) {
+ await guest.getByTestId('ready').click();
+ const start = owner.getByTestId('start-game');
+ await expect(start).toBeEnabled();
+ await start.click();
+}
+
/**
* Picks a difficulty by clicking its card.
*
diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js
index 9a42569..8e52abb 100644
--- a/web/e2e/pvp-game.spec.js
+++ b/web/e2e/pvp-game.spec.js
@@ -1,5 +1,12 @@
import { expect, test } from '@playwright/test';
-import { board, chainWords, playLegalMove, setNickname, waitForMyTurn } from './helpers.js';
+import {
+ board,
+ chainWords,
+ playLegalMove,
+ readyAndStart,
+ setNickname,
+ waitForMyTurn
+} from './helpers.js';
/**
* Two browser contexts, so each player has their own storage, nickname and
@@ -36,15 +43,26 @@ async function joinRoom(page, nickname, code) {
await page.getByRole('button', { name: 'Vào phòng' }).click();
}
+/** Seats a pair and plays them into a game, which is where most tests start. */
+async function playingPair(browser) {
+ const pair = await twoPlayers(browser);
+ const code = await createRoom(pair.host, 'Minh');
+ await joinRoom(pair.guest, 'Lan', code);
+ await readyAndStart(pair.host, pair.guest);
+ await waitForMyTurn(pair.host);
+ return { ...pair, code };
+}
+
test.describe('playing a stranger', () => {
test('two players join by code and alternate turns', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
expect(code).toMatch(/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/);
- await expect(host.getByTestId('waiting-status')).toBeVisible();
+ await expect(host.getByText('Còn trống')).toBeVisible();
await joinRoom(guest, 'Lan', code);
+ await readyAndStart(host, guest);
// Both boards come up, and exactly one player is on turn.
await waitForMyTurn(host);
@@ -76,6 +94,10 @@ test.describe('playing a stranger', () => {
// No code typed, no button pressed beyond opening the link.
await guest.goto(`/online?code=${code}`);
+ // The link seats them; the lobby is still where the game is agreed.
+ await expect(guest.getByTestId('ready')).toBeVisible();
+ await readyAndStart(host, guest);
+
await waitForMyTurn(host);
await expect(board(guest).syllable).toBeVisible();
@@ -83,11 +105,7 @@ test.describe('playing a stranger', () => {
});
test('resigning ends the game on both sides with the right winner', async ({ browser }) => {
- const { host, guest, close } = await twoPlayers(browser);
-
- const code = await createRoom(host, 'Minh');
- await joinRoom(guest, 'Lan', code);
- await waitForMyTurn(host);
+ const { host, guest, close } = await playingPair(browser);
host.on('dialog', (dialog) => dialog.accept());
await host.getByRole('button', { name: 'Đầu hàng' }).click();
@@ -98,23 +116,17 @@ test.describe('playing a stranger', () => {
await close();
});
- test('a rematch restarts in the same room once both agree', async ({ browser }) => {
- const { host, guest, close } = await twoPlayers(browser);
-
- const code = await createRoom(host, 'Minh');
- await joinRoom(guest, 'Lan', code);
- await waitForMyTurn(host);
+ test('a second game is agreed in the lobby the first one ends in', async ({ browser }) => {
+ const { host, guest, code, close } = await playingPair(browser);
host.on('dialog', (dialog) => dialog.accept());
await host.getByRole('button', { name: 'Đầu hàng' }).click();
- await expect(guest.getByTestId('rematch-prompt')).toBeVisible();
+ await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
- // One acceptance is not enough: the other player is still being asked.
- await host.getByTestId('rematch-accept').click();
- await expect(guest.getByTestId('rematch-prompt')).toContainText('Đối thủ muốn chơi lại');
- await expect(host.getByTestId('rematch-prompt')).toContainText('Đang chờ đối thủ');
-
- await guest.getByTestId('rematch-accept').click();
+ // The readiness that started the first game is spent, so the owner
+ // cannot simply start another.
+ await expect(host.getByTestId('start-game')).toBeDisabled();
+ await readyAndStart(host, guest);
// A new game in the same room: the code is unchanged and the board is
// back to a single opening word.
@@ -125,6 +137,90 @@ test.describe('playing a stranger', () => {
await close();
});
+ test('the guest readies, and cannot leave without taking it back', async ({ browser }) => {
+ const { host, guest, close } = await twoPlayers(browser);
+
+ const code = await createRoom(host, 'Minh');
+ await joinRoom(guest, 'Lan', code);
+
+ // Nothing starts until the owner says so.
+ await expect(host.getByTestId('start-game')).toBeDisabled();
+ await guest.getByTestId('ready').click();
+
+ await expect(guest.getByTestId('my-ready')).toHaveText('Đã sẵn sàng');
+ await expect(host.getByTestId('opponent-ready')).toHaveText('Đã sẵn sàng');
+ await expect(host.getByTestId('start-game')).toBeEnabled();
+
+ // Ready is a commitment: the way out is to take it back first.
+ await expect(guest.getByRole('button', { name: 'Rời phòng' })).toBeDisabled();
+ await guest.getByTestId('ready').click();
+ await expect(guest.getByRole('button', { name: 'Rời phòng' })).toBeEnabled();
+
+ await guest.getByRole('button', { name: 'Rời phòng' }).click();
+
+ // The room survives with its owner in it, one seat free.
+ await expect(host.getByText('Còn trống')).toBeVisible();
+ await expect(host.getByTestId('start-game')).toBeDisabled();
+ // And the player who left is back on the join screen.
+ await expect(guest.getByRole('button', { name: 'Tạo phòng' })).toBeVisible();
+
+ await close();
+ });
+
+ test('the owner can put an unready guest out of the room', async ({ browser }) => {
+ const { host, guest, close } = await twoPlayers(browser);
+
+ const code = await createRoom(host, 'Minh');
+ await joinRoom(guest, 'Lan', code);
+
+ const kick = host.getByRole('button', { name: 'Mời ra khỏi phòng' });
+ await expect(kick).toBeEnabled();
+
+ // A guest who is ready is waiting on the owner, not in the way.
+ await guest.getByTestId('ready').click();
+ await expect(kick).toBeDisabled();
+
+ await guest.getByTestId('ready').click();
+ await expect(kick).toBeEnabled();
+ host.on('dialog', (dialog) => dialog.accept());
+ await kick.click();
+
+ await expect(guest.getByTestId('join-error')).toHaveText('Bạn đã bị mời ra khỏi phòng.');
+ await expect(host.getByText('Còn trống')).toBeVisible();
+
+ await close();
+ });
+
+ test('the room outlives its owner, who hands it to whoever is left', async ({ browser }) => {
+ const { host, guest, close } = await twoPlayers(browser);
+
+ const code = await createRoom(host, 'Minh');
+ await joinRoom(guest, 'Lan', code);
+ await expect(guest.locator('.role', { hasText: 'Chủ phòng' })).toBeVisible();
+
+ await host.getByRole('button', { name: 'Rời phòng' }).click();
+
+ // The guest is the owner now, which is visible in what they are
+ // offered rather than only in a label.
+ await expect(guest.getByTestId('start-game')).toBeVisible();
+ await expect(guest.getByText('Còn trống')).toBeVisible();
+
+ // And the promotion is real: the next person in can be played against.
+ const thirdContext = await browser.newContext();
+ const third = await thirdContext.newPage();
+ await joinRoom(third, 'Nam', code);
+ await readyAndStart(guest, third);
+
+ // Both boards come up. Which of them moves first is the engine's
+ // business, and asserting it here would test seat order instead of the
+ // promotion.
+ await expect(board(guest).syllable).toBeVisible();
+ await expect(board(third).syllable).toBeVisible();
+
+ await thirdContext.close();
+ await close();
+ });
+
test('an unknown room code is refused in Vietnamese', async ({ page }) => {
await page.goto('/online');
await setNickname(page, 'Minh');
@@ -146,11 +242,8 @@ test.describe('playing a stranger', () => {
});
test('a full room turns a third player away', async ({ browser }) => {
- const { host, guest, close } = await twoPlayers(browser);
-
- const code = await createRoom(host, 'Minh');
- await joinRoom(guest, 'Lan', code);
- await waitForMyTurn(host);
+ const { host, code, close } = await playingPair(browser);
+ void host;
const thirdContext = await browser.newContext();
const third = await thirdContext.newPage();
diff --git a/web/e2e/reconnect.spec.js b/web/e2e/reconnect.spec.js
index 49145a5..0f40802 100644
--- a/web/e2e/reconnect.spec.js
+++ b/web/e2e/reconnect.spec.js
@@ -1,5 +1,12 @@
import { expect, test } from '@playwright/test';
-import { board, chainWords, playLegalMove, setNickname, waitForMyTurn } from './helpers.js';
+import {
+ board,
+ chainWords,
+ playLegalMove,
+ readyAndStart,
+ setNickname,
+ waitForMyTurn
+} from './helpers.js';
import { cuttableSocket } from './socket-cut.js';
/**
@@ -29,6 +36,7 @@ async function pvpRoom(browser) {
await setNickname(guest, 'Lan');
await guest.getByLabel('Mã phòng').fill(code);
await guest.getByRole('button', { name: 'Vào phòng' }).click();
+ await readyAndStart(host, guest);
await waitForMyTurn(host);
return {
diff --git a/web/src/lib/components/ConnectionBadge.svelte b/web/src/lib/components/ConnectionBadge.svelte
index b6b7435..039f3c0 100644
--- a/web/src/lib/components/ConnectionBadge.svelte
+++ b/web/src/lib/components/ConnectionBadge.svelte
@@ -7,8 +7,8 @@
[Status.CONNECTING]: t.connecting,
[Status.OPEN]: t.connected,
[Status.RECONNECTING]: t.reconnecting,
- [Status.CLOSED]: t.offline
- }[connection.status] ?? t.offline
+ [Status.CLOSED]: t.noConnection
+ }[connection.status] ?? t.noConnection
);
diff --git a/web/src/lib/components/GameOverPanel.svelte b/web/src/lib/components/GameOverPanel.svelte
index a932f06..ec8aed9 100644
--- a/web/src/lib/components/GameOverPanel.svelte
+++ b/web/src/lib/components/GameOverPanel.svelte
@@ -5,8 +5,8 @@
/**
* onrematch is optional because the two modes differ: a bot always plays
- * again, so the button starts the next game, while online play has to ask
- * the other person first and offers it from the rematch prompt instead.
+ * again, so the button starts the next game, while online play agrees the
+ * next one in the lobby this panel appears above.
*
* @type {{ isRecord: boolean, onrematch?: () => void, onhome: () => void }}
*/
diff --git a/web/src/lib/components/Lobby.svelte b/web/src/lib/components/Lobby.svelte
new file mode 100644
index 0000000..d478037
--- /dev/null
+++ b/web/src/lib/components/Lobby.svelte
@@ -0,0 +1,232 @@
+
+
+
+ {#if s.isOwner}
+ {s.opponentPresent ? t.ownerStartsHint : t.waitingForOpponent}
+ {:else if s.isReady}
+ {t.waitingForStart}
+ {:else}
+ {t.guestReadyHint}
+ {/if}
+ {t.unreadyToLeave}
+
+
+
{t.rematchExpired}
- {:else if offer.iAccepted} -{fill(t.rematchWaiting, { n: seconds })}
- {:else} - {#if offer.opponentAccepted} -{fill(t.rematchOpponentReady, { n: seconds })}
- {:else} -{t.rematchAsk}
- {/if} - - {/if} -- - {t.waitingForOpponent} -
- - -{t.onlineIntro}
diff --git a/web/tests/error-codes.test.js b/web/tests/error-codes.test.js index 96c1a1a..f280c92 100644 --- a/web/tests/error-codes.test.js +++ b/web/tests/error-codes.test.js @@ -15,14 +15,18 @@ import { errorMessages } from '../src/lib/i18n/vi.js'; const wsapiDir = fileURLToPath(new URL('../../server/internal/wsapi', import.meta.url)); -/** Every `errorMsg("…")` literal in the transport, excluding its own tests. */ +/** + * Every error-code literal in the transport, excluding its own tests. Both the + * one-recipient and the broadcast call sites count: a code that only ever goes + * to both players is no less a code the client has to know. + */ function serverErrorCodes() { const codes = new Set(); const files = readdirSync(wsapiDir).filter((f) => f.endsWith('.go') && !f.endsWith('_test.go')); for (const file of files) { const source = readFileSync(join(wsapiDir, file), 'utf8'); - for (const [, code] of source.matchAll(/errorMsg\("([a-z_]+)"\)/g)) { + for (const [, code] of source.matchAll(/(?:errorMsg|broadcastError)\("([a-z_]+)"\)/g)) { codes.add(code); } } diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js index 27a2f3a..f16510b 100644 --- a/web/tests/game-store.test.js +++ b/web/tests/game-store.test.js @@ -222,25 +222,40 @@ describe('opponentLeft', () => { }); }); +/** One lobby snapshot, with the fields a test does not care about defaulted. */ +function lobby(fields = {}) { + return msg('roomState', { + roomCode: 'ABCD', + iAmOwner: false, + canStart: false, + iAmReady: false, + opponentPresent: false, + opponentName: '', + opponentReady: false, + opponentConnected: false, + ...fields + }); +} + describe('room messages', () => { it('keeps the room code and the sanitized opponent name', () => { const store = createGameStore(); - store.apply(msg('roomCreated', { roomCode: 'ABCD' })); - expect(store.state.roomCode).toBe('ABCD'); + store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true })); - store.apply(msg('roomJoined', { roomCode: 'ABCD', opponentName: 'Lan' })); + expect(store.state.roomCode).toBe('ABCD'); expect(store.state.opponentName).toBe('Lan'); }); it('survives a reset, because identity outlives one game', () => { const store = createGameStore(); store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's', resumeToken: 't' })); - store.apply(msg('roomJoined', { roomCode: 'ABCD', opponentName: 'Lan' })); + store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true })); store.reset(); expect(store.state.nickname).toBe('Minh'); expect(store.state.roomCode).toBe('ABCD'); expect(store.state.opponentName).toBe('Lan'); + expect(store.state.opponentPresent).toBe(true); expect(store.state.chain).toEqual([]); }); }); @@ -255,70 +270,88 @@ describe('pong', () => { }); }); -describe('online rooms', () => { - it('waits once the room exists but has one seat filled', () => { +describe('the lobby', () => { + it('opens the lobby when the room appears', () => { const store = createGameStore(); - store.apply(msg('roomCreated', { roomCode: 'K7M2QP' })); + store.apply(lobby({ roomCode: 'K7M2QP', iAmOwner: true })); - expect(store.state.phase).toBe('waiting'); + expect(store.state.phase).toBe('lobby'); expect(store.state.roomCode).toBe('K7M2QP'); + expect(store.state.isOwner).toBe(true); }); - it('does not drop a live game back into waiting', () => { - // A resumed session is told its room again. Treating that as "waiting" - // would replace the board with the invite screen mid-game. + it('takes every field from the server rather than deriving any', () => { const store = createGameStore(); - store.apply(started()); - store.apply(msg('roomCreated', { roomCode: 'K7M2QP' })); + store.apply( + lobby({ + iAmOwner: false, + iAmReady: true, + canStart: true, + opponentPresent: true, + opponentName: 'Lan', + opponentReady: false, + opponentConnected: true + }) + ); - expect(store.state.phase).toBe('playing'); - }); -}); - -describe('rematch offers', () => { - function finished(store) { - store.apply(started()); - store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED })); - } - - it('records both sides of the answer as the server rendered them', () => { - const store = createGameStore(); - finished(store); - store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 })); - - expect(store.state.rematch).toEqual({ - iAccepted: true, - opponentAccepted: false, - expiresInMs: 27500 + expect(store.state).toMatchObject({ + isOwner: false, + isReady: true, + canStart: true, + opponentPresent: true, + opponentName: 'Lan', + opponentReady: false, + opponentConnected: true }); }); - it('ends the offer when the opponent cannot come back', () => { - // The countdown would otherwise keep running with nobody to answer it. + it('does not drop a live game back into the lobby', () => { + // A room state arriving mid-game is a presence change, not a phase. + // Acting on it would replace the board with the lobby mid-turn. const store = createGameStore(); - finished(store); - store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 })); - store.apply(msg('opponentLeft', { canReconnect: false, graceMs: 0 })); + store.apply(started()); + store.apply(lobby({ opponentPresent: true, opponentConnected: true })); - expect(store.state.rematch).toBeNull(); - }); - - it('keeps the offer while the opponent is only disconnected', () => { - const store = createGameStore(); - finished(store); - store.apply(msg('rematchState', { iAccepted: false, opponentAccepted: true, expiresInMs: 20000 })); - store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30000 })); - - expect(store.state.rematch).not.toBeNull(); - }); - - it('clears the offer once the next game starts', () => { - const store = createGameStore(); - finished(store); - store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: true, expiresInMs: 15000 })); - store.apply(started({ openingWord: 'thí sinh' })); - - expect(store.state.rematch).toBeNull(); expect(store.state.phase).toBe('playing'); }); + + it('keeps the result on screen when a finished game returns to the lobby', () => { + const store = createGameStore(); + store.apply(started()); + store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED })); + store.apply(lobby({ opponentPresent: true, opponentConnected: true })); + + expect(store.state.phase).toBe('over'); + expect(store.state.result).not.toBeNull(); + }); + + it('stops waiting for an opponent who is connected again', () => { + const store = createGameStore(); + store.apply(started()); + store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30_000 })); + store.apply(lobby({ opponentPresent: true, opponentConnected: true })); + + expect(store.state.opponentLeft).toBeNull(); + expect(store.state.opponentConnected).toBe(true); + }); + + it('forgets the room when this player is kicked out of it', () => { + const store = createGameStore(); + store.apply(lobby({ opponentPresent: true, opponentConnected: true })); + store.apply(msg('error', { code: 'kicked', message: '' })); + + expect(store.state.phase).toBe('idle'); + expect(store.state.roomCode).toBe(''); + expect(store.state.error).toBe('Bạn đã bị mời ra khỏi phòng.'); + }); + + it('forgets a room that closed for sitting idle', () => { + const store = createGameStore(); + store.apply(lobby({ iAmOwner: true })); + store.apply(msg('error', { code: 'room_idle_closed', message: '' })); + + expect(store.state.phase).toBe('idle'); + expect(store.state.roomCode).toBe(''); + expect(store.state.error).not.toBeNull(); + }); }); diff --git a/web/tests/ws-client.test.js b/web/tests/ws-client.test.js index c07107e..40b7fe9 100644 --- a/web/tests/ws-client.test.js +++ b/web/tests/ws-client.test.js @@ -323,10 +323,10 @@ describe('frames', () => { const h = setup(); h.client.connect(); h.last().open(); - h.last().deliver(serverMsg('roomCreated', { roomCode: 'ABCD' })); + h.last().deliver(serverMsg('roomState', { roomCode: 'ABCD' })); expect(h.received).toHaveLength(1); - expect(h.received[0].payload.case).toBe('roomCreated'); + expect(h.received[0].payload.case).toBe('roomState'); }); it('drops an undecodable frame instead of ending the session', () => {