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(2 Khá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 !compact} + + {/if} + + + +

+ {#if s.isOwner} + {s.opponentPresent ? t.ownerStartsHint : t.waitingForOpponent} + {:else if s.isReady} + {t.waitingForStart} + {:else} + {t.guestReadyHint} + {/if} +

+ +
+ {#if s.isOwner} + + {#if s.opponentPresent} + + {/if} + {:else} + + {/if} +
+ + + + {#if s.isReady} +

{t.unreadyToLeave}

+ {/if} +
+ + diff --git a/web/src/lib/components/RematchPrompt.svelte b/web/src/lib/components/RematchPrompt.svelte deleted file mode 100644 index 3fd2770..0000000 --- a/web/src/lib/components/RematchPrompt.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - -{#if offer} -
- {#if expired} -

{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} -
-{/if} - - diff --git a/web/src/lib/components/WaitingRoom.svelte b/web/src/lib/components/WaitingRoom.svelte deleted file mode 100644 index 8c52c35..0000000 --- a/web/src/lib/components/WaitingRoom.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - -
- - -

- - {t.waitingForOpponent} -

- - -
- - diff --git a/web/src/lib/i18n/vi.js b/web/src/lib/i18n/vi.js index d73d26d..80c8844 100644 --- a/web/src/lib/i18n/vi.js +++ b/web/src/lib/i18n/vi.js @@ -41,7 +41,7 @@ export const t = { connecting: 'Đang kết nối…', connected: 'Đã kết nối', reconnecting: 'Mất kết nối, đang thử lại…', - offline: 'Không có kết nối', + noConnection: 'Không có kết nối', won: 'Bạn thắng!', lost: 'Bạn thua.', @@ -76,16 +76,27 @@ export const t = { leaveRoom: 'Rời phòng', shareInvite: 'Vào chơi nối từ với tôi', + lobbyTitle: 'Phòng chờ', + owner: 'Chủ phòng', + guest: 'Khách', + ready: 'Sẵn sàng', + unready: 'Chưa sẵn sàng', + isReady: 'Đã sẵn sàng', + notReady: 'Chưa sẵn sàng', + startGame: 'Bắt đầu', + kickPlayer: 'Mời ra khỏi phòng', + kickConfirm: 'Mời người chơi này ra khỏi phòng?', + emptySeat: 'Còn trống', + offline: 'Mất kết nối', + ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi đối thủ đã sẵn sàng.', + guestReadyHint: 'Bấm sẵn sàng rồi chờ chủ phòng bắt đầu.', + waitingForStart: 'Đang chờ chủ phòng bắt đầu…', + unreadyToLeave: 'Bỏ sẵn sàng trước khi rời phòng.', + opponentDisconnected: 'Đối thủ mất kết nối…', opponentDisconnectedIn: 'Đối thủ mất kết nối… ({n}s)', opponentGone: 'Đối thủ đã rời trận.', - rematchAsk: 'Chơi lại ván nữa?', - rematchYes: 'Chơi lại', - rematchWaiting: 'Đang chờ đối thủ đồng ý… ({n}s)', - rematchOpponentReady: 'Đối thủ muốn chơi lại. ({n}s)', - rematchExpired: 'Không còn ván nào nữa.', - attributionIntro: 'Từ điển dựa trên', attributionSource: 'minhqnd/dictionary', attributionLicense: 'giấy phép CC BY-SA 4.0', @@ -149,14 +160,25 @@ export const errorMessages = { busy: 'Bạn đang ở trong một ván đấu khác.', cannot_join_own_room: 'Bạn không thể vào phòng của chính mình.', game_already_over: 'Ván đấu đã kết thúc.', + game_in_progress: 'Ván đấu đang diễn ra.', game_not_started: 'Ván đấu chưa bắt đầu.', game_start_failed: 'Không thể bắt đầu ván đấu. Hãy thử lại.', handshake_required: 'Phiên chơi chưa sẵn sàng. Hãy tải lại trang.', - no_rematch_offered: 'Chưa thể chơi lại lúc này.', + kicked: 'Bạn đã bị mời ra khỏi phòng.', + must_unready_first: 'Hãy bỏ sẵn sàng trước khi rời phòng.', + need_two_players: 'Cần hai người chơi mới bắt đầu được.', + not_everyone_ready: 'Đối thủ chưa sẵn sàng.', not_in_a_game: 'Bạn không ở trong ván đấu nào.', + not_in_a_room: 'Bạn không ở trong phòng nào.', + not_the_owner: 'Chỉ chủ phòng làm được việc này.', not_your_seat: 'Bạn không phải người chơi trong ván này.', + no_one_to_kick: 'Chưa có ai trong phòng để mời ra.', + opponent_offline: 'Đối thủ đang mất kết nối.', + owner_needs_no_ready: 'Chủ phòng không cần bấm sẵn sàng.', + player_is_ready: 'Không thể mời một người đã sẵn sàng ra khỏi phòng.', protocol_version_mismatch: 'Phiên bản đã cũ. Hãy tải lại trang.', room_full: 'Phòng đã đủ người.', + room_idle_closed: 'Phòng đã đóng vì không có ván nào được bắt đầu.', room_not_found: 'Không tìm thấy phòng với mã này.', room_start_failed: 'Không thể tạo phòng. Hãy thử lại.', server_restarting: 'Máy chủ đang khởi động lại. Hãy thử lại sau giây lát.', diff --git a/web/src/lib/proto/noitu/v1/game_pb.d.ts b/web/src/lib/proto/noitu/v1/game_pb.d.ts index a577df7..5b35d47 100644 --- a/web/src/lib/proto/noitu/v1/game_pb.d.ts +++ b/web/src/lib/proto/noitu/v1/game_pb.d.ts @@ -136,22 +136,73 @@ export declare type Resign = Message<"noitu.v1.Resign"> & { export declare const ResignSchema: GenMessage; /** - * 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. + * 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. * - * @generated from message noitu.v1.RequestRematch + * @generated from message noitu.v1.SetReady */ -export declare type RequestRematch = Message<"noitu.v1.RequestRematch"> & { +export declare type SetReady = Message<"noitu.v1.SetReady"> & { + /** + * @generated from field: bool ready = 1; + */ + ready: boolean; }; /** - * Describes the message noitu.v1.RequestRematch. - * Use `create(RequestRematchSchema)` to create a new message. + * Describes the message noitu.v1.SetReady. + * Use `create(SetReadySchema)` to create a new message. */ -export declare const RequestRematchSchema: GenMessage; +export declare const SetReadySchema: GenMessage; + +/** + * StartGame is the owner beginning the game the lobby has agreed on. It is + * refused unless the guest is seated, connected and ready. + * + * @generated from message noitu.v1.StartGame + */ +export declare type StartGame = Message<"noitu.v1.StartGame"> & { +}; + +/** + * Describes the message noitu.v1.StartGame. + * Use `create(StartGameSchema)` to create a new message. + */ +export declare const StartGameSchema: GenMessage; + +/** + * 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. + * + * @generated from message noitu.v1.KickPlayer + */ +export declare type KickPlayer = Message<"noitu.v1.KickPlayer"> & { +}; + +/** + * Describes the message noitu.v1.KickPlayer. + * Use `create(KickPlayerSchema)` to create a new message. + */ +export declare const KickPlayerSchema: GenMessage; + +/** + * 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. + * + * @generated from message noitu.v1.LeaveRoom + */ +export declare type LeaveRoom = Message<"noitu.v1.LeaveRoom"> & { +}; + +/** + * Describes the message noitu.v1.LeaveRoom. + * Use `create(LeaveRoomSchema)` to create a new message. + */ +export declare const LeaveRoomSchema: GenMessage; /** * Ping echoes the client clock so Pong can expose the offset between the two. @@ -222,10 +273,28 @@ export declare type ClientMessage = Message<"noitu.v1.ClientMessage"> & { case: "ping"; } | { /** - * @generated from field: noitu.v1.RequestRematch request_rematch = 8; + * @generated from field: noitu.v1.SetReady set_ready = 9; */ - value: RequestRematch; - case: "requestRematch"; + value: SetReady; + case: "setReady"; + } | { + /** + * @generated from field: noitu.v1.StartGame start_game = 10; + */ + value: StartGame; + case: "startGame"; + } | { + /** + * @generated from field: noitu.v1.KickPlayer kick_player = 11; + */ + value: KickPlayer; + case: "kickPlayer"; + } | { + /** + * @generated from field: noitu.v1.LeaveRoom leave_room = 12; + */ + value: LeaveRoom; + case: "leaveRoom"; } | { case: undefined; value?: undefined }; }; @@ -269,46 +338,6 @@ export declare type Welcome = Message<"noitu.v1.Welcome"> & { */ export declare const WelcomeSchema: GenMessage; -/** - * @generated from message noitu.v1.RoomCreated - */ -export declare type RoomCreated = Message<"noitu.v1.RoomCreated"> & { - /** - * @generated from field: string room_code = 1; - */ - roomCode: string; -}; - -/** - * Describes the message noitu.v1.RoomCreated. - * Use `create(RoomCreatedSchema)` to create a new message. - */ -export declare const RoomCreatedSchema: GenMessage; - -/** - * @generated from message noitu.v1.RoomJoined - */ -export declare type RoomJoined = Message<"noitu.v1.RoomJoined"> & { - /** - * @generated from field: string room_code = 1; - */ - roomCode: string; - - /** - * Always server-sanitized. A client must never render another player's raw - * input. - * - * @generated from field: string opponent_name = 2; - */ - opponentName: string; -}; - -/** - * Describes the message noitu.v1.RoomJoined. - * Use `create(RoomJoinedSchema)` to create a new message. - */ -export declare const RoomJoinedSchema: GenMessage; - /** * 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 @@ -587,38 +616,79 @@ export declare type Pong = Message<"noitu.v1.Pong"> & { export declare const PongSchema: GenMessage; /** - * 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. + * 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. * - * @generated from message noitu.v1.RematchState + * 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. + * + * @generated from message noitu.v1.RoomState */ -export declare type RematchState = Message<"noitu.v1.RematchState"> & { +export declare type RoomState = Message<"noitu.v1.RoomState"> & { /** - * @generated from field: bool i_accepted = 1; + * @generated from field: string room_code = 1; */ - iAccepted: boolean; + roomCode: string; /** - * @generated from field: bool opponent_accepted = 2; - */ - opponentAccepted: boolean; - - /** - * 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. + * True for the player who may start the game and kick the other. * - * @generated from field: uint32 expires_in_ms = 3; + * @generated from field: bool i_am_owner = 2; */ - expiresInMs: number; + iAmOwner: boolean; + + /** + * Whether StartGame would be accepted right now. The server decides this + * because it owns every condition that feeds it. + * + * @generated from field: bool can_start = 3; + */ + canStart: boolean; + + /** + * The recipient's own readiness. Always false for the owner, whose readiness + * is StartGame itself. + * + * @generated from field: bool i_am_ready = 4; + */ + iAmReady: boolean; + + /** + * False when the other seat is empty; the fields below are then meaningless. + * + * @generated from field: bool opponent_present = 5; + */ + opponentPresent: boolean; + + /** + * Always server-sanitized, as everywhere else another player's name appears. + * + * @generated from field: string opponent_name = 6; + */ + opponentName: string; + + /** + * @generated from field: bool opponent_ready = 7; + */ + opponentReady: boolean; + + /** + * False while the other player is inside their reconnect window. + * + * @generated from field: bool opponent_connected = 8; + */ + opponentConnected: boolean; }; /** - * Describes the message noitu.v1.RematchState. - * Use `create(RematchStateSchema)` to create a new message. + * Describes the message noitu.v1.RoomState. + * Use `create(RoomStateSchema)` to create a new message. */ -export declare const RematchStateSchema: GenMessage; +export declare const RoomStateSchema: GenMessage; /** * @generated from message noitu.v1.ServerMessage @@ -633,18 +703,6 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & { */ value: Welcome; case: "welcome"; - } | { - /** - * @generated from field: noitu.v1.RoomCreated room_created = 2; - */ - value: RoomCreated; - case: "roomCreated"; - } | { - /** - * @generated from field: noitu.v1.RoomJoined room_joined = 3; - */ - value: RoomJoined; - case: "roomJoined"; } | { /** * @generated from field: noitu.v1.GameStarted game_started = 4; @@ -689,10 +747,10 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & { case: "pong"; } | { /** - * @generated from field: noitu.v1.RematchState rematch_state = 11; + * @generated from field: noitu.v1.RoomState room_state = 12; */ - value: RematchState; - case: "rematchState"; + value: RoomState; + case: "roomState"; } | { case: undefined; value?: undefined }; }; diff --git a/web/src/lib/proto/noitu/v1/game_pb.js b/web/src/lib/proto/noitu/v1/game_pb.js index 57243b2..aee719a 100644 --- a/web/src/lib/proto/noitu/v1/game_pb.js +++ b/web/src/lib/proto/noitu/v1/game_pb.js @@ -21,7 +21,7 @@ import { enumDesc, fileDesc, messageDesc, tsEnum } from "@bufbuild/protobuf/code * Describes the file noitu/v1/game.proto. */ export const file_noitu_v1_game = /*@__PURE__*/ - fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIQCg5SZXF1ZXN0UmVtYXRjaCIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIuoCCg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIABIzCg9yZXF1ZXN0X3JlbWF0Y2gYCCABKAsyGC5ub2l0dS52MS5SZXF1ZXN0UmVtYXRjaEgAQgkKB3BheWxvYWQiaAoHV2VsY29tZRISCgpzZXNzaW9uX2lkGAEgASgJEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIYChBwcm90b2NvbF92ZXJzaW9uGAMgASgNEhkKEWFjY2VwdGVkX25pY2tuYW1lGAQgASgJIiAKC1Jvb21DcmVhdGVkEhEKCXJvb21fY29kZRgBIAEoCSI2CgpSb29tSm9pbmVkEhEKCXJvb21fY29kZRgBIAEoCRIVCg1vcHBvbmVudF9uYW1lGAIgASgJIlsKClBsYXllZFdvcmQSDAoEd29yZBgBIAEoCRINCgVieV9tZRgCIAEoCBIOCgZwb2ludHMYAyABKA0SEQoJc3lsbGFibGVzGAQgASgNEg0KBXR5cGVkGAUgASgJIpEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDSLJAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhAKCG15X3Njb3JlGAYgASgNEhYKDm9wcG9uZW50X3Njb3JlGAcgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDSJWCgxNb3ZlUmVqZWN0ZWQSJgoGcmVhc29uGAEgASgOMhYubm9pdHUudjEuUmVqZWN0UmVhc29uEgwKBHdvcmQYAiABKAkSEAoIdHVybl9zZXEYAyABKA0ifwoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIQCghteV9zY29yZRgDIAEoDRIUCgxjaGFpbl9sZW5ndGgYBCABKA0SEwoLc3VnZ2VzdGlvbnMYBSADKAkiNwoMT3Bwb25lbnRMZWZ0EhUKDWNhbl9yZWNvbm5lY3QYASABKAgSEAoIZ3JhY2VfbXMYAiABKA0iLAoLU2VydmVyRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjYKBFBvbmcSFgoOY2xpZW50X3RpbWVfbXMYASABKAMSFgoOc2VydmVyX3RpbWVfbXMYAiABKAMiVAoMUmVtYXRjaFN0YXRlEhIKCmlfYWNjZXB0ZWQYASABKAgSGQoRb3Bwb25lbnRfYWNjZXB0ZWQYAiABKAgSFQoNZXhwaXJlc19pbl9tcxgDIAEoDSL8AwoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDHJvb21fY3JlYXRlZBgCIAEoCzIVLm5vaXR1LnYxLlJvb21DcmVhdGVkSAASKwoLcm9vbV9qb2luZWQYAyABKAsyFC5ub2l0dS52MS5Sb29tSm9pbmVkSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASLwoNcmVtYXRjaF9zdGF0ZRgLIAEoCzIWLm5vaXR1LnYxLlJlbWF0Y2hTdGF0ZUgAQgkKB3BheWxvYWQqaQoKRGlmZmljdWx0eRIaChZESUZGSUNVTFRZX1VOU1BFQ0lGSUVEEAASEwoPRElGRklDVUxUWV9FQVNZEAESFQoRRElGRklDVUxUWV9NRURJVU0QAhITCg9ESUZGSUNVTFRZX0hBUkQQAyqOAgoMUmVqZWN0UmVhc29uEh0KGVJFSkVDVF9SRUFTT05fVU5TUEVDSUZJRUQQABIjCh9SRUpFQ1RfUkVBU09OX1RPT19GRVdfU1lMTEFCTEVTEAESHAoYUkVKRUNUX1JFQVNPTl9XUk9OR19MSU5LEAISIwofUkVKRUNUX1JFQVNPTl9OT1RfSU5fRElDVElPTkFSWRADEh4KGlJFSkVDVF9SRUFTT05fQUxSRUFEWV9VU0VEEAQSHwobUkVKRUNUX1JFQVNPTl9OT1RfWU9VUl9UVVJOEAUSGQoVUkVKRUNUX1JFQVNPTl9USU1FT1VUEAYSGwoXUkVKRUNUX1JFQVNPTl9HQU1FX09WRVIQByqxAQoNR2FtZUVuZFJlYXNvbhIfChtHQU1FX0VORF9SRUFTT05fVU5TUEVDSUZJRUQQABIbChdHQU1FX0VORF9SRUFTT05fVElNRU9VVBABEiEKHUdBTUVfRU5EX1JFQVNPTl9OT19MRUdBTF9NT1ZFEAISIQodR0FNRV9FTkRfUkVBU09OX09QUE9ORU5UX0xFRlQQAxIcChhHQU1FX0VORF9SRUFTT05fUkVTSUdORUQQBEI6WjhnaXRodWIuY29tL3RpZW5ubTk5ZGV2L25vaXR1L3NlcnZlci9nZW4vbm9pdHUvdjE7bm9pdHV2MWIGcHJvdG8z"); + fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiDAoKS2lja1BsYXllciILCglMZWF2ZVJvb20iHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyLnAwoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAQgkKB3BheWxvYWRKBAgIEAkiaAoHV2VsY29tZRISCgpzZXNzaW9uX2lkGAEgASgJEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIYChBwcm90b2NvbF92ZXJzaW9uGAMgASgNEhkKEWFjY2VwdGVkX25pY2tuYW1lGAQgASgJIlsKClBsYXllZFdvcmQSDAoEd29yZBgBIAEoCRINCgVieV9tZRgCIAEoCBIOCgZwb2ludHMYAyABKA0SEQoJc3lsbGFibGVzGAQgASgNEg0KBXR5cGVkGAUgASgJIpEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDSLJAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhAKCG15X3Njb3JlGAYgASgNEhYKDm9wcG9uZW50X3Njb3JlGAcgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDSJWCgxNb3ZlUmVqZWN0ZWQSJgoGcmVhc29uGAEgASgOMhYubm9pdHUudjEuUmVqZWN0UmVhc29uEgwKBHdvcmQYAiABKAkSEAoIdHVybl9zZXEYAyABKA0ifwoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIQCghteV9zY29yZRgDIAEoDRIUCgxjaGFpbl9sZW5ndGgYBCABKA0SEwoLc3VnZ2VzdGlvbnMYBSADKAkiNwoMT3Bwb25lbnRMZWZ0EhUKDWNhbl9yZWNvbm5lY3QYASABKAgSEAoIZ3JhY2VfbXMYAiABKA0iLAoLU2VydmVyRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjYKBFBvbmcSFgoOY2xpZW50X3RpbWVfbXMYASABKAMSFgoOc2VydmVyX3RpbWVfbXMYAiABKAMivgEKCVJvb21TdGF0ZRIRCglyb29tX2NvZGUYASABKAkSEgoKaV9hbV9vd25lchgCIAEoCBIRCgljYW5fc3RhcnQYAyABKAgSEgoKaV9hbV9yZWFkeRgEIAEoCBIYChBvcHBvbmVudF9wcmVzZW50GAUgASgIEhUKDW9wcG9uZW50X25hbWUYBiABKAkSFgoOb3Bwb25lbnRfcmVhZHkYByABKAgSGgoSb3Bwb25lbnRfY29ubmVjdGVkGAggASgIIqwDCg1TZXJ2ZXJNZXNzYWdlEiQKB3dlbGNvbWUYASABKAsyES5ub2l0dS52MS5XZWxjb21lSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAQgkKB3BheWxvYWRKBAgCEANKBAgDEARKBAgLEAwqaQoKRGlmZmljdWx0eRIaChZESUZGSUNVTFRZX1VOU1BFQ0lGSUVEEAASEwoPRElGRklDVUxUWV9FQVNZEAESFQoRRElGRklDVUxUWV9NRURJVU0QAhITCg9ESUZGSUNVTFRZX0hBUkQQAyqOAgoMUmVqZWN0UmVhc29uEh0KGVJFSkVDVF9SRUFTT05fVU5TUEVDSUZJRUQQABIjCh9SRUpFQ1RfUkVBU09OX1RPT19GRVdfU1lMTEFCTEVTEAESHAoYUkVKRUNUX1JFQVNPTl9XUk9OR19MSU5LEAISIwofUkVKRUNUX1JFQVNPTl9OT1RfSU5fRElDVElPTkFSWRADEh4KGlJFSkVDVF9SRUFTT05fQUxSRUFEWV9VU0VEEAQSHwobUkVKRUNUX1JFQVNPTl9OT1RfWU9VUl9UVVJOEAUSGQoVUkVKRUNUX1JFQVNPTl9USU1FT1VUEAYSGwoXUkVKRUNUX1JFQVNPTl9HQU1FX09WRVIQByqxAQoNR2FtZUVuZFJlYXNvbhIfChtHQU1FX0VORF9SRUFTT05fVU5TUEVDSUZJRUQQABIbChdHQU1FX0VORF9SRUFTT05fVElNRU9VVBABEiEKHUdBTUVfRU5EX1JFQVNPTl9OT19MRUdBTF9NT1ZFEAISIQodR0FNRV9FTkRfUkVBU09OX09QUE9ORU5UX0xFRlQQAxIcChhHQU1FX0VORF9SRUFTT05fUkVTSUdORUQQBEI6WjhnaXRodWIuY29tL3RpZW5ubTk5ZGV2L25vaXR1L3NlcnZlci9nZW4vbm9pdHUvdjE7bm9pdHV2MWIGcHJvdG8z"); /** * Describes the message noitu.v1.Hello. @@ -66,116 +66,123 @@ export const ResignSchema = /*@__PURE__*/ messageDesc(file_noitu_v1_game, 5); /** - * Describes the message noitu.v1.RequestRematch. - * Use `create(RequestRematchSchema)` to create a new message. + * Describes the message noitu.v1.SetReady. + * Use `create(SetReadySchema)` to create a new message. */ -export const RequestRematchSchema = /*@__PURE__*/ +export const SetReadySchema = /*@__PURE__*/ messageDesc(file_noitu_v1_game, 6); +/** + * Describes the message noitu.v1.StartGame. + * Use `create(StartGameSchema)` to create a new message. + */ +export const StartGameSchema = /*@__PURE__*/ + messageDesc(file_noitu_v1_game, 7); + +/** + * Describes the message noitu.v1.KickPlayer. + * Use `create(KickPlayerSchema)` to create a new message. + */ +export const KickPlayerSchema = /*@__PURE__*/ + messageDesc(file_noitu_v1_game, 8); + +/** + * Describes the message noitu.v1.LeaveRoom. + * Use `create(LeaveRoomSchema)` to create a new message. + */ +export const LeaveRoomSchema = /*@__PURE__*/ + messageDesc(file_noitu_v1_game, 9); + /** * Describes the message noitu.v1.Ping. * Use `create(PingSchema)` to create a new message. */ export const PingSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 7); + messageDesc(file_noitu_v1_game, 10); /** * Describes the message noitu.v1.ClientMessage. * Use `create(ClientMessageSchema)` to create a new message. */ export const ClientMessageSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 8); + messageDesc(file_noitu_v1_game, 11); /** * Describes the message noitu.v1.Welcome. * Use `create(WelcomeSchema)` to create a new message. */ export const WelcomeSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 9); - -/** - * Describes the message noitu.v1.RoomCreated. - * Use `create(RoomCreatedSchema)` to create a new message. - */ -export const RoomCreatedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 10); - -/** - * Describes the message noitu.v1.RoomJoined. - * Use `create(RoomJoinedSchema)` to create a new message. - */ -export const RoomJoinedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 11); + messageDesc(file_noitu_v1_game, 12); /** * Describes the message noitu.v1.PlayedWord. * Use `create(PlayedWordSchema)` to create a new message. */ export const PlayedWordSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 12); + messageDesc(file_noitu_v1_game, 13); /** * Describes the message noitu.v1.GameStarted. * Use `create(GameStartedSchema)` to create a new message. */ export const GameStartedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 13); + messageDesc(file_noitu_v1_game, 14); /** * Describes the message noitu.v1.TurnUpdate. * Use `create(TurnUpdateSchema)` to create a new message. */ export const TurnUpdateSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 14); + messageDesc(file_noitu_v1_game, 15); /** * Describes the message noitu.v1.MoveRejected. * Use `create(MoveRejectedSchema)` to create a new message. */ export const MoveRejectedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 15); + messageDesc(file_noitu_v1_game, 16); /** * Describes the message noitu.v1.GameOver. * Use `create(GameOverSchema)` to create a new message. */ export const GameOverSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 16); + messageDesc(file_noitu_v1_game, 17); /** * Describes the message noitu.v1.OpponentLeft. * Use `create(OpponentLeftSchema)` to create a new message. */ export const OpponentLeftSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 17); + messageDesc(file_noitu_v1_game, 18); /** * Describes the message noitu.v1.ServerError. * Use `create(ServerErrorSchema)` to create a new message. */ export const ServerErrorSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 18); + messageDesc(file_noitu_v1_game, 19); /** * Describes the message noitu.v1.Pong. * Use `create(PongSchema)` to create a new message. */ export const PongSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 19); + messageDesc(file_noitu_v1_game, 20); /** - * Describes the message noitu.v1.RematchState. - * Use `create(RematchStateSchema)` to create a new message. + * Describes the message noitu.v1.RoomState. + * Use `create(RoomStateSchema)` to create a new message. */ -export const RematchStateSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 20); +export const RoomStateSchema = /*@__PURE__*/ + messageDesc(file_noitu_v1_game, 21); /** * Describes the message noitu.v1.ServerMessage. * Use `create(ServerMessageSchema)` to create a new message. */ export const ServerMessageSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 21); + messageDesc(file_noitu_v1_game, 22); /** * Describes the enum noitu.v1.Difficulty. diff --git a/web/src/lib/stores/game.svelte.js b/web/src/lib/stores/game.svelte.js index 5426007..b03a919 100644 --- a/web/src/lib/stores/game.svelte.js +++ b/web/src/lib/stores/game.svelte.js @@ -19,10 +19,14 @@ import { rejectMessage, errorMessage } from '$lib/i18n/vi.js'; function initialState() { return { /** - * Where the screen is. `waiting` is online-only: the room exists and - * its code can be shared, but there is nobody to play yet. + * Where the screen is. `lobby` is online-only: the room exists and its + * code can be shared, and it is where every game is agreed before it + * starts and returned to after it ends. * - * @type {'idle' | 'waiting' | 'playing' | 'over'} + * RoomState deliberately does not move this. A game running is what + * the phase is about, and only GameStarted and GameOver know that. + * + * @type {'idle' | 'lobby' | 'playing' | 'over'} */ phase: 'idle', /** @type {ChainEntry[]} */ @@ -40,6 +44,18 @@ function initialState() { opponentName: '', roomCode: '', + /** + * The lobby, exactly as the server last described it. Every field is + * server-owned: the client never decides who owns the room, who is + * ready, or whether a game may start. + */ + isOwner: false, + isReady: false, + canStart: false, + opponentPresent: false, + opponentReady: false, + opponentConnected: false, + /** @type {{ word: string, message: string } | null} */ rejection: null, /** @@ -53,14 +69,6 @@ function initialState() { result: null, /** @type {{ canReconnect: boolean, graceMs: number } | null} */ opponentLeft: null, - /** - * The offer that follows a finished online game, or null when none is - * open. Both sides of the answer come from the server, so neither - * client has to work out which acceptance is whose. - * - * @type {{ iAccepted: boolean, opponentAccepted: boolean, expiresInMs: number } | null} - */ - rematch: null, /** @type {string | null} */ error: null }; @@ -73,11 +81,38 @@ function initialState() { export function createGameStore() { const state = $state(initialState()); - /** Returns the model to its pre-game shape, keeping the identity fields. */ + /** + * Returns the model to its pre-game shape, keeping the identity fields and + * the lobby. A game ending, or a new one starting, does not change which + * room this is or who is in it — the server says so with its own message. + */ + const kept = new Set([ + 'nickname', + 'roomCode', + 'opponentName', + 'isOwner', + 'isReady', + 'canStart', + 'opponentPresent', + 'opponentReady', + 'opponentConnected' + ]); + function reset() { const fresh = initialState(); for (const key of Object.keys(fresh)) { - if (key === 'nickname' || key === 'roomCode' || key === 'opponentName') continue; + if (kept.has(key)) continue; + state[key] = fresh[key]; + } + } + + /** + * Forgets the room entirely: this connection is not in one any more, which + * is what leaving and being kicked have in common. + */ + function leave() { + const fresh = initialState(); + for (const key of Object.keys(fresh)) { state[key] = fresh[key]; } } @@ -99,24 +134,29 @@ export function createGameStore() { state.nickname = value.acceptedNickname; break; - case 'roomCreated': - state.roomCode = value.roomCode; - // The room exists but has one seat filled. A resumed session - // that was waiting lands here too, which is why the phase is - // set rather than assumed. - if (state.phase !== 'playing') state.phase = 'waiting'; - break; - - case 'roomJoined': + case 'roomState': + // One snapshot, applied wholesale. Merging fields selectively + // is how a client ends up believing a mixture of two states + // the server was never in. state.roomCode = value.roomCode; + state.isOwner = value.iAmOwner; + state.isReady = value.iAmReady; + state.canStart = value.canStart; + state.opponentPresent = value.opponentPresent; state.opponentName = value.opponentName; - // The opponent is in the room, which is also how a reconnect is - // announced. Either way there is nobody to be waiting for. - state.opponentLeft = null; + state.opponentReady = value.opponentReady; + state.opponentConnected = value.opponentConnected; + // A room state arriving mid-game is a presence change, and an + // opponent who is connected again is not one to wait for. + if (value.opponentPresent && value.opponentConnected) state.opponentLeft = null; + // The lobby is where a room sits when no game is on. `over` + // keeps its result panel, which the lobby appears beneath. + if (state.phase === 'idle') state.phase = 'lobby'; break; case 'gameStarted': - // reset() already clears the rematch offer that led here. + // reset() clears the readiness that led here, along with the + // last game's board. reset(); state.phase = 'playing'; state.chain = [ @@ -181,25 +221,20 @@ export function createGameStore() { break; case 'opponentLeft': + // Only sent while a game is running: in a lobby the same fact + // arrives as part of the room's own state. state.opponentLeft = { canReconnect: value.canReconnect, graceMs: value.graceMs }; - // An opponent who cannot come back also ends any rematch offer. - // Leaving the prompt up would show a countdown with nobody left - // to answer it. - if (!value.canReconnect) state.rematch = null; - break; - - case 'rematchState': - state.rematch = { - iAccepted: value.iAccepted, - opponentAccepted: value.opponentAccepted, - expiresInMs: value.expiresInMs - }; + state.opponentConnected = false; break; case 'error': + // Two of them also end this player's membership of the room, so + // the model has to stop describing one. Set after, because + // leaving clears everything including the message. + if (value.code === 'kicked' || value.code === 'room_idle_closed') leave(); state.error = errorMessage(value.code); break; @@ -213,6 +248,7 @@ export function createGameStore() { state, apply, reset, + leave, clearRejection() { state.rejection = null; }, @@ -221,9 +257,6 @@ export function createGameStore() { }, clearOpponentLeft() { state.opponentLeft = null; - }, - clearRematch() { - state.rematch = null; } }; } diff --git a/web/src/lib/ws/messages.js b/web/src/lib/ws/messages.js index 4ad2179..50b0e47 100644 --- a/web/src/lib/ws/messages.js +++ b/web/src/lib/ws/messages.js @@ -4,9 +4,12 @@ import { CreateRoomSchema, HelloSchema, JoinRoomSchema, + KickPlayerSchema, + LeaveRoomSchema, PingSchema, - RequestRematchSchema, ResignSchema, + SetReadySchema, + StartGameSchema, StartBotGameSchema, SubmitWordSchema } from '$lib/proto/noitu/v1/game_pb.js'; @@ -76,12 +79,35 @@ export function submitWord(word, turnSeq) { } /** - * Asks to play the same room again. There is no matching decline: leaving is - * the decline, and the server learns about that from the socket closing. + * Declares the guest ready for the next game, or takes it back. Only the guest + * has a readiness to declare: the owner's is Start itself. + * + * @param {boolean} ready */ -export function requestRematch() { +export function setReady(ready) { return create(ClientMessageSchema, { - payload: { case: 'requestRematch', value: create(RequestRematchSchema, {}) } + payload: { case: 'setReady', value: create(SetReadySchema, { ready }) } + }); +} + +/** Begins the game the lobby has agreed on. Refused unless the guest is ready. */ +export function startGame() { + return create(ClientMessageSchema, { + payload: { case: 'startGame', value: create(StartGameSchema, {}) } + }); +} + +/** Frees the guest's seat. Refused while they are ready. */ +export function kickPlayer() { + return create(ClientMessageSchema, { + payload: { case: 'kickPlayer', value: create(KickPlayerSchema, {}) } + }); +} + +/** Gives up a seat without dropping the connection. Refused while ready. */ +export function leaveRoom() { + return create(ClientMessageSchema, { + payload: { case: 'leaveRoom', value: create(LeaveRoomSchema, {}) } }); } diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte index bbc5114..b7d2e7b 100644 --- a/web/src/routes/online/+page.svelte +++ b/web/src/routes/online/+page.svelte @@ -4,14 +4,22 @@ import { page } from '$app/state'; import GameBoard from '$lib/components/GameBoard.svelte'; import GameOverPanel from '$lib/components/GameOverPanel.svelte'; + import Lobby from '$lib/components/Lobby.svelte'; import NicknameInput from '$lib/components/NicknameInput.svelte'; import OpponentStatus from '$lib/components/OpponentStatus.svelte'; - import RematchPrompt from '$lib/components/RematchPrompt.svelte'; - import WaitingRoom from '$lib/components/WaitingRoom.svelte'; import { t } from '$lib/i18n/vi.js'; import { isRoomCode, normalizeRoomCode, ROOM_CODE_LENGTH } from '$lib/room-code.js'; import { game } from '$lib/stores/game.svelte.js'; - import { createRoom, joinRoom, requestRematch, resign, submitWord } from '$lib/ws/messages.js'; + import { + createRoom, + joinRoom, + kickPlayer, + leaveRoom, + resign, + setReady, + startGame, + submitWord + } from '$lib/ws/messages.js'; import { Status, connect, @@ -42,7 +50,7 @@ // The resume worked, so nothing that happens from here is its fault. $effect(() => { - if (playing || game.state.phase === 'waiting') untrack(() => (resuming = false)); + if (playing || game.state.phase === 'lobby') untrack(() => (resuming = false)); }); // Owns the socket while this screen is on, exactly as the bot screen does. @@ -124,10 +132,38 @@ request({ kind: 'join', code }); } - function leave() { + function goHome() { goto('/'); } + /** + * Gives up the seat without leaving the page: the room may still be there + * to rejoin, and the lobby list is the natural place to land. + * + * The local state goes with it. The server sends nothing back to somebody + * who is no longer in the room to be told about, and the button is only + * enabled when this client already knows the rule allows it. + * + * @param {boolean} ready + */ + function ready(ready) { + send(setReady(ready)); + } + + function start() { + send(startGame()); + } + + function kick() { + if (confirm(t.kickConfirm)) send(kickPlayer()); + } + + function leave() { + send(leaveRoom()); + game.leave(); + pending = null; + } + /** * @param {string} word * @returns {boolean} @@ -153,15 +189,21 @@ {/snippet} {#snippet gameOver()} - - - send(requestRematch())} /> + + + {/snippet} - {:else if game.state.phase === 'waiting'} - + {:else if game.state.phase === 'lobby'} + {:else}

{t.onlineTitle}

{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', () => {