mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(online): make a room a lobby with an owner and a ready-up
A room used to be a wrapper around one game: joining started it, and the room died with it unless both players accepted a rematch inside thirty seconds. It is now a lobby that outlives its games. Whoever created the room owns it and the other seat is the guest. The guest readies and the owner starts; the owner has no readiness of their own, because starting is the same statement. A finished game returns both to the lobby, where the next one is agreed exactly as the last was — the readiness that started a game is spent with it. A guest takes their readiness back before leaving, which is deliberate friction: a player the owner is waiting on should have to say so before walking away. The owner can free the seat of a guest who is not ready, and not of one who is — readiness is a commitment, not an inconvenience. An owner who leaves hands the room to whoever is left, unreadied, because they are the one who starts now. Something has to bound a room that outlives its games: the last player out closes it, as does ten minutes in a lobby nobody started a game in. A dropped connection is still not a player leaving — the seat is held for the reconnect window in the lobby as well as mid-game, so a refresh no longer costs somebody their room, and a resume lands in the lobby it left. The rematch handshake is retired, and RoomCreated and RoomJoined go with it. All three described part of what RoomState now describes in full, and three messages for one lobby is three ways for a client to hold a view of it the server never had. One snapshot per recipient, broadcast from the one place that knows an input is finished, so no handler can forget to send it.
This commit is contained in:
1 parent
ef638e52fd
commit
d481a093ec
39 files changed
+2352
-1215
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+4
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+63
-30
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
J
|
||||
Vendored
BIN
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
ZěÖ
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
|
||||
K7QX
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
|
||||
K7QXThuý
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
b
|
||||
K7QX(2Khách mời8@
|
||||
Generated
+406
-300
File diff suppressed because it is too large.
Load diff
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+402
-205
@@ -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}})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+430
-170
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+118
-25
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<script>
|
||||
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
* The room between games. Everything it renders comes from the last
|
||||
* RoomState the server sent, and every button is a request the server is
|
||||
* free to refuse — which is why none of them predict the answer by
|
||||
* changing the screen first.
|
||||
*
|
||||
* `compact` drops the room code panel, for the lobby that appears under a
|
||||
* finished game's result rather than as the whole screen.
|
||||
*
|
||||
* @type {{
|
||||
* compact?: boolean,
|
||||
* onready: (ready: boolean) => void,
|
||||
* onstart: () => void,
|
||||
* onkick: () => void,
|
||||
* onleave: () => void
|
||||
* }}
|
||||
*/
|
||||
let { compact = false, onready, onstart, onkick, onleave } = $props();
|
||||
|
||||
const s = $derived(game.state);
|
||||
</script>
|
||||
|
||||
<section class="lobby" aria-label={t.lobbyTitle}>
|
||||
{#if !compact}
|
||||
<RoomCodePanel code={s.roomCode} />
|
||||
{/if}
|
||||
|
||||
<ul class="seats">
|
||||
<li class="seat" class:ready={s.isOwner || s.isReady}>
|
||||
<span class="name">{s.nickname || t.you}</span>
|
||||
<span class="role">{s.isOwner ? t.owner : t.guest}</span>
|
||||
<!-- The owner has no readiness to show: starting is the statement. -->
|
||||
{#if !s.isOwner}
|
||||
<span class="state" data-testid="my-ready">{s.isReady ? t.isReady : t.notReady}</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class="seat" class:empty={!s.opponentPresent} class:ready={s.opponentReady}>
|
||||
{#if s.opponentPresent}
|
||||
<span class="name">{s.opponentName || t.opponent}</span>
|
||||
<span class="role">{s.isOwner ? t.guest : t.owner}</span>
|
||||
{#if !s.opponentConnected}
|
||||
<span class="state offline">{t.offline}</span>
|
||||
{:else if s.isOwner}
|
||||
<span class="state" data-testid="opponent-ready">
|
||||
{s.opponentReady ? t.isReady : t.notReady}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="name muted">{t.emptySeat}</span>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="hint">
|
||||
{#if s.isOwner}
|
||||
{s.opponentPresent ? t.ownerStartsHint : t.waitingForOpponent}
|
||||
{:else if s.isReady}
|
||||
{t.waitingForStart}
|
||||
{:else}
|
||||
{t.guestReadyHint}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if s.isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={!s.canStart}
|
||||
data-testid="start-game"
|
||||
onclick={onstart}
|
||||
>
|
||||
{t.startGame}
|
||||
</button>
|
||||
{#if s.opponentPresent}
|
||||
<button type="button" disabled={s.opponentReady} onclick={onkick}>
|
||||
{t.kickPlayer}
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
class:on={s.isReady}
|
||||
data-testid="ready"
|
||||
onclick={() => onready(!s.isReady)}
|
||||
>
|
||||
{s.isReady ? t.unready : t.ready}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Disabled rather than hidden while ready: the rule is worth seeing, and
|
||||
a button that vanishes reads as a bug. -->
|
||||
<button type="button" class="leave" disabled={s.isReady} onclick={onleave}>
|
||||
{t.leaveRoom}
|
||||
</button>
|
||||
{#if s.isReady}
|
||||
<p class="note">{t.unreadyToLeave}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.lobby {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.seats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.seat {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.seat.ready {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.seat.empty {
|
||||
border-style: dashed;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name.muted {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.role {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.state {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.state.offline {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions .primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.actions .primary.on {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
background: var(--surface-alt);
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.leave {
|
||||
align-self: center;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.leave:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,87 +0,0 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/** @type {{ onaccept: () => void }} */
|
||||
let { onaccept } = $props();
|
||||
|
||||
const offer = $derived(game.state.rematch);
|
||||
|
||||
// Counted down locally from the duration the server sent. The server owns
|
||||
// the real expiry and closes the room when it passes; this only stops the
|
||||
// prompt from claiming time that has already gone.
|
||||
let remainingMs = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const current = offer;
|
||||
if (!current) {
|
||||
remainingMs = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const endsAt = Date.now() + current.expiresInMs;
|
||||
remainingMs = current.expiresInMs;
|
||||
|
||||
const tick = setInterval(() => {
|
||||
remainingMs = Math.max(0, endsAt - Date.now());
|
||||
if (remainingMs === 0) clearInterval(tick);
|
||||
}, 250);
|
||||
return () => clearInterval(tick);
|
||||
});
|
||||
|
||||
const seconds = $derived(Math.ceil(remainingMs / 1000));
|
||||
const expired = $derived(!!offer && remainingMs === 0);
|
||||
</script>
|
||||
|
||||
{#if offer}
|
||||
<div class="prompt" data-testid="rematch-prompt">
|
||||
{#if expired}
|
||||
<p class="line">{t.rematchExpired}</p>
|
||||
{:else if offer.iAccepted}
|
||||
<p class="line">{fill(t.rematchWaiting, { n: seconds })}</p>
|
||||
{:else}
|
||||
{#if offer.opponentAccepted}
|
||||
<p class="line ready">{fill(t.rematchOpponentReady, { n: seconds })}</p>
|
||||
{:else}
|
||||
<p class="line">{t.rematchAsk}</p>
|
||||
{/if}
|
||||
<button type="button" onclick={onaccept} data-testid="rematch-accept">
|
||||
{t.rematchYes}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.line {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line.ready {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script>
|
||||
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
|
||||
/** @type {{ code: string, onleave: () => void }} */
|
||||
let { code, onleave } = $props();
|
||||
</script>
|
||||
|
||||
<section class="waiting">
|
||||
<RoomCodePanel {code} />
|
||||
|
||||
<p class="status" role="status" data-testid="waiting-status">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
{t.waitingForOpponent}
|
||||
</p>
|
||||
|
||||
<button type="button" class="leave" onclick={onleave}>{t.leaveRoom}</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.leave {
|
||||
padding: 10px 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
+30
-8
@@ -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.',
|
||||
|
||||
+145
-87
@@ -136,22 +136,73 @@ export declare type Resign = Message<"noitu.v1.Resign"> & {
|
||||
export declare const ResignSchema: GenMessage<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.
|
||||
* 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<RequestRematch>;
|
||||
export declare const SetReadySchema: GenMessage<SetReady>;
|
||||
|
||||
/**
|
||||
* 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<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.
|
||||
*
|
||||
* @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<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.
|
||||
*
|
||||
* @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<LeaveRoom>;
|
||||
|
||||
/**
|
||||
* 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<Welcome>;
|
||||
|
||||
/**
|
||||
* @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<RoomCreated>;
|
||||
|
||||
/**
|
||||
* @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<RoomJoined>;
|
||||
|
||||
/**
|
||||
* 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<Pong>;
|
||||
|
||||
/**
|
||||
* 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<RematchState>;
|
||||
export declare const RoomStateSchema: GenMessage<RoomState>;
|
||||
|
||||
/**
|
||||
* @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 };
|
||||
};
|
||||
|
||||
|
||||
Generated
+41
-34
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, {}) }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<OpponentStatus />
|
||||
{/snippet}
|
||||
{#snippet gameOver()}
|
||||
<!-- No rematch button on the panel: two people have to agree, and
|
||||
an offer is not always open. The prompt below appears only
|
||||
while one is, so it is the only thing that can ask. -->
|
||||
<GameOverPanel isRecord={false} onhome={leave} />
|
||||
<RematchPrompt onaccept={() => send(requestRematch())} />
|
||||
<!-- No rematch button on the panel: the room is still here, and
|
||||
the next game is agreed in the lobby below exactly as the
|
||||
last one was. -->
|
||||
<GameOverPanel isRecord={false} onhome={goHome} />
|
||||
<Lobby
|
||||
compact
|
||||
onready={ready}
|
||||
onstart={start}
|
||||
onkick={kick}
|
||||
onleave={leave}
|
||||
/>
|
||||
{/snippet}
|
||||
</GameBoard>
|
||||
{:else if game.state.phase === 'waiting'}
|
||||
<WaitingRoom code={game.state.roomCode} onleave={leave} />
|
||||
{:else if game.state.phase === 'lobby'}
|
||||
<Lobby onready={ready} onstart={start} onkick={kick} onleave={leave} />
|
||||
{:else}
|
||||
<h1>{t.onlineTitle}</h1>
|
||||
<p class="intro">{t.onlineIntro}</p>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user