From 4e2e9e1433cfe79dc68100981d31753637e79bea Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 8 Sep 2026 10:56:14 +0700 Subject: [PATCH] feat(online)!: seat two to four players in a room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A room holds up to four people and needs two to start. Both numbers are server constants sent to the client in RoomState, so the lobby draws whatever the server allows and widening a room is a server change alone. Failing a turn eliminates that player rather than ending the game. The syllable and the used words survive them, the turn passes to whoever is next, and the last player standing wins. Two seats is that same rule seen from close up, which is why there is one implementation of it and not two. A dead end still costs the first player to face it their own clock, as before: they get their turn, and lose it. Everyone behind them has already seen that board, so they go out together rather than each sitting out a turn limit they cannot use — which leaves the player who closed the position standing, the same outcome two players get. A player who is knocked out keeps their seat. They watch the rest of the game, chat included, with only the word input gone, and everybody lands back in the same lobby when it ends. The result screen is the whole table, ranked by who outlasted whom, with each score reported beside the place rather than deciding it. The turn clock is deliberately not paused for a seat that has dropped, so a player who loses their connection on their own turn loses it the way anybody else would. Their reconnect window decides only whether they are still in the game afterwards. Any number of windows can be open at once, settled by one timer armed for the nearest of them. Starting waits for every guest, not merely the first: a room of four that began on one yes would have dealt three people a turn they never agreed to. Kicking names a seat and is still refused on a player who is ready, and on the owner's own — leaving is what an owner who wants out does, and it hands the room on. Joining stays a lobby thing: a room with a game running turns a latecomer away even with seats going spare, because there is no way to hand somebody a game already in progress. BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that could only ever describe a second player, OpponentLeft is retired in favour of presence on RoomState, and suggestions move to the new PlayerEliminated — they describe the position that beat a player, which by the end of a longer game is nobody else's position. ProtocolVersion goes to 2, so a client built against 1 is refused with a readable error rather than decoding a frame that now means something else. --- README.md | 70 +- .../phase-01-engine-elimination.md | 48 ++ .../phase-02-wire-contract.md | 61 ++ .../phase-03-room-seats.md | 60 ++ .../phase-04-client.md | 37 + .../phase-05-tests-docs.md | 30 + plans/260908-1006-multiplayer-rooms/plan.md | 55 ++ proto/noitu/v1/game.proto | 174 +++- proto/testdata/client_kick_player.bin | Bin 2 -> 6 bytes proto/testdata/server_game_over.bin | 6 +- proto/testdata/server_game_started.bin | 7 +- proto/testdata/server_opponent_left.bin | 1 - proto/testdata/server_player_eliminated.bin | 3 + proto/testdata/server_room_state.bin | 7 +- proto/testdata/server_turn_update.bin | 9 +- proto/testdata/server_welcome.bin | 2 +- server/gen/noitu/v1/game.pb.go | 783 ++++++++++++------ server/internal/bot/realcorpus_test.go | 2 +- server/internal/bot/simulate_test.go | 2 +- server/internal/game/engine.go | 195 ++++- server/internal/game/engine_test.go | 12 +- server/internal/game/multiplayer_test.go | 235 ++++++ server/internal/game/state.go | 35 +- server/internal/wsapi/codec.go | 6 - server/internal/wsapi/convert.go | 2 +- server/internal/wsapi/multiplayer_test.go | 312 +++++++ server/internal/wsapi/nickname.go | 29 +- server/internal/wsapi/regression_test.go | 38 +- server/internal/wsapi/room.go | 718 ++++++++++------ server/internal/wsapi/session.go | 6 +- server/internal/wsapi/wire_test.go | 73 +- server/internal/wsapi/wsapi_test.go | 152 +++- web/e2e/helpers.js | 17 +- web/e2e/pvp-game.spec.js | 109 ++- web/e2e/reconnect.spec.js | 6 +- web/src/lib/components/ChainHistory.svelte | 10 + web/src/lib/components/GameBoard.svelte | 39 +- web/src/lib/components/GameOverPanel.svelte | 87 +- web/src/lib/components/Lobby.svelte | 130 ++- web/src/lib/components/OpponentStatus.svelte | 63 -- web/src/lib/components/PlayerStatus.svelte | 88 ++ web/src/lib/components/ScoreBoard.svelte | 94 ++- web/src/lib/history-export.js | 12 +- web/src/lib/i18n/vi.js | 29 +- web/src/lib/proto/noitu/v1/game_pb.d.ts | 321 +++++-- web/src/lib/proto/noitu/v1/game_pb.js | 44 +- web/src/lib/stores/game.svelte.js | 252 ++++-- web/src/lib/ws/messages.js | 21 +- web/src/routes/online/+page.svelte | 16 +- web/src/routes/play/+page.svelte | 7 +- web/tests/game-store.test.js | 263 ++++-- web/tests/game-wire.test.js | 11 +- web/tests/history-export.test.js | 29 +- web/tests/ws-client.test.js | 3 +- 54 files changed, 3692 insertions(+), 1129 deletions(-) create mode 100644 plans/260908-1006-multiplayer-rooms/phase-01-engine-elimination.md create mode 100644 plans/260908-1006-multiplayer-rooms/phase-02-wire-contract.md create mode 100644 plans/260908-1006-multiplayer-rooms/phase-03-room-seats.md create mode 100644 plans/260908-1006-multiplayer-rooms/phase-04-client.md create mode 100644 plans/260908-1006-multiplayer-rooms/phase-05-tests-docs.md create mode 100644 plans/260908-1006-multiplayer-rooms/plan.md delete mode 100644 proto/testdata/server_opponent_left.bin create mode 100644 proto/testdata/server_player_eliminated.bin create mode 100644 server/internal/game/multiplayer_test.go create mode 100644 server/internal/wsapi/multiplayer_test.go delete mode 100644 web/src/lib/components/OpponentStatus.svelte create mode 100644 web/src/lib/components/PlayerStatus.svelte diff --git a/README.md b/README.md index 3e7f233..2e82359 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # noitu -Trò chơi **nối từ** tiếng Việt trên web — chơi với máy hoặc đấu 1v1 trực tuyến. +Trò chơi **nối từ** tiếng Việt trên web — chơi với máy hoặc đấu trực tuyến 2–4 người. A web implementation of the Vietnamese word-chain game *nối từ*: each player submits a meaningful word of **at least 2 syllables** whose **first syllable matches the last syllable @@ -11,12 +11,17 @@ ngôn ngữ → ngữ pháp → pháp luật → luật lệ → ... ``` Playing a word that leaves the next player nothing to answer is not itself a win. They keep -the turn and lose it to the clock like any other, and the game-over screen then shows the -loser a few words the position still had — or tells them it had none. +the turn and lose it to the clock like any other, and are then shown a few words the +position still had — or told it had none. + +A room seats two to four. Failing a turn takes that player out rather than ending the game: +the syllable and the used words survive them, the turn passes to whoever is next, and the +last player standing wins. Two seats is that same rule seen from close up, which is why +there is one implementation of it and not two. ## Status -Playable: vs bot at three difficulties, and online 1v1 by room code. See +Playable: vs bot at three difficulties, and online rooms of two to four by room code. See [`plans/260904-1125-noi-tu-web-game/plan.md`](./plans/260904-1125-noi-tu-web-game/plan.md) for the implementation plan and phase breakdown. @@ -52,28 +57,43 @@ 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 seats them in the room's -lobby. +others type the code or open the link, which seats them in the room's lobby. + +A room holds up to four people and needs two to start. Both numbers are server +constants sent to the client in `RoomState`, so the lobby draws whatever the +server allows and widening a room is a server change alone. 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 +rest are guests. Every 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. +game returns everybody 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 any guest who is not ready, naming it rather than pointing at "the +other one". 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. +Joining is a lobby thing: a room with a game running turns a latecomer away +even when it has seats going spare, because there is no way to hand somebody a +game already in progress. -The two players can talk, in the lobby and during a game. The conversation +The whole room travels as one `RoomState` per recipient — the seating, roles, +readiness and 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. The +recipient's own row is in that list like everybody else's, marked `is_me`, +which is the only encoding of their role there is: a second one alongside would +be a second thing to keep in step with the server. + +A player who is knocked out keeps their seat. They watch the rest of the game — +the chain, the clock, the chat — with only the word input gone, and the final +screen shows everybody's standings, ranked by who outlasted whom with each +score reported beside the place rather than deciding it. + +Everybody in the room can talk, in the lobby and during a game. The conversation belongs to the room rather than to a game, so it survives one starting and finishing, and it dies with the room. A player is replayed what was said while they held their seat: a refresh brings their conversation back, and somebody who walks in with the code starts at silence rather than reading what the last -two people said. Text passes the same filter as a nickname before anyone sees +people in the room said. Text passes the same filter as a nickname before anyone sees it — control and format characters dropped, whitespace collapsed, combining marks capped — and a bot game has no chat, there being nobody to talk to. @@ -82,10 +102,16 @@ name does not: the panel shows them as having left. A name left behind would be one the next person to walk in could ask for, and the words above it would become theirs. -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, or the lobby when no game is running. +Everybody sees the others' server-sanitized nicknames, never the raw input, and +two people asking for the same name are told apart before either is shown it. A +disconnect holds the seat for a grace window and shows the rest of the room a +countdown; a return inside it resumes the same position, rebuilt from the engine +rather than from a recorded stream, or the lobby when no game is running. Any +number of windows can be open at once, and each is settled on its own deadline. + +The turn clock is deliberately not paused for a seat that has dropped. A player +who loses their connection on their own turn loses it the way anybody else +would; the window decides only whether they are still in the game afterwards. ### The frontend @@ -231,7 +257,7 @@ buf generate && buf lint |---|---| | `cd server && go test ./... -race` | The rules engine, the bot, the dictionary, and the whole transport layer | | `cd web && npm test` | The store, the socket client, the Vietnamese copy, and the built bundle | -| `cd web && npm run test:e2e` | Real browsers against the real binary: a bot game, an online game across two browser contexts, and reconnect | +| `cd web && npm run test:e2e` | Real browsers against the real binary: a bot game, online games across two to four browser contexts, elimination, and reconnect | The end-to-end suite plays against a small dictionary derived from [`testdata/fixture-words.txt`](./testdata/fixture-words.txt) through the same diff --git a/plans/260908-1006-multiplayer-rooms/phase-01-engine-elimination.md b/plans/260908-1006-multiplayer-rooms/phase-01-engine-elimination.md new file mode 100644 index 0000000..612c27b --- /dev/null +++ b/plans/260908-1006-multiplayer-rooms/phase-01-engine-elimination.md @@ -0,0 +1,48 @@ +# Phase 1 — Engine: elimination and standings + +`server/internal/game/engine.go`, `state.go`. + +The engine already carries `players []PlayerID` and advances `turnIndex` modulo +its length, so seating more than two costs nothing. What is two-player is the +*ending*: `opponentOf` picks the winner, and any single failure finishes the +game. + +## What changes + +- `alive []bool` parallel to `players`, plus `aliveCount`. +- `outOrder []PlayerID` — elimination order, first out first. Rank is derived + from it, so nothing has to be recomputed on read. +- `outReason map[PlayerID]EndReason` — why each player went out. +- `Turn()` returns the next **living** player; `advance()` skips the dead. +- `eliminate(p, reason)` marks a player out, appends to `outOrder`, and finishes + the game when one player is left. +- `expire(now)` eliminates the player to act, then keeps eliminating the next to + act while the position has no legal move — which is what stops a dead end from + costing every remaining player a full turn clock each. +- `NoMove()` and `Resign(p)` route through `eliminate` too. `Resign` works out + of turn: a player may give up while somebody else is thinking. +- `Alive(p) bool` and `Standings() []Standing` for the transport layer. +- Delete `opponentOf`. + +```go +// Standing is one player's final placing. Rank 1 is the winner; a player +// eliminated later ranks above one eliminated earlier. +type Standing struct { + Player PlayerID + Score int + Rank int + Reason EndReason // EndNone for the winner +} +``` + +`State` gains `Standings []Standing` and `Alive map[PlayerID]bool`. + +## Invariants worth a test + +- Two players: every existing outcome is identical to today's. +- Four players: A times out → A out, turn passes to B on the **same syllable**, + scores and used set survive. +- A dead end closed by A eliminates B, C and D in one call and leaves A standing. +- `Resign` out of turn eliminates the resigner and does not disturb whose turn + it is, unless the resigner *was* the player to act. +- Rank ordering: survivor 1, then reverse `outOrder`. diff --git a/plans/260908-1006-multiplayer-rooms/phase-02-wire-contract.md b/plans/260908-1006-multiplayer-rooms/phase-02-wire-contract.md new file mode 100644 index 0000000..8625181 --- /dev/null +++ b/plans/260908-1006-multiplayer-rooms/phase-02-wire-contract.md @@ -0,0 +1,61 @@ +# Phase 2 — Wire contract and codegen + +`proto/noitu/v1/game.proto`, regenerated into `server/gen/` and +`web/src/lib/proto/`. `ProtocolVersion` → **2**. + +## New messages + +```proto +// PlayerSlot is one seat in the room, rendered for one recipient. +message PlayerSlot { + string player_id = 1; // "p1".."p4" — stable within a room + string name = 2; + bool is_me = 3; + bool is_owner = 4; + bool ready = 5; + bool connected = 6; +} + +// PlayerScore is one player in a running or finished game. +message PlayerScore { + string player_id = 1; + string name = 2; + bool is_me = 3; + uint32 score = 4; + bool eliminated = 5; + bool connected = 6; + uint32 rank = 7; // final placing, 1 = winner; 0 while in play +} + +// PlayerEliminated is one player leaving a game that is still running. +message PlayerEliminated { + string player_id = 1; + string name = 2; + bool is_me = 3; + GameEndReason reason = 4; + // What the position still had, sent only to the player who went out. + repeated string suggestions = 5; +} +``` + +## Changed messages + +| Message | Change | +|---|---| +| `RoomState` | `reserved 2, 4, 5, 6, 7, 8` (`i_am_owner`, `i_am_ready`, `opponent_*`). Adds `repeated PlayerSlot players = 9`, `uint32 max_players = 10`, `uint32 min_players = 11`, `uint32 grace_ms = 12`. `room_code` and `can_start` stay. | +| `GameStarted` | Adds `repeated PlayerScore players = 7`, `string turn_player_id = 8`. | +| `TurnUpdate` | `reserved 6, 7` (`my_score`, `opponent_score`). Adds `repeated PlayerScore players = 9`, `string turn_player_id = 10`. `played` may now be absent — an elimination advances the turn without a word. | +| `GameOver` | `reserved 3` (`my_score`). Adds `repeated PlayerScore standings = 6`, ordered by rank. | +| `PlayedWord` | Adds `string player_id = 6`, so a four-way chain says who played what without matching names. | +| `KickPlayer` | Adds `string player_id = 1` — the owner now names a seat. | +| `OpponentLeft` | Retired. `ServerMessage` reserves tag 8; presence travels on `RoomState.players[].connected` with `RoomState.grace_ms`, which is broadcast mid-game already. | +| `ServerMessage` | `reserved 2, 3, 8, 11`; adds `PlayerEliminated player_eliminated = 15`. | + +`i_am_owner` / `i_am_ready` go because the recipient's own row already carries +them: two encodings of one fact are two ways for a client to disagree with the +server. + +## Regeneration + +`buf` is not on this machine — `go install github.com/bufbuild/buf/cmd/buf@latest` +first, then `make proto`. Both generated trees are committed. diff --git a/plans/260908-1006-multiplayer-rooms/phase-03-room-seats.md b/plans/260908-1006-multiplayer-rooms/phase-03-room-seats.md new file mode 100644 index 0000000..776f0e8 --- /dev/null +++ b/plans/260908-1006-multiplayer-rooms/phase-03-room-seats.md @@ -0,0 +1,60 @@ +# Phase 3 — Room: N seats, N grace windows + +`server/internal/wsapi/room.go`, `codec.go`, `hub.go`, `session.go`, +`nickname.go`. + +## Seats + +- `maxPlayers = 4`, `minPlayers = 2`, `seatIDs = [maxPlayers]game.PlayerID{"p1".."p4"}`. +- `seats [maxPlayers]*seat`. `freeSeat`, `occupied`, `seatOf` already loop. +- `distinguish(name, taken)` becomes `distinguish(name, taken []string)` and + counts up (" 2", " 3", …) until the name is unused. +- Drop `otherNickname` / `opponentSeat`; add `seatsInOrder()` yielding the + occupied seats in seat order, which is also engine turn order. + +## Lobby + +- `canStart()` — no bot strategy, in lobby, at least `minPlayers` seated, owner + connected, every non-owner seat connected and ready. +- `lobbyStart` refuses with `need_more_players` / `player_offline` / + `not_everyone_ready`. +- `lobbyKick` reads `KickPlayer.player_id`: refuse `no_one_to_kick` when the + seat is empty, `cannot_kick_self` when it is the owner's own, and + `player_is_ready` when that player is ready — the existing rule, per target. +- `handleJoin` refuses a full room exactly as now. + +## Grace windows + +One timer is no longer enough: any number of seats can be inside their window at +once. Each `seat` gains `graceUntil time.Time`; `resetGraceTimer()` arms a single +timer for the earliest of them, and on fire every seat past its deadline is +expired together. That keeps one timer and one wakeup regardless of how many +players dropped. + +A dropped seat mid-game is **not** skipped: the engine's turn clock runs for it, +so a player who drops on their own turn is eliminated by timeout before their +window is up. The window only decides whether they are still in the game after it. + +## Eliminations + +`applyEliminations(before int)` — compare the engine's `outOrder` length against +what it was, send each newly eliminated player's `PlayerEliminated` to every +seat (with `suggestions` only in the copy that goes to the player who went out), +then: + +- game over → `broadcastGameOver` with standings; +- otherwise → `r.turnSeq++` and `broadcastTurn(nil)`, so every client learns the + new deadline and whose turn it is without a word having been played. + +The room keeps `outWire map[game.PlayerID]noituv1.GameEndReason`, because +`OPPONENT_LEFT` is a transport fact the engine never learns: a grace expiry +eliminates with `EndResigned` and is reported as `OPPONENT_LEFT`. + +`GameOver.reason` stays the game-level reason — the reason the **last** +elimination happened — which keeps 1v1 wording identical. + +## Broadcasts + +- `broadcastRoomState` builds `PlayerSlot` rows in seat order. +- `broadcastTurn(move *game.Move)` and `sendGameStarted` build `PlayerScore` rows. +- `gameOverFor` builds standings once and sets `is_me` per recipient. diff --git a/plans/260908-1006-multiplayer-rooms/phase-04-client.md b/plans/260908-1006-multiplayer-rooms/phase-04-client.md new file mode 100644 index 0000000..0c6f255 --- /dev/null +++ b/plans/260908-1006-multiplayer-rooms/phase-04-client.md @@ -0,0 +1,37 @@ +# Phase 4 — Client: the room as a list of players + +## Store — `web/src/lib/stores/game.svelte.js` + +`opponentName` / `opponentScore` / `opponentPresent` / `opponentReady` / +`opponentConnected` / `isOwner` / `isReady` / `opponentLeft` all collapse into: + +```js +/** @type {PlayerSlot[]} */ roomPlayers: [], // from RoomState +/** @type {PlayerScore[]} */ gamePlayers: [], // from GameStarted / TurnUpdate +/** @type {PlayerScore[]} */ standings: [], // from GameOver +maxPlayers: 4, +minPlayers: 2, +graceMs: 0, +turnPlayerId: '', +/** @type {{ playerId, name, isMe, reason, suggestions } | null} */ +lastElimination: null, +``` + +with derived getters `me`, `isOwner`, `isReady`, `myScore`, `iAmEliminated`. +A `turnUpdate` with no `played` is an elimination advancing the turn — push +nothing to the chain, apply the rest. + +## Components + +| File | Change | +|---|---| +| `Lobby.svelte` | Renders `roomPlayers` plus empty slots up to `maxPlayers`. Kick button per row, on any guest who is not ready. Hints keyed off how many are seated and how many are ready. | +| `ScoreBoard.svelte` | A row per player, active row = `turnPlayerId`, eliminated rows struck through. Replaces the two-sided layout. | +| `PlayerStatus.svelte` | Replaces `OpponentStatus.svelte`: one line per disconnected player counting down `graceMs`, plus an "eliminated" banner for this player. | +| `GameOverPanel.svelte` | Standings table from `standings`; `myScore` read from the row where `isMe`. Suggestions still only for a player who did not win. | +| `ChainHistory.svelte` | Labels each entry with its player's name via `playerId`. | +| `GameBoard.svelte` | Drops `opponentLabel`; word input hidden while `iAmEliminated`. | +| `online/+page.svelte` | `kick(playerId)`. | +| `messages.js` | `kickPlayer(playerId)`, `PROTOCOL_VERSION = 2`. | +| `history-export.js` | Names each mover from the player list rather than "you / opponent". | +| `i18n/vi.js` | New keys: standings, eliminated, spectating, per-player disconnect, `need_more_players`, `cannot_kick_self`, `player_offline`. | diff --git a/plans/260908-1006-multiplayer-rooms/phase-05-tests-docs.md b/plans/260908-1006-multiplayer-rooms/phase-05-tests-docs.md new file mode 100644 index 0000000..90ea242 --- /dev/null +++ b/plans/260908-1006-multiplayer-rooms/phase-05-tests-docs.md @@ -0,0 +1,30 @@ +# Phase 5 — Tests and docs + +## Go + +- `game/engine_test.go` — the phase 1 invariants: four-player elimination order, + dead-end cascade, out-of-turn resign, standings ranks, and every existing + two-player case unchanged. +- `wsapi/wsapi_test.go` — a four-player room: join to full, fifth refused; start + refused until all ready; kick by id; a full game with two eliminations; an + eliminated player still receiving turn updates and chat. +- `wsapi/regression_test.go` — reconnect inside and outside the window with more + than two players seated. +- `wsapi/wire_test.go` — fixtures for the new and changed messages; + `go test ./internal/wsapi -update` rewrites `proto/testdata/`. +- `wsapi/convert_test.go` — unchanged mappings still exhaustive. + +## JavaScript + +- `tests/game-store.test.js` — the new projection, including a `turnUpdate` with + no `played`. +- `tests/game-wire.test.js` — decodes the regenerated fixtures. +- `tests/error-codes.test.js` — every server code has Vietnamese copy. +- `e2e/pvp-game.spec.js` — a three-player game through the browser. +- `e2e/reconnect.spec.js` — unchanged 1v1 path still passes. + +## Docs + +- `README.md`: "online 1v1 by room code" becomes rooms of two to four, and the + elimination rule joins the opening description. +- `docs/deployment.md` only if a knob changed — it did not. diff --git a/plans/260908-1006-multiplayer-rooms/plan.md b/plans/260908-1006-multiplayer-rooms/plan.md new file mode 100644 index 0000000..81582de --- /dev/null +++ b/plans/260908-1006-multiplayer-rooms/plan.md @@ -0,0 +1,55 @@ +# Rooms of two to four players + +Status: **done** — 2026-09-08. Phases 1–5 delivered and verified, except the +Playwright suite, which could not run on this machine: the browser download +times out here. The specs are written and parse; they have not been executed. + +A room seats up to `maxPlayers` (4, one constant) instead of exactly two. The +owner starts once every guest is ready; two seated players is enough. A player +who fails their turn is eliminated and the rest play on, last standing wins. + +## The rules this settles + +| Question | Answer | +|---|---| +| Room size | 2–4 seats. `maxPlayers` is one server constant, sent to the client in `RoomState` so the lobby renders whatever the server allows. | +| Starting | Owner only, and only when every other seated player is connected and ready. Two players is the minimum. | +| Kicking | Owner only, names a seat, refused when that player is ready — the existing rule, now per-target. | +| Failing a turn | The player who runs out of clock is **eliminated**; the syllable and the used set survive, the turn passes to the next living player. Last standing wins. | +| A dead-end position | The player to act still burns their clock and goes out, as today. Everyone after them is then eliminated at once rather than each waiting out a turn they cannot answer — which leaves the player who closed the position standing, exactly the 1v1 outcome. | +| Disconnecting mid-game | The seat's clock keeps running, so a player who drops on their own turn can time out and go out normally. Otherwise their reconnect window decides it: come back and play on, or be eliminated when it expires. | +| Being eliminated | They keep their seat and watch: live chain, live turns, chat still works, no word input. Everyone lands back in the same lobby when the game ends. | +| Game over | Full standings — every player, their score, ranked by finishing order (last standing first, then reverse elimination order). | + +## Wire contract + +This breaks the two-player shape of `RoomState`, `TurnUpdate`, `GameOver` and +`OpponentLeft`, so `ProtocolVersion` goes to **2** and the retired field tags go +to `reserved`. That is what the version number is for: an old client is refused +with a readable error rather than decoding a frame that means something else now. + +## Phases + +| # | Phase | Depends on | +|---|---|---| +| 1 | [Engine: elimination and standings](./phase-01-engine-elimination.md) | — | +| 2 | [Wire contract and codegen](./phase-02-wire-contract.md) | — | +| 3 | [Room: N seats, N grace windows](./phase-03-room-seats.md) | 1, 2 | +| 4 | [Client: the room as a list of players](./phase-04-client.md) | 2 | +| 5 | [Tests and docs](./phase-05-tests-docs.md) | 1–4 | + +All five are complete. `go vet`, `go test ./... -race` and `npm test` pass; +`npm run check` reports no problems. `npx playwright test` is blocked on the +browser download, not on the code. + +## Acceptance criteria + +- A room seats 2–4; a fifth join is refused with `room_full`. +- Owner's start button is refused until every other seated player is ready. +- The owner can kick any named guest who is not ready. +- A 4-player game eliminates on timeout and continues; the last player standing + wins and everyone sees the same standings. +- An eliminated player still sees the board and can chat, and returns to the + lobby with the others. +- 1v1 behaviour is unchanged end to end, including reconnect and vs-bot. +- `go vet`, `go test -race`, `npm test` and the Playwright suite all pass. diff --git a/proto/noitu/v1/game.proto b/proto/noitu/v1/game.proto index ee6c089..54865db 100644 --- a/proto/noitu/v1/game.proto +++ b/proto/noitu/v1/game.proto @@ -85,9 +85,9 @@ message SubmitWord { message Resign {} -// SetReady is the guest declaring themselves ready, or taking it back. +// SetReady is a guest declaring themselves ready, or taking it back. // -// Only the guest has a readiness to set. The owner's is implied by StartGame: +// Only guests have 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 { @@ -95,13 +95,18 @@ message SetReady { } // StartGame is the owner beginning the game the lobby has agreed on. It is -// refused unless the guest is seated, connected and ready. +// refused unless at least one guest is seated and every seated guest is +// connected and ready. message StartGame {} -// KickPlayer is the owner freeing the guest's seat. Refused while the guest is +// KickPlayer is the owner freeing one seat. Refused while that player is // ready: readiness is a commitment, and a player who has made it is not // something the owner gets to overrule. -message KickPlayer {} +message KickPlayer { + // Which seat, from RoomState.players. A room holds up to four people, so + // "the other one" stopped being an answer. + string player_id = 1; +} // 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 @@ -162,10 +167,55 @@ message PlayedWord { uint32 points = 3; uint32 syllables = 4; string typed = 5; + // Which seat played it. by_me answers "was this mine"; with four people at + // the table the chain also has to say whose the other words were, and a seat + // id says that without the client matching display names. + string player_id = 6; } -// GameStarted is rendered per recipient: my_turn is true for exactly one of -// the two players. +// PlayerSlot is one seat in the room, rendered for one recipient. +// +// The recipient's own row is in the list like everybody else's, marked by +// is_me. That is deliberately the only way to find yourself: a separate +// i_am_owner alongside an is_owner in the list would be two encodings of one +// fact, and two ways for a client to disagree with the server. +message PlayerSlot { + // Stable for as long as this player holds the seat. Not stable across a + // seat being vacated and refilled, which is exactly when a name stops + // meaning the same person too. + string player_id = 1; + // Always server-sanitized, as everywhere else another player's name appears. + string name = 2; + bool is_me = 3; + bool is_owner = 4; + // Always false for the owner, whose readiness is StartGame itself. + bool ready = 5; + // False while this player is inside their reconnect window. + bool connected = 6; +} + +// PlayerScore is one player in a running or finished game. +// +// Separate from PlayerSlot because they answer different questions: a slot is +// about the room, a score is about the game being played in it. A player who +// has been eliminated still has both — being out of the game is not being out +// of the room. +message PlayerScore { + string player_id = 1; + string name = 2; + bool is_me = 3; + uint32 score = 4; + // True once this player has been knocked out. They keep their seat, their + // score and their words; they simply no longer get a turn. + bool eliminated = 5; + bool connected = 6; + // Final placing, 1 for the winner. Zero while the game is still running, + // which is what tells the two apart without a second field. + uint32 rank = 7; +} + +// GameStarted is rendered per recipient: my_turn is true for exactly one +// player. message GameStarted { string opening_word = 1; string current_syllable = 2; @@ -175,19 +225,33 @@ message GameStarted { int64 deadline_unix_ms = 4; uint32 turn_seq = 5; uint32 turn_limit_ms = 6; + // Everyone playing, in turn order. + repeated PlayerScore players = 7; + // Whose turn it is. my_turn above says whether it is yours; this says whose + // it is when it is not, which a two-player game never had to. + string turn_player_id = 8; } -// TurnUpdate follows every accepted move and goes to both players, serialized +// TurnUpdate follows every turn change and goes to every player, serialized // once per recipient so by_me and my_turn are correct for each. +// +// played is absent when the turn moved without a word being played, which is +// what an elimination does: the syllable and the used set survive the player +// who could not answer them. message TurnUpdate { + // 6 was my_score and 7 was opponent_score. Both are in players below now, + // where a four-way game can express them. + reserved 6, 7; + PlayedWord played = 1; string current_syllable = 2; bool my_turn = 3; int64 deadline_unix_ms = 4; uint32 turn_seq = 5; - uint32 my_score = 6; - uint32 opponent_score = 7; uint32 chain_length = 8; + // Everyone playing, in turn order, with scores as they stand. + repeated PlayerScore players = 9; + string turn_player_id = 10; } message MoveRejected { @@ -196,23 +260,38 @@ message MoveRejected { uint32 turn_seq = 3; } -// GameOver is rendered per recipient: i_won is true for exactly one of the two -// players. +// GameOver is rendered per recipient: i_won is true for exactly one player, +// the one still standing when everybody else had been eliminated. message GameOver { + // 3 was my_score, which is now the standings row where is_me is true. 5 was + // suggestions, which moved to PlayerEliminated: they describe the position a + // player was stuck on, and by the time a game ends that is no longer the + // position anyone but the last player out was looking at. + reserved 3, 5; + bool i_won = 1; GameEndReason reason = 2; - uint32 my_score = 3; uint32 chain_length = 4; - // A few words that could still have been played from the position the game - // ended on, filled only for the player who lost — the winner is not the one - // who needed them. An empty list on a loss is itself the answer: the - // position was a dead end and nobody could have answered it. - repeated string suggestions = 5; + // The final table, best first: the player left standing, then the others in + // reverse order of elimination. Outlasting somebody is what beats them, so + // the ranking is finishing order and each score is reported beside it rather + // than deciding it. + repeated PlayerScore standings = 6; } -message OpponentLeft { - bool can_reconnect = 1; - uint32 grace_ms = 2; +// PlayerEliminated is one player knocked out of a game that is still running. +// +// Rendered per recipient like everything else in a room, and the only message +// whose contents differ by more than a flag: suggestions are filled in solely +// for the player who went out, because they are the one who was stuck. +message PlayerEliminated { + string player_id = 1; + string name = 2; + bool is_me = 3; + GameEndReason reason = 4; + // A few words the position still had, for the player who just lost it. An + // empty list is itself the answer: nobody could have answered that syllable. + repeated string suggestions = 5; } // ServerError.message is a UI key such as "room_not_found", never prose: all @@ -227,32 +306,39 @@ message Pong { int64 server_time_ms = 2; } -// RoomState is the whole lobby, rendered for one recipient, and it is the only +// RoomState is the whole room, 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. +// see — a seat filled or freed, a readiness set, an owner promoted, somebody +// dropping or coming back — 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. +// It describes the room, not the game, so it is meaningful during one too: +// while a game runs this is what carries presence, which is why there is no +// separate message for a player disconnecting. message RoomState { + // 2 was i_am_owner and 4 was i_am_ready: both are in the recipient's own + // row in players now, and two encodings of one fact are two ways for a + // client to disagree with the server. 5 through 8 were the opponent_* + // fields, which could only ever describe a second player. + reserved 2, 4, 5, 6, 7, 8; + 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; + // Everyone seated, in seat order, which is also the turn order a game will + // use. Always includes the recipient, marked is_me. + repeated PlayerSlot players = 9; + // How many seats the room has and how many players a game needs. Sent + // rather than compiled in, so the lobby draws whatever the server allows and + // raising the limit does not need a client deploy. + uint32 max_players = 10; + uint32 min_players = 11; + // How long a seat is held for a player who has dropped. The client counts + // down against it for anybody whose connected is false; the server still + // decides when the seat is actually forfeit. + uint32 grace_ms = 12; } // ChatMessage is one line as one recipient sees it. Rendered per recipient @@ -284,8 +370,10 @@ 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; + // with the rematch handshake. 8 was OpponentLeft, retired with the second + // seat: presence is per player now, and RoomState already carries it to + // everyone in the room whether or not a game is running. + reserved 2, 3, 8, 11; oneof payload { Welcome welcome = 1; @@ -293,11 +381,11 @@ message ServerMessage { TurnUpdate turn_update = 5; MoveRejected move_rejected = 6; GameOver game_over = 7; - OpponentLeft opponent_left = 8; ServerError error = 9; Pong pong = 10; RoomState room_state = 12; ChatMessage chat_message = 13; ChatHistory chat_history = 14; + PlayerEliminated player_eliminated = 15; } } diff --git a/proto/testdata/client_kick_player.bin b/proto/testdata/client_kick_player.bin index 839cc09ec801d201022a4e13403c220a09809268..1463f293e9d40b232d1e92c0f2962cb97d4721fe 100644 GIT binary patch literal 6 Ncma!I;bJN<1^@$h0S*8F literal 2 Jcma!I0002C09yb6 diff --git a/proto/testdata/server_game_over.bin b/proto/testdata/server_game_over.bin index dab436d..306667d 100644 --- a/proto/testdata/server_game_over.bin +++ b/proto/testdata/server_game_over.bin @@ -1,2 +1,4 @@ -: * -sinh viên* sinh sôi \ No newline at end of file +:T 2 +p2 Khách mời *082 +p1Người chơi (082 +p3Khách 2 (8 \ No newline at end of file diff --git a/proto/testdata/server_game_started.bin b/proto/testdata/server_game_started.bin index 31892b3..411352a 100644 --- a/proto/testdata/server_game_started.bin +++ b/proto/testdata/server_game_started.bin @@ -1,3 +1,6 @@ -"" +"n -hòa bìnhbình  ×ø©‘3(0 œ \ No newline at end of file +hòa bìnhbình  ×ø©‘3(0 œ: +p1Người chơi 0: +p2 Khách mời *(0: +p3Khách 2 Bp1 \ No newline at end of file diff --git a/proto/testdata/server_opponent_left.bin b/proto/testdata/server_opponent_left.bin deleted file mode 100644 index a1d1433..0000000 --- a/proto/testdata/server_opponent_left.bin +++ /dev/null @@ -1 +0,0 @@ -B°ê \ No newline at end of file diff --git a/proto/testdata/server_player_eliminated.bin b/proto/testdata/server_player_eliminated.bin new file mode 100644 index 0000000..9f25168 --- /dev/null +++ b/proto/testdata/server_player_eliminated.bin @@ -0,0 +1,3 @@ +z/ +p1Người chơi * +sinh viên* sinh sôi \ No newline at end of file diff --git a/proto/testdata/server_room_state.bin b/proto/testdata/server_room_state.bin index bc36158..706ae4e 100644 --- a/proto/testdata/server_room_state.bin +++ b/proto/testdata/server_room_state.bin @@ -1,2 +1,5 @@ -b -K7QX(2 Khách mời8@ \ No newline at end of file +bV +K7QXJ +p1Người chơi 0J +p2 Khách mời(0J +p3Khách 2(PX`°ê \ No newline at end of file diff --git a/proto/testdata/server_turn_update.bin b/proto/testdata/server_turn_update.bin index 2390fae..103971d 100644 --- a/proto/testdata/server_turn_update.bin +++ b/proto/testdata/server_turn_update.bin @@ -1,4 +1,7 @@ -*4 - +*€ + -bình yên * binh yênyên Àóù©‘3(08@ \ No newline at end of file +bình yên * binh yên2p2yên Àóù©‘3(@J +p1Người chơi 0J +p2 Khách mời *(0J +p3Khách 2 Rp3 \ No newline at end of file diff --git a/proto/testdata/server_welcome.bin b/proto/testdata/server_welcome.bin index a6af317..7a5f089 100644 --- a/proto/testdata/server_welcome.bin +++ b/proto/testdata/server_welcome.bin @@ -1,3 +1,3 @@ . -s-1a2b3cr-8f2c"Người chơi ẩn danh \ No newline at end of file +s-1a2b3cr-8f2c"Người chơi ẩn danh \ No newline at end of file diff --git a/server/gen/noitu/v1/game.pb.go b/server/gen/noitu/v1/game.pb.go index b4d1223..c30c0a7 100644 --- a/server/gen/noitu/v1/game.pb.go +++ b/server/gen/noitu/v1/game.pb.go @@ -493,9 +493,9 @@ func (*Resign) Descriptor() ([]byte, []int) { return file_noitu_v1_game_proto_rawDescGZIP(), []int{5} } -// SetReady is the guest declaring themselves ready, or taking it back. +// SetReady is a guest declaring themselves ready, or taking it back. // -// Only the guest has a readiness to set. The owner's is implied by StartGame: +// Only guests have a readiness to set. The owner's is implied by StartGame: // asking for the game to begin is the same statement, and a second flag they // would always have to set first buys nothing. type SetReady struct { @@ -543,7 +543,8 @@ func (x *SetReady) GetReady() bool { } // StartGame is the owner beginning the game the lobby has agreed on. It is -// refused unless the guest is seated, connected and ready. +// refused unless at least one guest is seated and every seated guest is +// connected and ready. type StartGame struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -580,11 +581,14 @@ func (*StartGame) Descriptor() ([]byte, []int) { return file_noitu_v1_game_proto_rawDescGZIP(), []int{7} } -// KickPlayer is the owner freeing the guest's seat. Refused while the guest is +// KickPlayer is the owner freeing one seat. Refused while that player is // ready: readiness is a commitment, and a player who has made it is not // something the owner gets to overrule. type KickPlayer struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Which seat, from RoomState.players. A room holds up to four people, so + // "the other one" stopped being an answer. + PlayerId string `protobuf:"bytes,1,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -619,6 +623,13 @@ func (*KickPlayer) Descriptor() ([]byte, []int) { return file_noitu_v1_game_proto_rawDescGZIP(), []int{8} } +func (x *KickPlayer) GetPlayerId() string { + if x != nil { + return x.PlayerId + } + return "" +} + // 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. @@ -1064,12 +1075,16 @@ func (x *Welcome) GetAcceptedNickname() string { // 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. type PlayedWord struct { - state protoimpl.MessageState `protogen:"open.v1"` - Word string `protobuf:"bytes,1,opt,name=word,proto3" json:"word,omitempty"` - ByMe bool `protobuf:"varint,2,opt,name=by_me,json=byMe,proto3" json:"by_me,omitempty"` - Points uint32 `protobuf:"varint,3,opt,name=points,proto3" json:"points,omitempty"` - Syllables uint32 `protobuf:"varint,4,opt,name=syllables,proto3" json:"syllables,omitempty"` - Typed string `protobuf:"bytes,5,opt,name=typed,proto3" json:"typed,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Word string `protobuf:"bytes,1,opt,name=word,proto3" json:"word,omitempty"` + ByMe bool `protobuf:"varint,2,opt,name=by_me,json=byMe,proto3" json:"by_me,omitempty"` + Points uint32 `protobuf:"varint,3,opt,name=points,proto3" json:"points,omitempty"` + Syllables uint32 `protobuf:"varint,4,opt,name=syllables,proto3" json:"syllables,omitempty"` + Typed string `protobuf:"bytes,5,opt,name=typed,proto3" json:"typed,omitempty"` + // Which seat played it. by_me answers "was this mine"; with four people at + // the table the chain also has to say whose the other words were, and a seat + // id says that without the client matching display names. + PlayerId string `protobuf:"bytes,6,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1139,8 +1154,213 @@ func (x *PlayedWord) GetTyped() string { return "" } -// GameStarted is rendered per recipient: my_turn is true for exactly one of -// the two players. +func (x *PlayedWord) GetPlayerId() string { + if x != nil { + return x.PlayerId + } + return "" +} + +// PlayerSlot is one seat in the room, rendered for one recipient. +// +// The recipient's own row is in the list like everybody else's, marked by +// is_me. That is deliberately the only way to find yourself: a separate +// i_am_owner alongside an is_owner in the list would be two encodings of one +// fact, and two ways for a client to disagree with the server. +type PlayerSlot struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable for as long as this player holds the seat. Not stable across a + // seat being vacated and refilled, which is exactly when a name stops + // meaning the same person too. + PlayerId string `protobuf:"bytes,1,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"` + // Always server-sanitized, as everywhere else another player's name appears. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + IsMe bool `protobuf:"varint,3,opt,name=is_me,json=isMe,proto3" json:"is_me,omitempty"` + IsOwner bool `protobuf:"varint,4,opt,name=is_owner,json=isOwner,proto3" json:"is_owner,omitempty"` + // Always false for the owner, whose readiness is StartGame itself. + Ready bool `protobuf:"varint,5,opt,name=ready,proto3" json:"ready,omitempty"` + // False while this player is inside their reconnect window. + Connected bool `protobuf:"varint,6,opt,name=connected,proto3" json:"connected,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlayerSlot) Reset() { + *x = PlayerSlot{} + mi := &file_noitu_v1_game_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlayerSlot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlayerSlot) ProtoMessage() {} + +func (x *PlayerSlot) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlayerSlot.ProtoReflect.Descriptor instead. +func (*PlayerSlot) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{15} +} + +func (x *PlayerSlot) GetPlayerId() string { + if x != nil { + return x.PlayerId + } + return "" +} + +func (x *PlayerSlot) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PlayerSlot) GetIsMe() bool { + if x != nil { + return x.IsMe + } + return false +} + +func (x *PlayerSlot) GetIsOwner() bool { + if x != nil { + return x.IsOwner + } + return false +} + +func (x *PlayerSlot) GetReady() bool { + if x != nil { + return x.Ready + } + return false +} + +func (x *PlayerSlot) GetConnected() bool { + if x != nil { + return x.Connected + } + return false +} + +// PlayerScore is one player in a running or finished game. +// +// Separate from PlayerSlot because they answer different questions: a slot is +// about the room, a score is about the game being played in it. A player who +// has been eliminated still has both — being out of the game is not being out +// of the room. +type PlayerScore struct { + state protoimpl.MessageState `protogen:"open.v1"` + PlayerId string `protobuf:"bytes,1,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + IsMe bool `protobuf:"varint,3,opt,name=is_me,json=isMe,proto3" json:"is_me,omitempty"` + Score uint32 `protobuf:"varint,4,opt,name=score,proto3" json:"score,omitempty"` + // True once this player has been knocked out. They keep their seat, their + // score and their words; they simply no longer get a turn. + Eliminated bool `protobuf:"varint,5,opt,name=eliminated,proto3" json:"eliminated,omitempty"` + Connected bool `protobuf:"varint,6,opt,name=connected,proto3" json:"connected,omitempty"` + // Final placing, 1 for the winner. Zero while the game is still running, + // which is what tells the two apart without a second field. + Rank uint32 `protobuf:"varint,7,opt,name=rank,proto3" json:"rank,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlayerScore) Reset() { + *x = PlayerScore{} + mi := &file_noitu_v1_game_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlayerScore) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlayerScore) ProtoMessage() {} + +func (x *PlayerScore) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlayerScore.ProtoReflect.Descriptor instead. +func (*PlayerScore) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{16} +} + +func (x *PlayerScore) GetPlayerId() string { + if x != nil { + return x.PlayerId + } + return "" +} + +func (x *PlayerScore) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PlayerScore) GetIsMe() bool { + if x != nil { + return x.IsMe + } + return false +} + +func (x *PlayerScore) GetScore() uint32 { + if x != nil { + return x.Score + } + return 0 +} + +func (x *PlayerScore) GetEliminated() bool { + if x != nil { + return x.Eliminated + } + return false +} + +func (x *PlayerScore) GetConnected() bool { + if x != nil { + return x.Connected + } + return false +} + +func (x *PlayerScore) GetRank() uint32 { + if x != nil { + return x.Rank + } + return 0 +} + +// GameStarted is rendered per recipient: my_turn is true for exactly one +// player. type GameStarted struct { state protoimpl.MessageState `protogen:"open.v1"` OpeningWord string `protobuf:"bytes,1,opt,name=opening_word,json=openingWord,proto3" json:"opening_word,omitempty"` @@ -1151,13 +1371,18 @@ type GameStarted struct { DeadlineUnixMs int64 `protobuf:"varint,4,opt,name=deadline_unix_ms,json=deadlineUnixMs,proto3" json:"deadline_unix_ms,omitempty"` TurnSeq uint32 `protobuf:"varint,5,opt,name=turn_seq,json=turnSeq,proto3" json:"turn_seq,omitempty"` TurnLimitMs uint32 `protobuf:"varint,6,opt,name=turn_limit_ms,json=turnLimitMs,proto3" json:"turn_limit_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Everyone playing, in turn order. + Players []*PlayerScore `protobuf:"bytes,7,rep,name=players,proto3" json:"players,omitempty"` + // Whose turn it is. my_turn above says whether it is yours; this says whose + // it is when it is not, which a two-player game never had to. + TurnPlayerId string `protobuf:"bytes,8,opt,name=turn_player_id,json=turnPlayerId,proto3" json:"turn_player_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GameStarted) Reset() { *x = GameStarted{} - mi := &file_noitu_v1_game_proto_msgTypes[15] + mi := &file_noitu_v1_game_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1169,7 +1394,7 @@ func (x *GameStarted) String() string { func (*GameStarted) ProtoMessage() {} func (x *GameStarted) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[15] + mi := &file_noitu_v1_game_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1182,7 +1407,7 @@ func (x *GameStarted) ProtoReflect() protoreflect.Message { // Deprecated: Use GameStarted.ProtoReflect.Descriptor instead. func (*GameStarted) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{15} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{17} } func (x *GameStarted) GetOpeningWord() string { @@ -1227,8 +1452,26 @@ func (x *GameStarted) GetTurnLimitMs() uint32 { return 0 } -// TurnUpdate follows every accepted move and goes to both players, serialized +func (x *GameStarted) GetPlayers() []*PlayerScore { + if x != nil { + return x.Players + } + return nil +} + +func (x *GameStarted) GetTurnPlayerId() string { + if x != nil { + return x.TurnPlayerId + } + return "" +} + +// TurnUpdate follows every turn change and goes to every player, serialized // once per recipient so by_me and my_turn are correct for each. +// +// played is absent when the turn moved without a word being played, which is +// what an elimination does: the syllable and the used set survive the player +// who could not answer them. type TurnUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` Played *PlayedWord `protobuf:"bytes,1,opt,name=played,proto3" json:"played,omitempty"` @@ -1236,16 +1479,17 @@ type TurnUpdate struct { MyTurn bool `protobuf:"varint,3,opt,name=my_turn,json=myTurn,proto3" json:"my_turn,omitempty"` DeadlineUnixMs int64 `protobuf:"varint,4,opt,name=deadline_unix_ms,json=deadlineUnixMs,proto3" json:"deadline_unix_ms,omitempty"` TurnSeq uint32 `protobuf:"varint,5,opt,name=turn_seq,json=turnSeq,proto3" json:"turn_seq,omitempty"` - MyScore uint32 `protobuf:"varint,6,opt,name=my_score,json=myScore,proto3" json:"my_score,omitempty"` - OpponentScore uint32 `protobuf:"varint,7,opt,name=opponent_score,json=opponentScore,proto3" json:"opponent_score,omitempty"` ChainLength uint32 `protobuf:"varint,8,opt,name=chain_length,json=chainLength,proto3" json:"chain_length,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Everyone playing, in turn order, with scores as they stand. + Players []*PlayerScore `protobuf:"bytes,9,rep,name=players,proto3" json:"players,omitempty"` + TurnPlayerId string `protobuf:"bytes,10,opt,name=turn_player_id,json=turnPlayerId,proto3" json:"turn_player_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *TurnUpdate) Reset() { *x = TurnUpdate{} - mi := &file_noitu_v1_game_proto_msgTypes[16] + mi := &file_noitu_v1_game_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1257,7 +1501,7 @@ func (x *TurnUpdate) String() string { func (*TurnUpdate) ProtoMessage() {} func (x *TurnUpdate) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[16] + mi := &file_noitu_v1_game_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1270,7 +1514,7 @@ func (x *TurnUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use TurnUpdate.ProtoReflect.Descriptor instead. func (*TurnUpdate) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{16} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{18} } func (x *TurnUpdate) GetPlayed() *PlayedWord { @@ -1308,20 +1552,6 @@ func (x *TurnUpdate) GetTurnSeq() uint32 { return 0 } -func (x *TurnUpdate) GetMyScore() uint32 { - if x != nil { - return x.MyScore - } - return 0 -} - -func (x *TurnUpdate) GetOpponentScore() uint32 { - if x != nil { - return x.OpponentScore - } - return 0 -} - func (x *TurnUpdate) GetChainLength() uint32 { if x != nil { return x.ChainLength @@ -1329,6 +1559,20 @@ func (x *TurnUpdate) GetChainLength() uint32 { return 0 } +func (x *TurnUpdate) GetPlayers() []*PlayerScore { + if x != nil { + return x.Players + } + return nil +} + +func (x *TurnUpdate) GetTurnPlayerId() string { + if x != nil { + return x.TurnPlayerId + } + return "" +} + type MoveRejected struct { state protoimpl.MessageState `protogen:"open.v1"` Reason RejectReason `protobuf:"varint,1,opt,name=reason,proto3,enum=noitu.v1.RejectReason" json:"reason,omitempty"` @@ -1340,7 +1584,7 @@ type MoveRejected struct { func (x *MoveRejected) Reset() { *x = MoveRejected{} - mi := &file_noitu_v1_game_proto_msgTypes[17] + mi := &file_noitu_v1_game_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1352,7 +1596,7 @@ func (x *MoveRejected) String() string { func (*MoveRejected) ProtoMessage() {} func (x *MoveRejected) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[17] + mi := &file_noitu_v1_game_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1365,7 +1609,7 @@ func (x *MoveRejected) ProtoReflect() protoreflect.Message { // Deprecated: Use MoveRejected.ProtoReflect.Descriptor instead. func (*MoveRejected) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{17} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{19} } func (x *MoveRejected) GetReason() RejectReason { @@ -1389,26 +1633,25 @@ func (x *MoveRejected) GetTurnSeq() uint32 { return 0 } -// GameOver is rendered per recipient: i_won is true for exactly one of the two -// players. +// GameOver is rendered per recipient: i_won is true for exactly one player, +// the one still standing when everybody else had been eliminated. type GameOver struct { state protoimpl.MessageState `protogen:"open.v1"` IWon bool `protobuf:"varint,1,opt,name=i_won,json=iWon,proto3" json:"i_won,omitempty"` Reason GameEndReason `protobuf:"varint,2,opt,name=reason,proto3,enum=noitu.v1.GameEndReason" json:"reason,omitempty"` - MyScore uint32 `protobuf:"varint,3,opt,name=my_score,json=myScore,proto3" json:"my_score,omitempty"` ChainLength uint32 `protobuf:"varint,4,opt,name=chain_length,json=chainLength,proto3" json:"chain_length,omitempty"` - // A few words that could still have been played from the position the game - // ended on, filled only for the player who lost — the winner is not the one - // who needed them. An empty list on a loss is itself the answer: the - // position was a dead end and nobody could have answered it. - Suggestions []string `protobuf:"bytes,5,rep,name=suggestions,proto3" json:"suggestions,omitempty"` + // The final table, best first: the player left standing, then the others in + // reverse order of elimination. Outlasting somebody is what beats them, so + // the ranking is finishing order and each score is reported beside it rather + // than deciding it. + Standings []*PlayerScore `protobuf:"bytes,6,rep,name=standings,proto3" json:"standings,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GameOver) Reset() { *x = GameOver{} - mi := &file_noitu_v1_game_proto_msgTypes[18] + mi := &file_noitu_v1_game_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1420,7 +1663,7 @@ func (x *GameOver) String() string { func (*GameOver) ProtoMessage() {} func (x *GameOver) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[18] + mi := &file_noitu_v1_game_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1433,7 +1676,7 @@ func (x *GameOver) ProtoReflect() protoreflect.Message { // Deprecated: Use GameOver.ProtoReflect.Descriptor instead. func (*GameOver) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{18} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{20} } func (x *GameOver) GetIWon() bool { @@ -1450,13 +1693,6 @@ func (x *GameOver) GetReason() GameEndReason { return GameEndReason_GAME_END_REASON_UNSPECIFIED } -func (x *GameOver) GetMyScore() uint32 { - if x != nil { - return x.MyScore - } - return 0 -} - func (x *GameOver) GetChainLength() uint32 { if x != nil { return x.ChainLength @@ -1464,36 +1700,46 @@ func (x *GameOver) GetChainLength() uint32 { return 0 } -func (x *GameOver) GetSuggestions() []string { +func (x *GameOver) GetStandings() []*PlayerScore { if x != nil { - return x.Suggestions + return x.Standings } return nil } -type OpponentLeft struct { - state protoimpl.MessageState `protogen:"open.v1"` - CanReconnect bool `protobuf:"varint,1,opt,name=can_reconnect,json=canReconnect,proto3" json:"can_reconnect,omitempty"` - GraceMs uint32 `protobuf:"varint,2,opt,name=grace_ms,json=graceMs,proto3" json:"grace_ms,omitempty"` +// PlayerEliminated is one player knocked out of a game that is still running. +// +// Rendered per recipient like everything else in a room, and the only message +// whose contents differ by more than a flag: suggestions are filled in solely +// for the player who went out, because they are the one who was stuck. +type PlayerEliminated struct { + state protoimpl.MessageState `protogen:"open.v1"` + PlayerId string `protobuf:"bytes,1,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + IsMe bool `protobuf:"varint,3,opt,name=is_me,json=isMe,proto3" json:"is_me,omitempty"` + Reason GameEndReason `protobuf:"varint,4,opt,name=reason,proto3,enum=noitu.v1.GameEndReason" json:"reason,omitempty"` + // A few words the position still had, for the player who just lost it. An + // empty list is itself the answer: nobody could have answered that syllable. + Suggestions []string `protobuf:"bytes,5,rep,name=suggestions,proto3" json:"suggestions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *OpponentLeft) Reset() { - *x = OpponentLeft{} - mi := &file_noitu_v1_game_proto_msgTypes[19] +func (x *PlayerEliminated) Reset() { + *x = PlayerEliminated{} + mi := &file_noitu_v1_game_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *OpponentLeft) String() string { +func (x *PlayerEliminated) String() string { return protoimpl.X.MessageStringOf(x) } -func (*OpponentLeft) ProtoMessage() {} +func (*PlayerEliminated) ProtoMessage() {} -func (x *OpponentLeft) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[19] +func (x *PlayerEliminated) ProtoReflect() protoreflect.Message { + mi := &file_noitu_v1_game_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1504,23 +1750,44 @@ func (x *OpponentLeft) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use OpponentLeft.ProtoReflect.Descriptor instead. -func (*OpponentLeft) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{19} +// Deprecated: Use PlayerEliminated.ProtoReflect.Descriptor instead. +func (*PlayerEliminated) Descriptor() ([]byte, []int) { + return file_noitu_v1_game_proto_rawDescGZIP(), []int{21} } -func (x *OpponentLeft) GetCanReconnect() bool { +func (x *PlayerEliminated) GetPlayerId() string { if x != nil { - return x.CanReconnect + return x.PlayerId + } + return "" +} + +func (x *PlayerEliminated) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PlayerEliminated) GetIsMe() bool { + if x != nil { + return x.IsMe } return false } -func (x *OpponentLeft) GetGraceMs() uint32 { +func (x *PlayerEliminated) GetReason() GameEndReason { if x != nil { - return x.GraceMs + return x.Reason } - return 0 + return GameEndReason_GAME_END_REASON_UNSPECIFIED +} + +func (x *PlayerEliminated) GetSuggestions() []string { + if x != nil { + return x.Suggestions + } + return nil } // ServerError.message is a UI key such as "room_not_found", never prose: all @@ -1535,7 +1802,7 @@ type ServerError struct { func (x *ServerError) Reset() { *x = ServerError{} - mi := &file_noitu_v1_game_proto_msgTypes[20] + mi := &file_noitu_v1_game_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1547,7 +1814,7 @@ func (x *ServerError) String() string { func (*ServerError) ProtoMessage() {} func (x *ServerError) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[20] + mi := &file_noitu_v1_game_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1560,7 +1827,7 @@ func (x *ServerError) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerError.ProtoReflect.Descriptor instead. func (*ServerError) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{20} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{22} } func (x *ServerError) GetCode() string { @@ -1587,7 +1854,7 @@ type Pong struct { func (x *Pong) Reset() { *x = Pong{} - mi := &file_noitu_v1_game_proto_msgTypes[21] + mi := &file_noitu_v1_game_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1599,7 +1866,7 @@ func (x *Pong) String() string { func (*Pong) ProtoMessage() {} func (x *Pong) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[21] + mi := &file_noitu_v1_game_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1612,7 +1879,7 @@ func (x *Pong) ProtoReflect() protoreflect.Message { // Deprecated: Use Pong.ProtoReflect.Descriptor instead. func (*Pong) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{21} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{23} } func (x *Pong) GetClientTimeMs() int64 { @@ -1629,40 +1896,41 @@ func (x *Pong) GetServerTimeMs() int64 { return 0 } -// RoomState is the whole lobby, rendered for one recipient, and it is the only +// RoomState is the whole room, 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. +// see — a seat filled or freed, a readiness set, an owner promoted, somebody +// dropping or coming back — 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. +// It describes the room, not the game, so it is meaningful during one too: +// while a game runs this is what carries presence, which is why there is no +// separate message for a player disconnecting. type RoomState struct { state protoimpl.MessageState `protogen:"open.v1"` RoomCode string `protobuf:"bytes,1,opt,name=room_code,json=roomCode,proto3" json:"room_code,omitempty"` - // True for the player who may start the game and kick the other. - IAmOwner bool `protobuf:"varint,2,opt,name=i_am_owner,json=iAmOwner,proto3" json:"i_am_owner,omitempty"` // Whether StartGame would be accepted right now. The server decides this // because it owns every condition that feeds it. CanStart bool `protobuf:"varint,3,opt,name=can_start,json=canStart,proto3" json:"can_start,omitempty"` - // The recipient's own readiness. Always false for the owner, whose readiness - // is StartGame itself. - IAmReady bool `protobuf:"varint,4,opt,name=i_am_ready,json=iAmReady,proto3" json:"i_am_ready,omitempty"` - // False when the other seat is empty; the fields below are then meaningless. - OpponentPresent bool `protobuf:"varint,5,opt,name=opponent_present,json=opponentPresent,proto3" json:"opponent_present,omitempty"` - // Always server-sanitized, as everywhere else another player's name appears. - OpponentName string `protobuf:"bytes,6,opt,name=opponent_name,json=opponentName,proto3" json:"opponent_name,omitempty"` - OpponentReady bool `protobuf:"varint,7,opt,name=opponent_ready,json=opponentReady,proto3" json:"opponent_ready,omitempty"` - // False while the other player is inside their reconnect window. - OpponentConnected bool `protobuf:"varint,8,opt,name=opponent_connected,json=opponentConnected,proto3" json:"opponent_connected,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Everyone seated, in seat order, which is also the turn order a game will + // use. Always includes the recipient, marked is_me. + Players []*PlayerSlot `protobuf:"bytes,9,rep,name=players,proto3" json:"players,omitempty"` + // How many seats the room has and how many players a game needs. Sent + // rather than compiled in, so the lobby draws whatever the server allows and + // raising the limit does not need a client deploy. + MaxPlayers uint32 `protobuf:"varint,10,opt,name=max_players,json=maxPlayers,proto3" json:"max_players,omitempty"` + MinPlayers uint32 `protobuf:"varint,11,opt,name=min_players,json=minPlayers,proto3" json:"min_players,omitempty"` + // How long a seat is held for a player who has dropped. The client counts + // down against it for anybody whose connected is false; the server still + // decides when the seat is actually forfeit. + GraceMs uint32 `protobuf:"varint,12,opt,name=grace_ms,json=graceMs,proto3" json:"grace_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomState) Reset() { *x = RoomState{} - mi := &file_noitu_v1_game_proto_msgTypes[22] + mi := &file_noitu_v1_game_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1674,7 +1942,7 @@ func (x *RoomState) String() string { func (*RoomState) ProtoMessage() {} func (x *RoomState) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[22] + mi := &file_noitu_v1_game_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1687,7 +1955,7 @@ func (x *RoomState) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomState.ProtoReflect.Descriptor instead. func (*RoomState) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{22} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{24} } func (x *RoomState) GetRoomCode() string { @@ -1697,13 +1965,6 @@ func (x *RoomState) GetRoomCode() string { return "" } -func (x *RoomState) GetIAmOwner() bool { - if x != nil { - return x.IAmOwner - } - return false -} - func (x *RoomState) GetCanStart() bool { if x != nil { return x.CanStart @@ -1711,39 +1972,32 @@ func (x *RoomState) GetCanStart() bool { return false } -func (x *RoomState) GetIAmReady() bool { +func (x *RoomState) GetPlayers() []*PlayerSlot { if x != nil { - return x.IAmReady + return x.Players } - return false + return nil } -func (x *RoomState) GetOpponentPresent() bool { +func (x *RoomState) GetMaxPlayers() uint32 { if x != nil { - return x.OpponentPresent + return x.MaxPlayers } - return false + return 0 } -func (x *RoomState) GetOpponentName() string { +func (x *RoomState) GetMinPlayers() uint32 { if x != nil { - return x.OpponentName + return x.MinPlayers } - return "" + return 0 } -func (x *RoomState) GetOpponentReady() bool { +func (x *RoomState) GetGraceMs() uint32 { if x != nil { - return x.OpponentReady + return x.GraceMs } - return false -} - -func (x *RoomState) GetOpponentConnected() bool { - if x != nil { - return x.OpponentConnected - } - return false + return 0 } // ChatMessage is one line as one recipient sees it. Rendered per recipient @@ -1765,7 +2019,7 @@ type ChatMessage struct { func (x *ChatMessage) Reset() { *x = ChatMessage{} - mi := &file_noitu_v1_game_proto_msgTypes[23] + mi := &file_noitu_v1_game_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1777,7 +2031,7 @@ func (x *ChatMessage) String() string { func (*ChatMessage) ProtoMessage() {} func (x *ChatMessage) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[23] + mi := &file_noitu_v1_game_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1790,7 +2044,7 @@ func (x *ChatMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatMessage.ProtoReflect.Descriptor instead. func (*ChatMessage) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{23} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{25} } func (x *ChatMessage) GetFromMe() bool { @@ -1837,7 +2091,7 @@ type ChatHistory struct { func (x *ChatHistory) Reset() { *x = ChatHistory{} - mi := &file_noitu_v1_game_proto_msgTypes[24] + mi := &file_noitu_v1_game_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1849,7 +2103,7 @@ func (x *ChatHistory) String() string { func (*ChatHistory) ProtoMessage() {} func (x *ChatHistory) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[24] + mi := &file_noitu_v1_game_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1862,7 +2116,7 @@ func (x *ChatHistory) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatHistory.ProtoReflect.Descriptor instead. func (*ChatHistory) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{24} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{26} } func (x *ChatHistory) GetMessages() []*ChatMessage { @@ -1881,12 +2135,12 @@ type ServerMessage struct { // *ServerMessage_TurnUpdate // *ServerMessage_MoveRejected // *ServerMessage_GameOver - // *ServerMessage_OpponentLeft // *ServerMessage_Error // *ServerMessage_Pong // *ServerMessage_RoomState // *ServerMessage_ChatMessage // *ServerMessage_ChatHistory + // *ServerMessage_PlayerEliminated Payload isServerMessage_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1894,7 +2148,7 @@ type ServerMessage struct { func (x *ServerMessage) Reset() { *x = ServerMessage{} - mi := &file_noitu_v1_game_proto_msgTypes[25] + mi := &file_noitu_v1_game_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1906,7 +2160,7 @@ func (x *ServerMessage) String() string { func (*ServerMessage) ProtoMessage() {} func (x *ServerMessage) ProtoReflect() protoreflect.Message { - mi := &file_noitu_v1_game_proto_msgTypes[25] + mi := &file_noitu_v1_game_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1919,7 +2173,7 @@ func (x *ServerMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerMessage.ProtoReflect.Descriptor instead. func (*ServerMessage) Descriptor() ([]byte, []int) { - return file_noitu_v1_game_proto_rawDescGZIP(), []int{25} + return file_noitu_v1_game_proto_rawDescGZIP(), []int{27} } func (x *ServerMessage) GetPayload() isServerMessage_Payload { @@ -1974,15 +2228,6 @@ func (x *ServerMessage) GetGameOver() *GameOver { return nil } -func (x *ServerMessage) GetOpponentLeft() *OpponentLeft { - if x != nil { - if x, ok := x.Payload.(*ServerMessage_OpponentLeft); ok { - return x.OpponentLeft - } - } - return nil -} - func (x *ServerMessage) GetError() *ServerError { if x != nil { if x, ok := x.Payload.(*ServerMessage_Error); ok { @@ -2028,6 +2273,15 @@ func (x *ServerMessage) GetChatHistory() *ChatHistory { return nil } +func (x *ServerMessage) GetPlayerEliminated() *PlayerEliminated { + if x != nil { + if x, ok := x.Payload.(*ServerMessage_PlayerEliminated); ok { + return x.PlayerEliminated + } + } + return nil +} + type isServerMessage_Payload interface { isServerMessage_Payload() } @@ -2052,10 +2306,6 @@ type ServerMessage_GameOver struct { GameOver *GameOver `protobuf:"bytes,7,opt,name=game_over,json=gameOver,proto3,oneof"` } -type ServerMessage_OpponentLeft struct { - OpponentLeft *OpponentLeft `protobuf:"bytes,8,opt,name=opponent_left,json=opponentLeft,proto3,oneof"` -} - type ServerMessage_Error struct { Error *ServerError `protobuf:"bytes,9,opt,name=error,proto3,oneof"` } @@ -2076,6 +2326,10 @@ type ServerMessage_ChatHistory struct { ChatHistory *ChatHistory `protobuf:"bytes,14,opt,name=chat_history,json=chatHistory,proto3,oneof"` } +type ServerMessage_PlayerEliminated struct { + PlayerEliminated *PlayerEliminated `protobuf:"bytes,15,opt,name=player_eliminated,json=playerEliminated,proto3,oneof"` +} + func (*ServerMessage_Welcome) isServerMessage_Payload() {} func (*ServerMessage_GameStarted) isServerMessage_Payload() {} @@ -2086,8 +2340,6 @@ func (*ServerMessage_MoveRejected) isServerMessage_Payload() {} func (*ServerMessage_GameOver) isServerMessage_Payload() {} -func (*ServerMessage_OpponentLeft) isServerMessage_Payload() {} - func (*ServerMessage_Error) isServerMessage_Payload() {} func (*ServerMessage_Pong) isServerMessage_Payload() {} @@ -2098,6 +2350,8 @@ func (*ServerMessage_ChatMessage) isServerMessage_Payload() {} func (*ServerMessage_ChatHistory) isServerMessage_Payload() {} +func (*ServerMessage_PlayerEliminated) isServerMessage_Payload() {} + var File_noitu_v1_game_proto protoreflect.FileDescriptor const file_noitu_v1_game_proto_rawDesc = "" + @@ -2122,9 +2376,10 @@ const file_noitu_v1_game_proto_rawDesc = "" + "\x06Resign\" \n" + "\bSetReady\x12\x14\n" + "\x05ready\x18\x01 \x01(\bR\x05ready\"\v\n" + - "\tStartGame\"\f\n" + + "\tStartGame\")\n" + "\n" + - "KickPlayer\"\v\n" + + "KickPlayer\x12\x1b\n" + + "\tplayer_id\x18\x01 \x01(\tR\bplayerId\"\v\n" + "\tLeaveRoom\"\x1e\n" + "\bSendChat\x12\x12\n" + "\x04text\x18\x01 \x01(\tR\x04text\",\n" + @@ -2155,61 +2410,84 @@ const file_noitu_v1_game_proto_rawDesc = "" + "session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" + "\fresume_token\x18\x02 \x01(\tR\vresumeToken\x12)\n" + "\x10protocol_version\x18\x03 \x01(\rR\x0fprotocolVersion\x12+\n" + - "\x11accepted_nickname\x18\x04 \x01(\tR\x10acceptedNickname\"\x81\x01\n" + + "\x11accepted_nickname\x18\x04 \x01(\tR\x10acceptedNickname\"\x9e\x01\n" + "\n" + "PlayedWord\x12\x12\n" + "\x04word\x18\x01 \x01(\tR\x04word\x12\x13\n" + "\x05by_me\x18\x02 \x01(\bR\x04byMe\x12\x16\n" + "\x06points\x18\x03 \x01(\rR\x06points\x12\x1c\n" + "\tsyllables\x18\x04 \x01(\rR\tsyllables\x12\x14\n" + - "\x05typed\x18\x05 \x01(\tR\x05typed\"\xdd\x01\n" + + "\x05typed\x18\x05 \x01(\tR\x05typed\x12\x1b\n" + + "\tplayer_id\x18\x06 \x01(\tR\bplayerId\"\xa1\x01\n" + + "\n" + + "PlayerSlot\x12\x1b\n" + + "\tplayer_id\x18\x01 \x01(\tR\bplayerId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x13\n" + + "\x05is_me\x18\x03 \x01(\bR\x04isMe\x12\x19\n" + + "\bis_owner\x18\x04 \x01(\bR\aisOwner\x12\x14\n" + + "\x05ready\x18\x05 \x01(\bR\x05ready\x12\x1c\n" + + "\tconnected\x18\x06 \x01(\bR\tconnected\"\xbb\x01\n" + + "\vPlayerScore\x12\x1b\n" + + "\tplayer_id\x18\x01 \x01(\tR\bplayerId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x13\n" + + "\x05is_me\x18\x03 \x01(\bR\x04isMe\x12\x14\n" + + "\x05score\x18\x04 \x01(\rR\x05score\x12\x1e\n" + + "\n" + + "eliminated\x18\x05 \x01(\bR\n" + + "eliminated\x12\x1c\n" + + "\tconnected\x18\x06 \x01(\bR\tconnected\x12\x12\n" + + "\x04rank\x18\a \x01(\rR\x04rank\"\xb4\x02\n" + "\vGameStarted\x12!\n" + "\fopening_word\x18\x01 \x01(\tR\vopeningWord\x12)\n" + "\x10current_syllable\x18\x02 \x01(\tR\x0fcurrentSyllable\x12\x17\n" + "\amy_turn\x18\x03 \x01(\bR\x06myTurn\x12(\n" + "\x10deadline_unix_ms\x18\x04 \x01(\x03R\x0edeadlineUnixMs\x12\x19\n" + "\bturn_seq\x18\x05 \x01(\rR\aturnSeq\x12\"\n" + - "\rturn_limit_ms\x18\x06 \x01(\rR\vturnLimitMs\"\xa8\x02\n" + + "\rturn_limit_ms\x18\x06 \x01(\rR\vturnLimitMs\x12/\n" + + "\aplayers\x18\a \x03(\v2\x15.noitu.v1.PlayerScoreR\aplayers\x12$\n" + + "\x0eturn_player_id\x18\b \x01(\tR\fturnPlayerId\"\xc9\x02\n" + "\n" + "TurnUpdate\x12,\n" + "\x06played\x18\x01 \x01(\v2\x14.noitu.v1.PlayedWordR\x06played\x12)\n" + "\x10current_syllable\x18\x02 \x01(\tR\x0fcurrentSyllable\x12\x17\n" + "\amy_turn\x18\x03 \x01(\bR\x06myTurn\x12(\n" + "\x10deadline_unix_ms\x18\x04 \x01(\x03R\x0edeadlineUnixMs\x12\x19\n" + - "\bturn_seq\x18\x05 \x01(\rR\aturnSeq\x12\x19\n" + - "\bmy_score\x18\x06 \x01(\rR\amyScore\x12%\n" + - "\x0eopponent_score\x18\a \x01(\rR\ropponentScore\x12!\n" + - "\fchain_length\x18\b \x01(\rR\vchainLength\"m\n" + + "\bturn_seq\x18\x05 \x01(\rR\aturnSeq\x12!\n" + + "\fchain_length\x18\b \x01(\rR\vchainLength\x12/\n" + + "\aplayers\x18\t \x03(\v2\x15.noitu.v1.PlayerScoreR\aplayers\x12$\n" + + "\x0eturn_player_id\x18\n" + + " \x01(\tR\fturnPlayerIdJ\x04\b\x06\x10\aJ\x04\b\a\x10\b\"m\n" + "\fMoveRejected\x12.\n" + "\x06reason\x18\x01 \x01(\x0e2\x16.noitu.v1.RejectReasonR\x06reason\x12\x12\n" + "\x04word\x18\x02 \x01(\tR\x04word\x12\x19\n" + - "\bturn_seq\x18\x03 \x01(\rR\aturnSeq\"\xb0\x01\n" + + "\bturn_seq\x18\x03 \x01(\rR\aturnSeq\"\xb4\x01\n" + "\bGameOver\x12\x13\n" + "\x05i_won\x18\x01 \x01(\bR\x04iWon\x12/\n" + - "\x06reason\x18\x02 \x01(\x0e2\x17.noitu.v1.GameEndReasonR\x06reason\x12\x19\n" + - "\bmy_score\x18\x03 \x01(\rR\amyScore\x12!\n" + - "\fchain_length\x18\x04 \x01(\rR\vchainLength\x12 \n" + - "\vsuggestions\x18\x05 \x03(\tR\vsuggestions\"N\n" + - "\fOpponentLeft\x12#\n" + - "\rcan_reconnect\x18\x01 \x01(\bR\fcanReconnect\x12\x19\n" + - "\bgrace_ms\x18\x02 \x01(\rR\agraceMs\";\n" + + "\x06reason\x18\x02 \x01(\x0e2\x17.noitu.v1.GameEndReasonR\x06reason\x12!\n" + + "\fchain_length\x18\x04 \x01(\rR\vchainLength\x123\n" + + "\tstandings\x18\x06 \x03(\v2\x15.noitu.v1.PlayerScoreR\tstandingsJ\x04\b\x03\x10\x04J\x04\b\x05\x10\x06\"\xab\x01\n" + + "\x10PlayerEliminated\x12\x1b\n" + + "\tplayer_id\x18\x01 \x01(\tR\bplayerId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x13\n" + + "\x05is_me\x18\x03 \x01(\bR\x04isMe\x12/\n" + + "\x06reason\x18\x04 \x01(\x0e2\x17.noitu.v1.GameEndReasonR\x06reason\x12 \n" + + "\vsuggestions\x18\x05 \x03(\tR\vsuggestions\";\n" + "\vServerError\x12\x12\n" + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\"R\n" + "\x04Pong\x12$\n" + "\x0eclient_time_ms\x18\x01 \x01(\x03R\fclientTimeMs\x12$\n" + - "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xa7\x02\n" + + "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xf6\x01\n" + "\tRoomState\x12\x1b\n" + - "\troom_code\x18\x01 \x01(\tR\broomCode\x12\x1c\n" + - "\n" + - "i_am_owner\x18\x02 \x01(\bR\biAmOwner\x12\x1b\n" + - "\tcan_start\x18\x03 \x01(\bR\bcanStart\x12\x1c\n" + - "\n" + - "i_am_ready\x18\x04 \x01(\bR\biAmReady\x12)\n" + - "\x10opponent_present\x18\x05 \x01(\bR\x0fopponentPresent\x12#\n" + - "\ropponent_name\x18\x06 \x01(\tR\fopponentName\x12%\n" + - "\x0eopponent_ready\x18\a \x01(\bR\ropponentReady\x12-\n" + - "\x12opponent_connected\x18\b \x01(\bR\x11opponentConnected\"t\n" + + "\troom_code\x18\x01 \x01(\tR\broomCode\x12\x1b\n" + + "\tcan_start\x18\x03 \x01(\bR\bcanStart\x12.\n" + + "\aplayers\x18\t \x03(\v2\x14.noitu.v1.PlayerSlotR\aplayers\x12\x1f\n" + + "\vmax_players\x18\n" + + " \x01(\rR\n" + + "maxPlayers\x12\x1f\n" + + "\vmin_players\x18\v \x01(\rR\n" + + "minPlayers\x12\x19\n" + + "\bgrace_ms\x18\f \x01(\rR\agraceMsJ\x04\b\x02\x10\x03J\x04\b\x04\x10\x05J\x04\b\x05\x10\x06J\x04\b\x06\x10\aJ\x04\b\a\x10\bJ\x04\b\b\x10\t\"t\n" + "\vChatMessage\x12\x17\n" + "\afrom_me\x18\x01 \x01(\bR\x06fromMe\x12\x16\n" + "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + @@ -2217,23 +2495,23 @@ const file_noitu_v1_game_proto_rawDesc = "" + "\fsent_unix_ms\x18\x04 \x01(\x03R\n" + "sentUnixMs\"@\n" + "\vChatHistory\x121\n" + - "\bmessages\x18\x01 \x03(\v2\x15.noitu.v1.ChatMessageR\bmessages\"\x84\x05\n" + + "\bmessages\x18\x01 \x03(\v2\x15.noitu.v1.ChatMessageR\bmessages\"\x96\x05\n" + "\rServerMessage\x12-\n" + "\awelcome\x18\x01 \x01(\v2\x11.noitu.v1.WelcomeH\x00R\awelcome\x12:\n" + "\fgame_started\x18\x04 \x01(\v2\x15.noitu.v1.GameStartedH\x00R\vgameStarted\x127\n" + "\vturn_update\x18\x05 \x01(\v2\x14.noitu.v1.TurnUpdateH\x00R\n" + "turnUpdate\x12=\n" + "\rmove_rejected\x18\x06 \x01(\v2\x16.noitu.v1.MoveRejectedH\x00R\fmoveRejected\x121\n" + - "\tgame_over\x18\a \x01(\v2\x12.noitu.v1.GameOverH\x00R\bgameOver\x12=\n" + - "\ropponent_left\x18\b \x01(\v2\x16.noitu.v1.OpponentLeftH\x00R\fopponentLeft\x12-\n" + + "\tgame_over\x18\a \x01(\v2\x12.noitu.v1.GameOverH\x00R\bgameOver\x12-\n" + "\x05error\x18\t \x01(\v2\x15.noitu.v1.ServerErrorH\x00R\x05error\x12$\n" + "\x04pong\x18\n" + " \x01(\v2\x0e.noitu.v1.PongH\x00R\x04pong\x124\n" + "\n" + "room_state\x18\f \x01(\v2\x13.noitu.v1.RoomStateH\x00R\troomState\x12:\n" + "\fchat_message\x18\r \x01(\v2\x15.noitu.v1.ChatMessageH\x00R\vchatMessage\x12:\n" + - "\fchat_history\x18\x0e \x01(\v2\x15.noitu.v1.ChatHistoryH\x00R\vchatHistoryB\t\n" + - "\apayloadJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\v\x10\f*i\n" + + "\fchat_history\x18\x0e \x01(\v2\x15.noitu.v1.ChatHistoryH\x00R\vchatHistory\x12I\n" + + "\x11player_eliminated\x18\x0f \x01(\v2\x1a.noitu.v1.PlayerEliminatedH\x00R\x10playerEliminatedB\t\n" + + "\apayloadJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\b\x10\tJ\x04\b\v\x10\f*i\n" + "\n" + "Difficulty\x12\x1a\n" + "\x16DIFFICULTY_UNSPECIFIED\x10\x00\x12\x13\n" + @@ -2269,37 +2547,39 @@ func file_noitu_v1_game_proto_rawDescGZIP() []byte { } var file_noitu_v1_game_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_noitu_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_noitu_v1_game_proto_msgTypes = make([]protoimpl.MessageInfo, 28) var file_noitu_v1_game_proto_goTypes = []any{ - (Difficulty)(0), // 0: noitu.v1.Difficulty - (RejectReason)(0), // 1: noitu.v1.RejectReason - (GameEndReason)(0), // 2: noitu.v1.GameEndReason - (*Hello)(nil), // 3: noitu.v1.Hello - (*StartBotGame)(nil), // 4: noitu.v1.StartBotGame - (*CreateRoom)(nil), // 5: noitu.v1.CreateRoom - (*JoinRoom)(nil), // 6: noitu.v1.JoinRoom - (*SubmitWord)(nil), // 7: noitu.v1.SubmitWord - (*Resign)(nil), // 8: noitu.v1.Resign - (*SetReady)(nil), // 9: noitu.v1.SetReady - (*StartGame)(nil), // 10: noitu.v1.StartGame - (*KickPlayer)(nil), // 11: noitu.v1.KickPlayer - (*LeaveRoom)(nil), // 12: noitu.v1.LeaveRoom - (*SendChat)(nil), // 13: noitu.v1.SendChat - (*Ping)(nil), // 14: noitu.v1.Ping - (*ClientMessage)(nil), // 15: noitu.v1.ClientMessage - (*Welcome)(nil), // 16: noitu.v1.Welcome - (*PlayedWord)(nil), // 17: noitu.v1.PlayedWord - (*GameStarted)(nil), // 18: noitu.v1.GameStarted - (*TurnUpdate)(nil), // 19: noitu.v1.TurnUpdate - (*MoveRejected)(nil), // 20: noitu.v1.MoveRejected - (*GameOver)(nil), // 21: noitu.v1.GameOver - (*OpponentLeft)(nil), // 22: noitu.v1.OpponentLeft - (*ServerError)(nil), // 23: noitu.v1.ServerError - (*Pong)(nil), // 24: noitu.v1.Pong - (*RoomState)(nil), // 25: noitu.v1.RoomState - (*ChatMessage)(nil), // 26: noitu.v1.ChatMessage - (*ChatHistory)(nil), // 27: noitu.v1.ChatHistory - (*ServerMessage)(nil), // 28: noitu.v1.ServerMessage + (Difficulty)(0), // 0: noitu.v1.Difficulty + (RejectReason)(0), // 1: noitu.v1.RejectReason + (GameEndReason)(0), // 2: noitu.v1.GameEndReason + (*Hello)(nil), // 3: noitu.v1.Hello + (*StartBotGame)(nil), // 4: noitu.v1.StartBotGame + (*CreateRoom)(nil), // 5: noitu.v1.CreateRoom + (*JoinRoom)(nil), // 6: noitu.v1.JoinRoom + (*SubmitWord)(nil), // 7: noitu.v1.SubmitWord + (*Resign)(nil), // 8: noitu.v1.Resign + (*SetReady)(nil), // 9: noitu.v1.SetReady + (*StartGame)(nil), // 10: noitu.v1.StartGame + (*KickPlayer)(nil), // 11: noitu.v1.KickPlayer + (*LeaveRoom)(nil), // 12: noitu.v1.LeaveRoom + (*SendChat)(nil), // 13: noitu.v1.SendChat + (*Ping)(nil), // 14: noitu.v1.Ping + (*ClientMessage)(nil), // 15: noitu.v1.ClientMessage + (*Welcome)(nil), // 16: noitu.v1.Welcome + (*PlayedWord)(nil), // 17: noitu.v1.PlayedWord + (*PlayerSlot)(nil), // 18: noitu.v1.PlayerSlot + (*PlayerScore)(nil), // 19: noitu.v1.PlayerScore + (*GameStarted)(nil), // 20: noitu.v1.GameStarted + (*TurnUpdate)(nil), // 21: noitu.v1.TurnUpdate + (*MoveRejected)(nil), // 22: noitu.v1.MoveRejected + (*GameOver)(nil), // 23: noitu.v1.GameOver + (*PlayerEliminated)(nil), // 24: noitu.v1.PlayerEliminated + (*ServerError)(nil), // 25: noitu.v1.ServerError + (*Pong)(nil), // 26: noitu.v1.Pong + (*RoomState)(nil), // 27: noitu.v1.RoomState + (*ChatMessage)(nil), // 28: noitu.v1.ChatMessage + (*ChatHistory)(nil), // 29: noitu.v1.ChatHistory + (*ServerMessage)(nil), // 30: noitu.v1.ServerMessage } var file_noitu_v1_game_proto_depIdxs = []int32{ 0, // 0: noitu.v1.StartBotGame.difficulty:type_name -> noitu.v1.Difficulty @@ -2315,26 +2595,31 @@ var file_noitu_v1_game_proto_depIdxs = []int32{ 11, // 10: noitu.v1.ClientMessage.kick_player:type_name -> noitu.v1.KickPlayer 12, // 11: noitu.v1.ClientMessage.leave_room:type_name -> noitu.v1.LeaveRoom 13, // 12: noitu.v1.ClientMessage.send_chat:type_name -> noitu.v1.SendChat - 17, // 13: noitu.v1.TurnUpdate.played:type_name -> noitu.v1.PlayedWord - 1, // 14: noitu.v1.MoveRejected.reason:type_name -> noitu.v1.RejectReason - 2, // 15: noitu.v1.GameOver.reason:type_name -> noitu.v1.GameEndReason - 26, // 16: noitu.v1.ChatHistory.messages:type_name -> noitu.v1.ChatMessage - 16, // 17: noitu.v1.ServerMessage.welcome:type_name -> noitu.v1.Welcome - 18, // 18: noitu.v1.ServerMessage.game_started:type_name -> noitu.v1.GameStarted - 19, // 19: noitu.v1.ServerMessage.turn_update:type_name -> noitu.v1.TurnUpdate - 20, // 20: noitu.v1.ServerMessage.move_rejected:type_name -> noitu.v1.MoveRejected - 21, // 21: noitu.v1.ServerMessage.game_over:type_name -> noitu.v1.GameOver - 22, // 22: noitu.v1.ServerMessage.opponent_left:type_name -> noitu.v1.OpponentLeft - 23, // 23: noitu.v1.ServerMessage.error:type_name -> noitu.v1.ServerError - 24, // 24: noitu.v1.ServerMessage.pong:type_name -> noitu.v1.Pong - 25, // 25: noitu.v1.ServerMessage.room_state:type_name -> noitu.v1.RoomState - 26, // 26: noitu.v1.ServerMessage.chat_message:type_name -> noitu.v1.ChatMessage - 27, // 27: noitu.v1.ServerMessage.chat_history:type_name -> noitu.v1.ChatHistory - 28, // [28:28] is the sub-list for method output_type - 28, // [28:28] is the sub-list for method input_type - 28, // [28:28] is the sub-list for extension type_name - 28, // [28:28] is the sub-list for extension extendee - 0, // [0:28] is the sub-list for field type_name + 19, // 13: noitu.v1.GameStarted.players:type_name -> noitu.v1.PlayerScore + 17, // 14: noitu.v1.TurnUpdate.played:type_name -> noitu.v1.PlayedWord + 19, // 15: noitu.v1.TurnUpdate.players:type_name -> noitu.v1.PlayerScore + 1, // 16: noitu.v1.MoveRejected.reason:type_name -> noitu.v1.RejectReason + 2, // 17: noitu.v1.GameOver.reason:type_name -> noitu.v1.GameEndReason + 19, // 18: noitu.v1.GameOver.standings:type_name -> noitu.v1.PlayerScore + 2, // 19: noitu.v1.PlayerEliminated.reason:type_name -> noitu.v1.GameEndReason + 18, // 20: noitu.v1.RoomState.players:type_name -> noitu.v1.PlayerSlot + 28, // 21: noitu.v1.ChatHistory.messages:type_name -> noitu.v1.ChatMessage + 16, // 22: noitu.v1.ServerMessage.welcome:type_name -> noitu.v1.Welcome + 20, // 23: noitu.v1.ServerMessage.game_started:type_name -> noitu.v1.GameStarted + 21, // 24: noitu.v1.ServerMessage.turn_update:type_name -> noitu.v1.TurnUpdate + 22, // 25: noitu.v1.ServerMessage.move_rejected:type_name -> noitu.v1.MoveRejected + 23, // 26: noitu.v1.ServerMessage.game_over:type_name -> noitu.v1.GameOver + 25, // 27: noitu.v1.ServerMessage.error:type_name -> noitu.v1.ServerError + 26, // 28: noitu.v1.ServerMessage.pong:type_name -> noitu.v1.Pong + 27, // 29: noitu.v1.ServerMessage.room_state:type_name -> noitu.v1.RoomState + 28, // 30: noitu.v1.ServerMessage.chat_message:type_name -> noitu.v1.ChatMessage + 29, // 31: noitu.v1.ServerMessage.chat_history:type_name -> noitu.v1.ChatHistory + 24, // 32: noitu.v1.ServerMessage.player_eliminated:type_name -> noitu.v1.PlayerEliminated + 33, // [33:33] is the sub-list for method output_type + 33, // [33:33] is the sub-list for method input_type + 33, // [33:33] is the sub-list for extension type_name + 33, // [33:33] is the sub-list for extension extendee + 0, // [0:33] is the sub-list for field type_name } func init() { file_noitu_v1_game_proto_init() } @@ -2356,18 +2641,18 @@ func file_noitu_v1_game_proto_init() { (*ClientMessage_LeaveRoom)(nil), (*ClientMessage_SendChat)(nil), } - file_noitu_v1_game_proto_msgTypes[25].OneofWrappers = []any{ + file_noitu_v1_game_proto_msgTypes[27].OneofWrappers = []any{ (*ServerMessage_Welcome)(nil), (*ServerMessage_GameStarted)(nil), (*ServerMessage_TurnUpdate)(nil), (*ServerMessage_MoveRejected)(nil), (*ServerMessage_GameOver)(nil), - (*ServerMessage_OpponentLeft)(nil), (*ServerMessage_Error)(nil), (*ServerMessage_Pong)(nil), (*ServerMessage_RoomState)(nil), (*ServerMessage_ChatMessage)(nil), (*ServerMessage_ChatHistory)(nil), + (*ServerMessage_PlayerEliminated)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -2375,7 +2660,7 @@ func file_noitu_v1_game_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_noitu_v1_game_proto_rawDesc), len(file_noitu_v1_game_proto_rawDesc)), NumEnums: 3, - NumMessages: 26, + NumMessages: 28, NumExtensions: 0, NumServices: 0, }, diff --git a/server/internal/bot/realcorpus_test.go b/server/internal/bot/realcorpus_test.go index 672fcf4..f932ec9 100644 --- a/server/internal/bot/realcorpus_test.go +++ b/server/internal/bot/realcorpus_test.go @@ -61,7 +61,7 @@ func playRealGame(tb testing.TB, dict game.Dictionary, first, second Strategy, s // The dead end the bot walked into. The engine leaves a stuck // player their turn, so the harness settles it the way the room // settles a bot's: immediately, against the player to act. - if !e.NoMove() { + if !e.NoMove(time.Now()) { tb.Fatalf("bot had no move at %q but the engine says the position has one", e.Current()) } break diff --git a/server/internal/bot/simulate_test.go b/server/internal/bot/simulate_test.go index 6fd2196..057d622 100644 --- a/server/internal/bot/simulate_test.go +++ b/server/internal/bot/simulate_test.go @@ -119,7 +119,7 @@ func playGame(t *testing.T, dict game.Dictionary, first, second Strategy) game.P // A dead end no longer ends the game on its own: a human keeps the // turn and loses it to the clock. Two bots have no clock, so this // is where the position is settled -- exactly as the room does it. - if !e.NoMove() { + if !e.NoMove(time.Now()) { t.Fatalf("bot had no move at %q but the engine says the position has one", e.Current()) } break diff --git a/server/internal/game/engine.go b/server/internal/game/engine.go index 13ba619..493387e 100644 --- a/server/internal/game/engine.go +++ b/server/internal/game/engine.go @@ -26,14 +26,29 @@ const ( maxPointsPerWord = 100 ) -// Engine holds one game. +// Engine holds one game of two or more players. +// +// A player who fails their turn is eliminated and the rest play on from the +// same syllable; the game ends when one of them is left. Two seats is that +// same rule seen from close up, which is why there is one implementation of it +// and not two. // // Not safe for concurrent use. Exactly one goroutine owns an Engine — in the // server that is the room goroutine, which serializes every input through a // single channel. type Engine struct { - dict Dictionary - players []PlayerID + dict Dictionary + players []PlayerID + // alive is parallel to players. Eliminating somebody clears their flag + // rather than dropping them from the slice: their score, their words and + // their place in turn order all have to survive them. + alive []bool + aliveN int + // outOrder is who went out, first out first, and outReason is why. Between + // them they are the whole final table — a rank is a position in this list + // read backwards, so nothing has to be recomputed to report one. + outOrder []PlayerID + outReason map[PlayerID]EndReason used map[string]struct{} current string turnIndex int @@ -81,13 +96,17 @@ func New(dict Dictionary, players []PlayerID, opening string, turnLimit time.Dur e := &Engine{ dict: dict, players: append([]PlayerID{}, players...), + alive: make([]bool, len(players)), + aliveN: len(players), + outReason: make(map[PlayerID]EndReason, len(players)), used: map[string]struct{}{canonical: {}}, current: last, turnLimit: turnLimit, deadline: now.Add(turnLimit), scores: make(map[PlayerID]int, len(players)), } - for _, p := range players { + for i, p := range players { + e.alive[i] = true e.scores[p] = 0 } @@ -106,9 +125,22 @@ func New(dict Dictionary, players []PlayerID, opening string, turnLimit time.Dur // the same word graph that Submit validates against. func (e *Engine) Dict() Dictionary { return e.dict } -// Turn reports whose move it is. +// Turn reports whose move it is. Always somebody still in the game, and once +// the last elimination has landed it is the winner. func (e *Engine) Turn() PlayerID { return e.players[e.turnIndex] } +// Players reports the seats in turn order, eliminated ones included. +func (e *Engine) Players() []PlayerID { return append([]PlayerID{}, e.players...) } + +// Alive reports whether a player is still in the game. +func (e *Engine) Alive(p PlayerID) bool { + i := e.indexOf(p) + return i >= 0 && e.alive[i] +} + +// Score reports one player's points. +func (e *Engine) Score(p PlayerID) int { return e.scores[p] } + // Current reports the syllable the next word must start with. func (e *Engine) Current() string { return e.current } @@ -145,7 +177,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas return Move{}, ReasonNotYourTurn } if e.IsExpired(now) { - e.expire() + e.expire(now) return Move{}, ReasonTimeout } @@ -191,7 +223,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas e.history = append(e.history, move) e.scores[p] += move.Points e.current = last - e.turnIndex = (e.turnIndex + 1) % len(e.players) + e.advance() e.deadline = now.Add(e.turnLimit) // A dead end is deliberately not the end of the game. Ending it here would @@ -255,67 +287,162 @@ func (e *Engine) IsExpired(now time.Time) bool { return now.After(e.deadline) } -// Timeout ends the game against the player whose turn expired. The caller -// drives this from its own timer; the engine never reads the clock itself. +// Timeout eliminates the player whose turn expired. The caller drives this +// from its own timer; the engine never reads the clock itself. +// +// It reports that something happened, not that the game ended: past two seats +// a timeout usually just moves the turn on. Over answers the other question. func (e *Engine) Timeout(now time.Time) bool { if e.over || !e.IsExpired(now) { return false } - e.expire() + e.expire(now) return true } -// NoMove ends the game against the player to act when the position leaves -// them nothing to play, without waiting for their clock to run out. +// NoMove eliminates the player to act when the position leaves them nothing to +// play, without waiting for their clock to run out. // // Only for a player who has no clock to wait for — the bot answers the moment // it has searched, and making it sit out a turn limit it cannot use would // stall the room. A human keeps their turn: see Submit. -func (e *Engine) NoMove() bool { +func (e *Engine) NoMove(now time.Time) bool { if e.over || e.HasLegalMove() { return false } - e.finish(e.opponentOf(e.Turn()), EndNoLegalMove) + e.expire(now) return true } -// expire ends the game against the player to act, whose turn has run out. +// expire ends the current turn against the player holding it. // // A player who never had a word to play did not run out of thinking time: // there was nothing to think about, and reporting a timeout would blame them // for a position nobody could have answered. -func (e *Engine) expire() { +func (e *Engine) expire(now time.Time) { reason := EndTimeout if !e.HasLegalMove() { reason = EndNoLegalMove } - e.finish(e.opponentOf(e.Turn()), reason) + e.eliminate(e.Turn(), reason) + e.settle() + if !e.over { + e.deadline = now.Add(e.turnLimit) + } } -// Resign ends the game against the player who gave up. -func (e *Engine) Resign(p PlayerID) bool { +// settle clears out everybody a dead end leaves with nothing. +// +// The first player to face one still loses it on their own clock — they get +// their turn, for the reason Submit gives. Everyone behind them has already +// seen that board, so making each of them sit out a full turn limit they +// cannot use would add minutes of nothing to a game that is already decided. +// Going out together instead leaves the player who closed the position +// standing, which is exactly what two players get. +func (e *Engine) settle() { + for !e.over && !e.HasLegalMove() { + e.eliminate(e.Turn(), EndNoLegalMove) + } +} + +// Resign eliminates the player who gave up. +// +// It works out of turn: past two seats a player may want out while somebody +// else is thinking, and holding them to a turn they have already given up on +// is not a rule worth having. The clock restarts only when the resignation +// actually moved the turn on, so leaving out of turn cannot hand the player to +// act more time than they had. +func (e *Engine) Resign(p PlayerID, now time.Time) bool { if e.over { return false } - e.finish(e.opponentOf(p), EndResigned) + before := e.Turn() + if !e.eliminate(p, EndResigned) { + return false + } + e.settle() + if !e.over && e.Turn() != before { + e.deadline = now.Add(e.turnLimit) + } return true } -func (e *Engine) finish(winner PlayerID, reason EndReason) { - e.over = true - e.winner = winner +// eliminate takes one player out and ends the game when one is left. +// +// The seat stays in players. An eliminated player keeps their score and the +// words they played, and the transport layer still has them to render — being +// out of the game is not being out of the room. +func (e *Engine) eliminate(p PlayerID, reason EndReason) bool { + i := e.indexOf(p) + if i < 0 || !e.alive[i] { + return false + } + + e.alive[i] = false + e.aliveN-- + e.outOrder = append(e.outOrder, p) + e.outReason[p] = reason + // The game-level reason is the latest elimination's, which with two seats + // is the only one there ever was. e.endReason = reason + + if e.turnIndex == i { + e.advance() + } + if e.aliveN <= 1 { + e.over = true + e.winner = e.players[e.turnIndex] + } + return true } -// opponentOf returns the other player. With more than two seats it returns the -// next one, which keeps the two-player case exact and the rest sane. -func (e *Engine) opponentOf(p PlayerID) PlayerID { - for i, candidate := range e.players { - if candidate == p { - return e.players[(i+1)%len(e.players)] +// advance moves the turn to the next player still in the game. +func (e *Engine) advance() { + for range e.players { + e.turnIndex = (e.turnIndex + 1) % len(e.players) + if e.alive[e.turnIndex] { + return } } - return p +} + +func (e *Engine) indexOf(p PlayerID) int { + for i, candidate := range e.players { + if candidate == p { + return i + } + } + return -1 +} + +// EliminatedCount is how many players have gone out. A caller that remembers +// it across an input can tell exactly who that input knocked out. +func (e *Engine) EliminatedCount() int { return len(e.outOrder) } + +// OutReason reports how a player left the game, and EndNone for one who has +// not. The transport layer needs it per player: with several seats, "why the +// game ended" and "why this player went out" stop being the same question. +func (e *Engine) OutReason(p PlayerID) EndReason { return e.outReason[p] } + +// Standings is the final table, best first. Meaningless while the game is in +// play, for the same reason Winner is. +func (e *Engine) Standings() []Standing { + out := make([]Standing, 0, len(e.players)) + if e.winner != "" { + out = append(out, Standing{Player: e.winner, Score: e.scores[e.winner], Rank: 1, Reason: EndNone}) + } + // Read backwards: outlasting somebody is what beats them, so of the players + // who went out the last one to go placed highest. + for i := len(e.outOrder) - 1; i >= 0; i-- { + p := e.outOrder[i] + out = append(out, Standing{ + Player: p, + Score: e.scores[p], + Rank: len(out) + 1, + Reason: e.outReason[p], + }) + } + return out } // Used reports whether a canonical word has already been played. @@ -331,15 +458,23 @@ func (e *Engine) Snapshot() State { scores[p] = s } + alive := make(map[PlayerID]bool, len(e.players)) + for i, p := range e.players { + alive[p] = e.alive[i] + } + return State{ Current: e.current, Turn: e.Turn(), Deadline: e.deadline, History: append([]Move{}, e.history...), Scores: scores, + Alive: alive, + Eliminated: append([]PlayerID{}, e.outOrder...), ChainLength: e.ChainLength(), Over: e.over, Winner: e.winner, EndReason: e.endReason, + Standings: e.Standings(), } } diff --git a/server/internal/game/engine_test.go b/server/internal/game/engine_test.go index e698e85..725cf57 100644 --- a/server/internal/game/engine_test.go +++ b/server/internal/game/engine_test.go @@ -353,7 +353,7 @@ func TestDeadEndEndsOnTheClockAsNoLegalMove(t *testing.T) { func TestNoMoveEndsADeadEndImmediately(t *testing.T) { e := deadEndGame(t) - if !e.NoMove() { + if !e.NoMove(t0) { t.Fatal("NoMove = false in a position with nothing to play") } if e.Winner() != bob { @@ -363,7 +363,7 @@ func TestNoMoveEndsADeadEndImmediately(t *testing.T) { t.Errorf("EndReason = %s, want %s", got, EndNoLegalMove) } - if e.NoMove() { + if e.NoMove(t0) { t.Error("NoMove ended an already finished game a second time") } } @@ -373,7 +373,7 @@ func TestNoMoveEndsADeadEndImmediately(t *testing.T) { func TestNoMoveRefusesAPlayablePosition(t *testing.T) { e := newGame(t, standardDict(), "ngôn ngữ") - if e.NoMove() { + if e.NoMove(t0) { t.Error("NoMove = true with legal moves available") } if e.Over() { @@ -461,7 +461,7 @@ func TestTimeoutAwardsOpponent(t *testing.T) { func TestResign(t *testing.T) { e := newGame(t, standardDict(), "ngôn ngữ") - if !e.Resign(alice) { + if !e.Resign(alice, t0) { t.Fatal("Resign returned false") } if e.Winner() != bob { @@ -470,7 +470,7 @@ func TestResign(t *testing.T) { if e.Snapshot().EndReason != EndResigned { t.Errorf("EndReason = %s, want %s", e.Snapshot().EndReason, EndResigned) } - if e.Resign(bob) { + if e.Resign(bob, t0) { t.Error("Resign succeeded on a finished game") } } @@ -479,7 +479,7 @@ func TestResign(t *testing.T) { // player as copy. func TestSubmitAfterGameOver(t *testing.T) { e := newGame(t, standardDict(), "ngôn ngữ") - e.Resign(alice) + e.Resign(alice, t0) if _, r := e.Submit(bob, "ngữ pháp", t0); r != ReasonGameOver { t.Errorf("Submit after the game ended = %s, want %s", r, ReasonGameOver) diff --git a/server/internal/game/multiplayer_test.go b/server/internal/game/multiplayer_test.go new file mode 100644 index 0000000..ff9b421 --- /dev/null +++ b/server/internal/game/multiplayer_test.go @@ -0,0 +1,235 @@ +package game + +import ( + "slices" + "testing" + "time" +) + +// The rules that only exist past two seats: a failed turn takes one player out +// rather than ending the game, the rest carry on from the same syllable, and +// the last one standing wins. + +const ( + carol = PlayerID("carol") + dave = PlayerID("dave") +) + +// fourPlayers opens a game on a chain long enough that nobody runs out of words +// by accident. +func fourPlayers(t *testing.T) *Engine { + t.Helper() + e, err := New(standardDict(), []PlayerID{alice, bob, carol, dave}, "ngôn ngữ", 20*time.Second, t0) + if err != nil { + t.Fatalf("New: %v", err) + } + return e +} + +func TestTimeoutEliminatesAndPlaysOn(t *testing.T) { + e := fourPlayers(t) + syllable := e.Current() + + if !e.Timeout(t0.Add(21 * time.Second)) { + t.Fatal("Timeout did not fire after the deadline") + } + + if e.Over() { + t.Error("a four-player game ended on the first timeout") + } + if e.Alive(alice) { + t.Error("the player whose turn expired is still in the game") + } + if e.Turn() != bob { + t.Errorf("Turn = %q, want %q", e.Turn(), bob) + } + // The position survives the player: whoever inherits the turn answers the + // same syllable, against the same used set. + if e.Current() != syllable { + t.Errorf("Current = %q, want %q — an elimination is not a move", e.Current(), syllable) + } + // And they get a full turn to do it in, rather than the remains of one + // somebody else spent. + if want := t0.Add(41 * time.Second); !e.Deadline().Equal(want) { + t.Errorf("Deadline = %v, want %v", e.Deadline(), want) + } +} + +func TestTheLastPlayerStandingWins(t *testing.T) { + e := fourPlayers(t) + + // Three timeouts, each a turn limit after the last. + for i, out := range []PlayerID{alice, bob, carol} { + at := t0.Add(time.Duration(i+1) * 21 * time.Second) + if !e.Timeout(at) { + t.Fatalf("timeout %d did not fire", i) + } + if e.Alive(out) { + t.Errorf("%q survived their own timeout", out) + } + } + + if !e.Over() { + t.Fatal("the game did not end with one player left") + } + if e.Winner() != dave { + t.Errorf("Winner = %q, want %q", e.Winner(), dave) + } +} + +// Standings rank by who outlasted whom. A player who scored more and went out +// earlier still places below one who was there at the end. +func TestStandingsRankByFinishingOrder(t *testing.T) { + e := fourPlayers(t) + + // Alice banks a word before losing her next turn, so she outscores + // everybody and still finishes last. + if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone { + t.Fatalf("Submit: %s", r) + } + for i := range 3 { + if !e.Timeout(t0.Add(time.Duration(i+1) * 21 * time.Second)) { + t.Fatalf("timeout %d did not fire", i) + } + } + + got := e.Standings() + if len(got) != 4 { + t.Fatalf("Standings has %d rows, want 4", len(got)) + } + // bob, carol, dave time out in that order, leaving alice. + want := []PlayerID{alice, dave, carol, bob} + for i, standing := range got { + if standing.Player != want[i] { + t.Errorf("rank %d is %q, want %q", i+1, standing.Player, want[i]) + } + if standing.Rank != i+1 { + t.Errorf("%q has rank %d at position %d", standing.Player, standing.Rank, i+1) + } + } + if got[0].Reason != EndNone { + t.Errorf("the winner left the game for reason %s, want %s", got[0].Reason, EndNone) + } + if got[1].Reason != EndTimeout { + t.Errorf("an eliminated player's reason = %s, want %s", got[1].Reason, EndTimeout) + } +} + +// A dead end must not cost every remaining player a full turn clock each. The +// first one to face it loses it on their own time, as in a two-player game; +// everybody behind them has already seen that board and goes out with them, +// which leaves the player who closed the position standing. +func TestADeadEndSettlesInOneTurnNotThree(t *testing.T) { + d := newDict("a b", "b c", "c d") + e, err := New(d, []PlayerID{alice, bob, carol, dave}, "a b", 20*time.Second, t0) + if err != nil { + t.Fatalf("New: %v", err) + } + + // a b -> b c -> c d, and "d" starts nothing. + if _, r := e.Submit(alice, "b c", t0); r != ReasonNone { + t.Fatalf("Submit b c: %s", r) + } + if _, r := e.Submit(bob, "c d", t0); r != ReasonNone { + t.Fatalf("Submit c d: %s", r) + } + + // Carol is on turn with nothing to play, and loses it to the clock. + if !e.Timeout(t0.Add(21 * time.Second)) { + t.Fatal("Timeout did not fire") + } + + if !e.Over() { + t.Fatal("a dead end left the game running with nobody able to answer it") + } + if e.Winner() != bob { + t.Errorf("Winner = %q, want %q — the player who closed the position", e.Winner(), bob) + } + // Carol was the one on the clock. Dave and Alice never got a turn they + // could have used, and go out with her rather than each spending one. + if got := e.Snapshot().Eliminated; !slices.Equal(got, []PlayerID{carol, dave, alice}) { + t.Errorf("Eliminated = %v, want [carol dave alice]", got) + } + if got := e.OutReason(dave); got != EndNoLegalMove { + t.Errorf("dave went out for %s, want %s", got, EndNoLegalMove) + } +} + +// Past two seats a player may want out while somebody else is thinking, and +// holding them to a turn they have already given up on is not a rule worth +// having. +func TestResignOutOfTurnLeavesTheClockAlone(t *testing.T) { + e := fourPlayers(t) + deadline := e.Deadline() + + if !e.Resign(carol, t0.Add(5*time.Second)) { + t.Fatal("Resign out of turn returned false") + } + + if e.Over() { + t.Error("one player leaving ended a four-player game") + } + if e.Alive(carol) { + t.Error("the resigning player is still in the game") + } + if e.Turn() != alice { + t.Errorf("Turn = %q, want %q — resigning out of turn moved the turn", e.Turn(), alice) + } + if !e.Deadline().Equal(deadline) { + t.Error("resigning out of turn handed the player to act more time") + } + + // And the turn skips the empty seat when it comes round. + if _, r := e.Submit(alice, "ngữ pháp", t0.Add(6*time.Second)); r != ReasonNone { + t.Fatalf("Submit: %s", r) + } + if e.Turn() != bob { + t.Fatalf("Turn = %q, want %q", e.Turn(), bob) + } + if _, r := e.Submit(bob, "pháp luật", t0.Add(7*time.Second)); r != ReasonNone { + t.Fatalf("Submit: %s", r) + } + if e.Turn() != dave { + t.Errorf("Turn = %q, want %q — the turn stopped at an eliminated seat", e.Turn(), dave) + } +} + +// An eliminated player keeps their score and their words. Being out of the game +// is not being out of the record of it. +func TestAnEliminatedPlayerKeepsTheirScore(t *testing.T) { + e := fourPlayers(t) + + if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone { + t.Fatalf("Submit: %s", r) + } + scored := e.Score(alice) + if scored == 0 { + t.Fatal("the move scored nothing") + } + + e.Resign(alice, t0.Add(time.Second)) + + if got := e.Score(alice); got != scored { + t.Errorf("score after elimination = %d, want %d", got, scored) + } + if state := e.Snapshot(); len(state.History) != 1 || state.History[0].Player != alice { + t.Errorf("the eliminated player's move is missing from the history: %+v", state.History) + } + if !slices.Contains(e.Players(), alice) { + t.Error("Players dropped the eliminated seat, which the transport still has to render") + } +} + +// A submission from a player who is out is refused as a turn error, not +// silently applied to whoever is actually on turn. +func TestAnEliminatedPlayerCannotMove(t *testing.T) { + e := fourPlayers(t) + e.Resign(carol, t0) + + if _, r := e.Submit(carol, "ngữ pháp", t0.Add(time.Second)); r != ReasonNotYourTurn { + t.Errorf("Submit from an eliminated player = %s, want %s", r, ReasonNotYourTurn) + } + if e.Resign(carol, t0.Add(time.Second)) { + t.Error("Resign succeeded twice for the same player") + } +} diff --git a/server/internal/game/state.go b/server/internal/game/state.go index b3b47b3..891fd84 100644 --- a/server/internal/game/state.go +++ b/server/internal/game/state.go @@ -117,16 +117,41 @@ func (r EndReason) String() string { return "unknown" } +// Standing is one player's final placing. +// +// Rank 1 is whoever was still standing when the game ended; below them, a +// player eliminated later ranks above one eliminated earlier. Outlasting +// somebody is what beats them, so the ranking is finishing order and the score +// is reported beside it rather than deciding it. +type Standing struct { + Player PlayerID + Score int + Rank int + // Reason is how this player left the game, and EndNone for the winner, + // who did not. + Reason EndReason +} + // State is a snapshot for the transport layer to render. It copies everything // it exposes, so a caller can hold it without touching engine state. type State struct { - Current string - Turn PlayerID - Deadline time.Time - History []Move - Scores map[PlayerID]int + Current string + Turn PlayerID + Deadline time.Time + History []Move + Scores map[PlayerID]int + // Alive says who is still in the game. A player who has been eliminated + // keeps their score and their place in the history; they simply no longer + // get a turn. + Alive map[PlayerID]bool + // Eliminated is the order players went out, first out first. A caller that + // remembers its length can tell exactly who went out on the last input. + Eliminated []PlayerID ChainLength int Over bool Winner PlayerID EndReason EndReason + // Standings is the final table, best first. Meaningless while the game is + // in play, for the same reason Winner is. + Standings []Standing } diff --git a/server/internal/wsapi/codec.go b/server/internal/wsapi/codec.go index 3c05380..282d5e7 100644 --- a/server/internal/wsapi/codec.go +++ b/server/internal/wsapi/codec.go @@ -65,12 +65,6 @@ func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) * }} } -func opponentLeftMsg(canReconnect bool, graceMs uint32) *noituv1.ServerMessage { - return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_OpponentLeft{ - OpponentLeft: &noituv1.OpponentLeft{CanReconnect: canReconnect, GraceMs: graceMs}, - }} -} - // errorMsg carries a UI key, never prose and never an internal error string. // The Vietnamese copy lives in the frontend so all wording stays in one place, // and a raw error would leak server internals to anyone with a socket. diff --git a/server/internal/wsapi/convert.go b/server/internal/wsapi/convert.go index 2656902..ac938f4 100644 --- a/server/internal/wsapi/convert.go +++ b/server/internal/wsapi/convert.go @@ -24,7 +24,7 @@ import ( // agree on everything they both know how to decode. Bump it when a change // would make an older client misread a frame rather than merely ignore an // unknown field. -const ProtocolVersion uint32 = 1 +const ProtocolVersion uint32 = 2 // RejectReason maps an engine rejection onto the wire enum. // diff --git a/server/internal/wsapi/multiplayer_test.go b/server/internal/wsapi/multiplayer_test.go new file mode 100644 index 0000000..ffcff67 --- /dev/null +++ b/server/internal/wsapi/multiplayer_test.go @@ -0,0 +1,312 @@ +package wsapi + +import ( + "testing" + "time" + + noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" +) + +// What a room of more than two people does that a pair does not: seat up to +// four, refuse the fifth, wait for every guest before it starts, kick by name, +// and outlive the first player it knocks out. + +// roomOf seats n players and leaves them in the lobby. The first is the owner. +// +// It returns the owner's last view of the room as well as the clients: every +// room_state the seating produced has been read by then, so a caller that +// awaited another would wait for one nothing is going to send. +func roomOf(t *testing.T, url string, n int) (clients []*testClient, state *noituv1.RoomState) { + t.Helper() + + names := []string{"Chủ phòng", "Khách", "Người thứ ba", "Người thứ tư", "Người thứ năm"} + host := dial(t, url) + host.hello(names[0]) + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) + state = host.await("room_state").GetRoomState() + clients = append(clients, host) + + for i := 1; i < n; i++ { + c := dial(t, url) + c.hello(names[i]) + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: state.GetRoomCode()}, + }}) + clients = append(clients, c) + // Everybody seated sees the arrival, which is also what keeps each + // client's inbox drained before the next assertion reads from it. + for _, seated := range clients { + got := seated.await("room_state").GetRoomState() + if seated == host { + state = got + } + } + } + return clients, state +} + +func TestARoomSeatsFourAndRefusesTheFifth(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + _, state := roomOf(t, url, maxPlayers) + + if got := len(state.GetPlayers()); got != maxPlayers { + t.Fatalf("the room holds %d players, want %d", got, maxPlayers) + } + if state.GetMaxPlayers() != maxPlayers || state.GetMinPlayers() != minPlayers { + t.Errorf("limits = %d/%d, want %d/%d", + state.GetMinPlayers(), state.GetMaxPlayers(), minPlayers, maxPlayers) + } + // Exactly one row is the recipient's, and exactly one is the owner's. + me, owners := 0, 0 + for _, p := range state.GetPlayers() { + if p.GetIsMe() { + me++ + } + if p.GetIsOwner() { + owners++ + } + } + if me != 1 || owners != 1 { + t.Errorf("the room has %d rows marked mine and %d marked owner, want 1 of each", me, owners) + } + + fifth := dial(t, url) + fifth.hello("Người thứ năm") + fifth.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ + JoinRoom: &noituv1.JoinRoom{RoomCode: state.GetRoomCode()}, + }}) + if got := fifth.await("error").GetError().GetCode(); got != "room_full" { + t.Errorf("the fifth joiner got %q, want room_full", got) + } +} + +// Every guest, not merely the first: a room of four that starts on one yes has +// dealt three people a turn they never agreed to. +func TestStartWaitsForEveryGuest(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + clients, _ := roomOf(t, url, 3) + host, second, third := clients[0], clients[1], clients[2] + + second.setReady(true) + for _, c := range clients { + c.await("room_state") + } + + host.startGame() + if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" { + t.Fatalf("starting with one guest ready returned %q, want not_everyone_ready", got) + } + + third.setReady(true) + for _, c := range clients { + state := c.await("room_state").GetRoomState() + if !state.GetCanStart() { + t.Errorf("can_start is false with everybody ready: %+v", state) + } + } + + host.startGame() + for _, c := range clients { + start := c.await("game_started").GetGameStarted() + if got := len(start.GetPlayers()); got != 3 { + t.Errorf("the game was dealt to %d players, want 3", got) + } + if start.GetTurnPlayerId() != "p1" { + t.Errorf("turn_player_id = %q, want p1 — seat order is turn order", start.GetTurnPlayerId()) + } + } +} + +func TestKickNamesASeat(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + clients, _ := roomOf(t, url, 3) + host, second, third := clients[0], clients[1], clients[2] + + // The owner cannot free their own seat: leaving is what an owner who wants + // out does, and it hands the room on rather than dropping it. + host.kickPlayer("p1") + if got := host.await("error").GetError().GetCode(); got != "cannot_kick_self" { + t.Errorf("kicking themselves returned %q, want cannot_kick_self", got) + } + + // Readiness is a commitment, per target rather than per room. + second.setReady(true) + for _, c := range clients { + c.await("room_state") + } + host.kickPlayer("p2") + if got := host.await("error").GetError().GetCode(); got != "player_is_ready" { + t.Errorf("kicking a ready player returned %q, want player_is_ready", got) + } + + // The unready one goes, and the ready one is untouched. + host.kickPlayer("p3") + if got := third.await("error").GetError().GetCode(); got != "kicked" { + t.Fatalf("the kicked player was told %q", got) + } + + state := host.await("room_state").GetRoomState() + if got := len(state.GetPlayers()); got != 2 { + t.Fatalf("the room holds %d players after one kick, want 2", got) + } + if slotFor(state, "p3") != nil { + t.Error("the kicked seat is still occupied") + } + if seat := slotFor(state, "p2"); seat == nil || !seat.GetReady() { + t.Error("kicking one player disturbed another's readiness") + } +} + +// The room outlives the first player it knocks out: the rest carry on from the +// same syllable, and the player who went out stays to watch. +func TestAGameOutlivesItsFirstElimination(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second}) + clients, _ := roomOf(t, url, 3) + host, second, third := clients[0], clients[1], clients[2] + + second.setReady(true) + third.setReady(true) + for _, c := range clients { + c.await("room_state") + } + + host.startGame() + starts := map[*testClient]*noituv1.GameStarted{} + for _, c := range clients { + starts[c] = c.await("game_started").GetGameStarted() + } + + // The player on turn gives up. Two are left, so the game does not end. + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}}) + + for _, c := range clients { + out := c.await("player_eliminated").GetPlayerEliminated() + if out.GetPlayerId() != "p1" { + t.Errorf("%q went out, want p1", out.GetPlayerId()) + } + if got := out.GetIsMe(); got != (c == host) { + t.Errorf("is_me = %v for the wrong recipient", got) + } + if c != host && len(out.GetSuggestions()) != 0 { + t.Errorf("a player who is still in was sent suggestions %v", out.GetSuggestions()) + } + } + + // A turn update with no word: the position survived the player who left it. + update := second.await("turn_update").GetTurnUpdate() + if update.GetPlayed() != nil { + t.Error("an elimination reported a word as played") + } + if update.GetCurrentSyllable() != starts[second].GetCurrentSyllable() { + t.Errorf("the syllable moved on an elimination: %q -> %q", + starts[second].GetCurrentSyllable(), update.GetCurrentSyllable()) + } + if update.GetTurnPlayerId() != "p2" || !update.GetMyTurn() { + t.Errorf("turn went to %q, want p2", update.GetTurnPlayerId()) + } + if update.GetTurnSeq() == starts[second].GetTurnSeq() { + t.Error("the turn sequence did not move, so a stale submission could still land") + } + + // The player who went out is still in the room and still being told what + // is happening in it. + watching := host.await("turn_update").GetTurnUpdate() + if watching.GetMyTurn() { + t.Error("an eliminated player was dealt a turn") + } + var eliminated bool + for _, p := range watching.GetPlayers() { + if p.GetPlayerId() == "p1" { + eliminated = p.GetEliminated() + } + } + if !eliminated { + t.Error("the table does not show the eliminated player as out") + } + + // And they can still talk, which is the other half of staying in the room. + host.say("chúc may mắn") + if got := second.await("chat_message").GetChatMessage().GetText(); got != "chúc may mắn" { + t.Errorf("an eliminated player's message arrived as %q", got) + } +} + +// The last elimination ends it, and everybody is shown the same table from +// their own side. +func TestStandingsReachEverySeat(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second}) + clients, _ := roomOf(t, url, 3) + host, second, third := clients[0], clients[1], clients[2] + + second.setReady(true) + third.setReady(true) + for _, c := range clients { + c.await("room_state") + } + host.startGame() + for _, c := range clients { + c.await("game_started") + } + + host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}}) + for _, c := range clients { + c.await("player_eliminated") + } + second.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}}) + + winners := 0 + for _, c := range clients { + over := c.await("game_over").GetGameOver() + if over.GetIWon() { + winners++ + } + + standings := over.GetStandings() + if len(standings) != 3 { + t.Fatalf("standings has %d rows, want 3", len(standings)) + } + // Finishing order: the survivor, then the players who went out, latest + // first. Rank matches position, so the two cannot drift apart. + want := []string{"p3", "p2", "p1"} + mine := 0 + for i, row := range standings { + if row.GetPlayerId() != want[i] { + t.Errorf("rank %d is %q, want %q", i+1, row.GetPlayerId(), want[i]) + } + if int(row.GetRank()) != i+1 { + t.Errorf("%q has rank %d at position %d", row.GetPlayerId(), row.GetRank(), i+1) + } + if row.GetIsMe() { + mine++ + } + } + if mine != 1 { + t.Errorf("%d standings rows are marked mine, want 1", mine) + } + } + if winners != 1 { + t.Errorf("%d players were told they won, want 1", winners) + } +} + +// Two people dropping at once is two windows, not one. The room has to hold +// both seats and settle each on its own deadline. +func TestSeveralReconnectWindowsRunAtOnce(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{GraceFor: 300 * time.Millisecond}) + clients, _ := roomOf(t, url, 3) + host, second, third := clients[0], clients[1], clients[2] + + second.conn.CloseNow() + third.conn.CloseNow() + + // Both seats are held first, then both are freed. The owner is the one + // still here to watch it happen. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + state := host.await("room_state").GetRoomState() + if len(state.GetPlayers()) == 1 { + return + } + } + t.Error("the room never freed the seats whose windows had run out") +} diff --git a/server/internal/wsapi/nickname.go b/server/internal/wsapi/nickname.go index 8466dd0..aa039f9 100644 --- a/server/internal/wsapi/nickname.go +++ b/server/internal/wsapi/nickname.go @@ -1,6 +1,8 @@ package wsapi import ( + "fmt" + "slices" "strings" "unicode" @@ -110,16 +112,25 @@ func capMarks(s string, maxMarks int) string { // defaultNickname is what an unusable name falls back to. const defaultNickname = "Người chơi" -// distinguish returns a name for the joining player that their opponent cannot -// be confused with. Nicknames are the only way to tell two strangers apart, so -// letting both sides render the same string defeats the point of having them. -func distinguish(name, taken string) string { - if name != taken { +// distinguish returns a name the joining player cannot be confused with any of +// the ones already in the room. Nicknames are the only way to tell strangers +// apart, so letting two of them render the same string defeats the point of +// having names at all. +// +// The counter is bounded by the room: taken holds at most one name per seat, so +// a free suffix is always found within that many tries. +func distinguish(name string, taken []string) string { + if !slices.Contains(taken, name) { return name } - suffix := " 2" - if runes := []rune(name); len(runes)+len(suffix) > maxNicknameRunes { - name = strings.TrimSpace(string(runes[:maxNicknameRunes-len(suffix)])) + for n := 2; ; n++ { + suffix := fmt.Sprintf(" %d", n) + trimmed := name + if runes := []rune(name); len(runes)+len(suffix) > maxNicknameRunes { + trimmed = strings.TrimSpace(string(runes[:maxNicknameRunes-len(suffix)])) + } + if candidate := trimmed + suffix; !slices.Contains(taken, candidate) { + return candidate + } } - return name + suffix } diff --git a/server/internal/wsapi/regression_test.go b/server/internal/wsapi/regression_test.go index 4000265..88752fe 100644 --- a/server/internal/wsapi/regression_test.go +++ b/server/internal/wsapi/regression_test.go @@ -109,8 +109,11 @@ func TestStrangerCannotResignForASeatedPlayer(t *testing.T) { stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ JoinRoom: &noituv1.JoinRoom{RoomCode: code}, }}) - if got := stranger.await("error").GetError().GetCode(); got != "room_full" { - t.Fatalf("third joiner got %q, want room_full", got) + // Not room_full: a four-seat room with two people in it has seats going + // spare. Arriving in the middle of a game is what is refused, and a + // stranger holding the code is refused it like anybody else. + if got := stranger.await("error").GetError().GetCode(); got != "game_in_progress" { + t.Fatalf("third joiner got %q, want game_in_progress", got) } stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}}) @@ -244,6 +247,15 @@ func TestPvPGameRunsToAWinner(t *testing.T) { hostTurn := awaitMyTurn(t, host) host.submit("d e", hostTurn.GetTurnSeq()) + // The elimination comes first and carries what the position had left, so + // it has to be read before the result it caused. + guestOut := guest.await("player_eliminated").GetPlayerEliminated() + hostOut := host.await("player_eliminated").GetPlayerEliminated() + + if !guestOut.GetIsMe() || hostOut.GetIsMe() { + t.Error("is_me should be true only for the player who went out") + } + hostOver := host.await("game_over").GetGameOver() guestOver := guest.await("game_over").GetGameOver() @@ -258,12 +270,14 @@ func TestPvPGameRunsToAWinner(t *testing.T) { t.Errorf("reason = %v, want NO_LEGAL_MOVE", hostOver.GetReason()) } - // Nothing could have been played, and the empty list is how the loser is - // told so. The winner is never sent one. - if got := guestOver.GetSuggestions(); len(got) != 0 { + // Nothing could have been played, and the empty list is how the player who + // was stuck is told so. It rides on the elimination rather than the + // result: it describes the position they were looking at, which by the end + // of a longer game is nobody else's position. + if got := guestOut.GetSuggestions(); len(got) != 0 { t.Errorf("the losing player was offered %v out of a dead end, want nothing", got) } - if got := hostOver.GetSuggestions(); len(got) != 0 { + if got := hostOut.GetSuggestions(); len(got) != 0 { t.Errorf("the winner was sent suggestions %v, want none", got) } } @@ -278,16 +292,18 @@ func TestLosingPlayerIsToldWhatCouldHaveBeenPlayed(t *testing.T) { // The opening is "a b", so "b c" is still there to be played. guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}}) + guestOut := guest.await("player_eliminated").GetPlayerEliminated() + hostOut := host.await("player_eliminated").GetPlayerEliminated() + guestOver := guest.await("game_over").GetGameOver() - hostOver := host.await("game_over").GetGameOver() if guestOver.GetIWon() { t.Fatal("the player who resigned was told they won") } - if got := guestOver.GetSuggestions(); !slices.Equal(got, []string{"b c"}) { + if got := guestOut.GetSuggestions(); !slices.Equal(got, []string{"b c"}) { t.Errorf("suggestions = %v, want [b c]", got) } - if got := hostOver.GetSuggestions(); len(got) != 0 { + if got := hostOut.GetSuggestions(); len(got) != 0 { t.Errorf("the winner was sent suggestions %v, want none", got) } } @@ -325,7 +341,7 @@ func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) { JoinRoom: &noituv1.JoinRoom{RoomCode: code}, }}) - shown := host.await("room_state").GetRoomState().GetOpponentName() + shown := otherSlot(host.await("room_state").GetRoomState()).GetName() for _, r := range []rune{nul, zeroWidthSpace, bidiOverride} { if strings.ContainsRune(shown, r) { t.Errorf("opponent name %q still carries the invisible rune %U", shown, r) @@ -412,7 +428,7 @@ func TestResumeAfterGameEndedLandsInTheLobby(t *testing.T) { if state.GetRoomCode() != code { t.Errorf("resumed into room %q, want %q", state.GetRoomCode(), code) } - if !state.GetOpponentPresent() { + if otherSlot(state) == nil { t.Errorf("the player who stayed is missing from the resumed lobby: %+v", state) } } diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go index 0918429..8378f6b 100644 --- a/server/internal/wsapi/room.go +++ b/server/internal/wsapi/room.go @@ -17,6 +17,15 @@ import ( // validation as a human's, so there is one rule implementation rather than two. const botPlayerID game.PlayerID = "bot" +// maxPlayers is how many seats a room has, and minPlayers how many it takes +// to start one. Both are sent to the client in RoomState rather than compiled +// into it, so the lobby draws whatever the server allows and widening a room +// is a server change alone. +const ( + maxPlayers = 4 + minPlayers = 2 +) + // minOpeningOutDegree keeps the first word from being a dead end. Opening on a // syllable with two continuations makes for a game that ends before it starts. const minOpeningOutDegree = 20 @@ -109,6 +118,9 @@ type lobbyInput struct { // toggle: a toggle applied to a state the client is a frame behind on sets // the opposite of what the player clicked. ready bool + // target is the seat a lobbyKick names. A room holds up to four people, so + // "the other one" stopped being an answer. + target game.PlayerID } // chatInput is one line of text from a seated player. It carries the @@ -164,6 +176,10 @@ type seat struct { // filled. A replay starts there, which is what keeps a stranger who walks // in with the code from being handed what the last two people said. chatFrom uint64 + // graceUntil is when this seat stops being held for the player who dropped + // out of it, and zero while they are connected. Per seat rather than per + // room because any number of them can be waiting at once. + graceUntil time.Time } // chatEntry is one line of the room's conversation. @@ -200,7 +216,7 @@ type room struct { graceFor time.Duration idleFor time.Duration - seats [2]*seat + seats [maxPlayers]*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 @@ -213,10 +229,11 @@ type room struct { // is identifiable rather than silently applied to the next turn. turnSeq uint32 - // disconnected is the seat currently inside its reconnect grace window, - // or nil. Only one seat can be waiting: if the second also drops, there is - // nobody left to win and the room ends. - disconnected *seat + // outWire overrides how one player's elimination is reported, for the + // cases the engine cannot know about. A reconnect window running out is + // the only one: to the engine that is a resignation, and to the other + // players it is somebody who left. + outWire map[game.PlayerID]noituv1.GameEndReason // chat is the room's recent conversation, oldest first, capped at // chatHistoryLimit. It belongs to the room, so it outlives each game and @@ -325,6 +342,21 @@ func (r *room) run() { turnTimer = time.NewTimer(time.Until(r.engine.Deadline())) } + // resetGraceTimer arms one timer for the earliest reconnect window still + // open. Several seats can be waiting at once, and a timer each would be a + // timer per player to stop, drain and reason about; one wakeup at the + // nearest deadline settles every window that has passed by the time it + // fires. + resetGraceTimer := func() { + stop(graceTimer) + graceTimer = nil + next, waiting := r.nextGraceExpiry() + if !waiting { + return + } + graceTimer = time.NewTimer(time.Until(next)) + } + // 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. @@ -363,58 +395,44 @@ func (r *room) run() { r.handleCreate(m) case startBotInput: r.handleStartBot(m) - resetTurnTimer() case joinInput: r.handleJoin(m) - resetTurnTimer() case submitInput: r.handleSubmit(m) - resetTurnTimer() case botMoveInput: r.handleBotMove(m) - resetTurnTimer() case lobbyInput: r.handleLobby(m) - resetTurnTimer() case chatInput: r.handleChat(m) idleActivity = false case resignInput: - if !r.occupies(m.sess, m.player) { - m.sess.send(errorMsg("not_your_seat")) - break - } - if r.engine != nil && r.engine.Resign(m.player) { - r.broadcastGameOver() - } - resetTurnTimer() + r.handleResign(m) case disconnectInput: // 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 evict a seat its new socket is sitting in. - if r.handleDisconnect(m) { - stop(graceTimer) - graceTimer = time.NewTimer(r.graceFor) - } - resetTurnTimer() + r.handleDisconnect(m) case resumeInput: r.handleResume(m) - stop(graceTimer) - graceTimer = nil - resetTurnTimer() } + // Every input can move the turn, open or close a reconnect window, + // or both — an elimination does all of it at once. Recomputing both + // timers here rather than in each arm is what keeps a new input + // type from silently forgetting one. + resetTurnTimer() + resetGraceTimer() case <-turnC: // The timer and every message land on the same select, so a move // that arrives at the deadline is either strictly before or // strictly after it. There is no window where both apply. - if r.engine != nil && r.engine.Timeout(time.Now()) { - r.broadcastGameOver() + if r.engine != nil { + before := r.eliminatedCount() + if r.engine.Timeout(time.Now()) { + r.applyEliminations(before) + } } resetTurnTimer() @@ -422,6 +440,7 @@ func (r *room) run() { graceTimer = nil r.handleGraceExpiry() resetTurnTimer() + resetGraceTimer() case <-idleC: // A lobby nobody started a game in. Whoever is still sitting in it @@ -472,6 +491,22 @@ func (r *room) handleCreate(m createInput) { r.sendChatHistory(r.seats[0]) } +// handleResign is one player giving up. The seat, not the claimed id, is the +// authority, as everywhere a connection acts on a room. +func (r *room) handleResign(m resignInput) { + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + return + } + if r.engine == nil || r.engine.Over() { + return + } + before := r.eliminatedCount() + if r.engine.Resign(m.player, time.Now()) { + r.applyEliminations(before) + } +} + // handleStartBot seats a bot opposite the player and begins immediately. func (r *room) handleStartBot(m startBotInput) { strategy, err := bot.New(m.difficulty, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))) @@ -494,8 +529,8 @@ func (r *room) handleStartBot(m startBotInput) { } } -// handleJoin seats a second human in the lobby. It no longer starts anything: -// the owner does that, once this player says they are ready. +// handleJoin seats another human in the lobby. It does not start anything: the +// owner does that, once everybody has said 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 @@ -507,11 +542,12 @@ func (r *room) handleJoin(m joinInput) { m.sess.send(errorMsg("room_full")) return } - // 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. + // A room can have a free seat and still be mid-game — four people can + // start a game three of them are in. Arriving in the middle of one is not + // something to seat somebody for: they would have no words, no score, and + // no way to be told what they had missed. if !r.inLobby() { - m.sess.send(errorMsg("room_full")) + m.sess.send(errorMsg("game_in_progress")) return } for _, s := range r.seats { @@ -524,7 +560,7 @@ func (r *room) handleJoin(m joinInput) { id := seatIDs[free] r.seats[free] = &seat{ id: id, - nickname: distinguish(m.sess.nickname(), r.otherNickname(id)), + nickname: distinguish(m.sess.nickname(), r.takenNicknames(id)), sess: m.sess, // Seated now, so the conversation up to this point is not theirs to // read. A room code is pasted into group chats by design. @@ -574,15 +610,14 @@ func (r *room) handleLobby(m lobbyInput) { m.sess.send(errorMsg("not_the_owner")) return } - guest := r.guestSeat() switch { - case guest == nil: - m.sess.send(errorMsg("need_two_players")) + case r.seatedCount() < minPlayers: + m.sess.send(errorMsg("need_more_players")) return - case guest.sess == nil: - m.sess.send(errorMsg("opponent_offline")) + case !r.allConnected(): + m.sess.send(errorMsg("player_offline")) return - case !guest.ready: + case !r.guestsReady(): m.sess.send(errorMsg("not_everyone_ready")) return } @@ -596,21 +631,28 @@ func (r *room) handleLobby(m lobbyInput) { m.sess.send(errorMsg("not_the_owner")) return } - guest := r.guestSeat() - if guest == nil { + target := r.seatOf(m.target) + switch { + case target == 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 { + case target == mine: + // Leaving is what an owner who wants out does, and it hands the + // room on. Kicking yourself would drop the seat and the role + // together while the others were still sitting here. + m.sess.send(errorMsg("cannot_kick_self")) + return + case target.ready: + // Readiness is a commitment, and the owner does not get to + // overrule one: a player who is ready is waiting on the owner, + // not in the way. m.sess.send(errorMsg("player_is_ready")) return } - if guest.sess != nil { - guest.sess.send(errorMsg("kicked")) + if target.sess != nil { + target.sess.send(errorMsg("kicked")) } - r.vacate(guest) + r.vacate(target) r.lobbyChanged = true case lobbyLeave: @@ -641,12 +683,24 @@ func (r *room) beginGame() error { return err } - engine, err := game.New(r.dict, []game.PlayerID{r.seats[0].id, r.seats[1].id}, opening, r.turnLimit, time.Now()) + // Seat order is turn order, so a player's place at the table is the place + // they took in the lobby and nothing has to be shuffled or announced. + ids := make([]game.PlayerID, 0, maxPlayers) + for _, s := range r.seats { + if s != nil { + ids = append(ids, s.id) + } + } + + engine, err := game.New(r.dict, ids, opening, r.turnLimit, time.Now()) if err != nil { return err } r.engine = engine r.opening = opening + // Fresh per game: an override from the last one would describe a player + // who has since come back and is playing this one. + r.outWire = make(map[game.PlayerID]noituv1.GameEndReason, len(ids)) // Never restarts at 1. A rematch reuses the same connections, so a // submission still in flight from the previous game would otherwise be // able to match a turn in this one and be applied to it. @@ -654,30 +708,35 @@ func (r *room) beginGame() error { // 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.ready = false + if s != nil { + s.ready = false + } } + state := r.engine.Snapshot() for _, s := range r.seats { - r.sendGameStarted(s) + r.sendGameStarted(s, state) } r.maybeScheduleBot() return nil } -// sendGameStarted renders the opening position for one seat. my_turn is -// per-recipient, which is why this is built per seat rather than broadcast. -func (r *room) sendGameStarted(s *seat) { - if s.sess == nil { +// sendGameStarted renders the opening position for one seat. my_turn and is_me +// are per-recipient, which is why this is built per seat rather than broadcast. +func (r *room) sendGameStarted(s *seat, state game.State) { + if s == nil || s.sess == nil { return } s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameStarted{ GameStarted: &noituv1.GameStarted{ OpeningWord: r.opening, - CurrentSyllable: r.engine.Current(), - MyTurn: r.engine.Turn() == s.id, - DeadlineUnixMs: r.engine.Deadline().UnixMilli(), + CurrentSyllable: state.Current, + MyTurn: state.Turn == s.id, + DeadlineUnixMs: state.Deadline.UnixMilli(), TurnSeq: r.turnSeq, TurnLimitMs: uint32(r.turnLimit.Milliseconds()), + Players: r.scoreRows(r.engine.Players(), state, s.id, nil), + TurnPlayerId: string(state.Turn), }, }}) } @@ -705,23 +764,20 @@ func (r *room) handleSubmit(m submitInput) { return } + before := r.eliminatedCount() move, reason := r.engine.Submit(m.player, m.word, time.Now()) if reason != game.ReasonNone { r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), m.word, m.turnSeq)) - // A rejection for an expired turn is also the end of the game. - if r.engine.Over() { - r.broadcastGameOver() - } + // A rejection for an expired turn also took this player out of the + // game, and everybody has to be told which. + r.applyEliminations(before) return } + // An accepted move never ends a game: a dead end is left for whoever + // inherits it, which is what Submit's own comment explains. r.turnSeq++ - r.broadcastTurn(move) - - if r.engine.Over() { - r.broadcastGameOver() - return - } + r.broadcastTurn(&move) r.maybeScheduleBot() } @@ -736,34 +792,34 @@ func (r *room) handleBotMove(m botMoveInput) { return } + now := time.Now() + before := r.eliminatedCount() + if m.err != nil { // The bot has nothing to play. A human in this position keeps their // turn and loses it to the clock; the bot has no clock to spend, so // the position is settled now and reported for what it is rather than // as a resignation it never chose. - if !r.engine.NoMove() { - r.engine.Resign(botPlayerID) + if !r.engine.NoMove(now) { + r.engine.Resign(botPlayerID, now) } - r.broadcastGameOver() + r.applyEliminations(before) return } - move, reason := r.engine.Submit(botPlayerID, m.word, time.Now()) + move, reason := r.engine.Submit(botPlayerID, m.word, now) if reason != game.ReasonNone { // The bot searched the same dictionary the engine validates against, // so this means the two disagree — a bug worth seeing, not a move to // retry. slog.Error("bot move rejected by engine", "room", r.code, "word", m.word, "reason", reason.String()) - r.engine.Resign(botPlayerID) - r.broadcastGameOver() + r.engine.Resign(botPlayerID, now) + r.applyEliminations(before) return } r.turnSeq++ - r.broadcastTurn(move) - if r.engine.Over() { - r.broadcastGameOver() - } + r.broadcastTurn(&move) } // maybeScheduleBot starts the bot thinking if it is now its turn. @@ -796,114 +852,275 @@ func (r *room) maybeScheduleBot() { }() } -// broadcastTurn sends the move to both seats, rendered for each. -func (r *room) broadcastTurn(move game.Move) { +// broadcastTurn sends the position to every seat, rendered for each. +// +// move is nil when the turn moved without a word being played, which is what +// an elimination does: the syllable and the used set survive the player who +// could not answer them, and everybody still needs the new deadline and the +// new player to act. +func (r *room) broadcastTurn(move *game.Move) { state := r.engine.Snapshot() - for i, s := range r.seats { - if s.sess == nil { + for _, s := range r.seats { + r.sendTurnUpdate(s, state, move) + } +} + +// sendTurnUpdate renders one position for one seat. by_me, my_turn and is_me +// are all per-recipient, which is why there is no single shared frame. +func (r *room) sendTurnUpdate(s *seat, state game.State, move *game.Move) { + if s == nil || s.sess == nil { + return + } + update := &noituv1.TurnUpdate{ + CurrentSyllable: state.Current, + MyTurn: state.Turn == s.id, + DeadlineUnixMs: state.Deadline.UnixMilli(), + TurnSeq: r.turnSeq, + ChainLength: uint32(state.ChainLength), + Players: r.scoreRows(r.engine.Players(), state, s.id, nil), + TurnPlayerId: string(state.Turn), + } + if move != nil { + update.Played = PlayedWord(*move, move.Player == s.id) + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: update}}) +} + +// eliminatedCount is how many players the engine has knocked out so far, and 0 +// when there is no game. Remembered across an input so applyEliminations can +// tell that input's doing from what was already true. +func (r *room) eliminatedCount() int { + if r.engine == nil { + return 0 + } + return r.engine.EliminatedCount() +} + +// applyEliminations reports everybody the last input knocked out, then whatever +// the game became: finished, or one turn further on. +// +// Every path that takes a player out of a game ends here — a timeout, a +// resignation, a bot with nothing to play, a reconnect window running out — so +// there is one place that decides what the room says about it. +func (r *room) applyEliminations(before int) { + if r.engine == nil { + return + } + state := r.engine.Snapshot() + if len(state.Eliminated) == before { + return + } + + // An elimination does not move the position, so one lookup describes it + // for everybody who went out on this input. + suggestions := r.engine.Suggestions(maxSuggestions) + for _, id := range state.Eliminated[before:] { + r.broadcastElimination(id, suggestions) + } + + if r.engine.Over() { + r.broadcastGameOver(state) + return + } + // A new turn nobody played into. The sequence still has to move: a + // submission already in flight was answering the position the player who + // just went out was looking at. + r.turnSeq++ + r.broadcastTurn(nil) +} + +// broadcastElimination tells the room one player is out. +// +// The suggestions go only to that player. They are what the position still had +// to offer, and the people who could still answer it are not the ones who +// needed to be told — an empty list is the answer for whoever was stuck, and +// noise for everybody else. +func (r *room) broadcastElimination(id game.PlayerID, suggestions []string) { + name := "" + if out := r.seatOf(id); out != nil { + name = out.nickname + } + reason := r.wireEndReason(id) + + for _, s := range r.seats { + if s == nil || s.sess == nil { continue } - opponent := r.seats[1-i] - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{ - TurnUpdate: &noituv1.TurnUpdate{ - Played: PlayedWord(move, move.Player == s.id), - CurrentSyllable: state.Current, - MyTurn: state.Turn == s.id, - DeadlineUnixMs: state.Deadline.UnixMilli(), - TurnSeq: r.turnSeq, - MyScore: uint32(state.Scores[s.id]), - OpponentScore: uint32(state.Scores[opponent.id]), - ChainLength: uint32(state.ChainLength), - }, + msg := &noituv1.PlayerEliminated{ + PlayerId: string(id), + Name: name, + IsMe: s.id == id, + Reason: reason, + } + if s.id == id { + msg.Suggestions = suggestions + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_PlayerEliminated{ + PlayerEliminated: msg, }}) } } +// wireEndReason says how one player left the game. +// +// The engine's answer, unless the room overrode it: a reconnect window running +// out is a resignation to the engine, because that is the only shape it has +// for a player who stops playing, and somebody who left to everybody in the +// room. +func (r *room) wireEndReason(p game.PlayerID) noituv1.GameEndReason { + if code, overridden := r.outWire[p]; overridden { + return code + } + return EndReason(r.engine.OutReason(p)) +} + // broadcastGameOver reports the result from each seat's point of view. -func (r *room) broadcastGameOver() { - state := r.engine.Snapshot() +func (r *room) broadcastGameOver(state game.State) { + // The reason the game ended is the reason the last player went out, which + // with two seats is the only elimination there was. + reason := noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED + if n := len(state.Eliminated); n > 0 { + reason = r.wireEndReason(state.Eliminated[n-1]) + } + + ranks := make(map[game.PlayerID]int, len(state.Standings)) + order := make([]game.PlayerID, 0, len(state.Standings)) + for _, standing := range state.Standings { + ranks[standing.Player] = standing.Rank + order = append(order, standing.Player) + } + for _, s := range r.seats { - if s.sess == nil { + if s == nil || s.sess == nil { continue } - s.sess.send(r.gameOverFor(state, s.id, EndReason(state.EndReason))) + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ + GameOver: &noituv1.GameOver{ + IWon: state.Winner == s.id, + Reason: reason, + ChainLength: uint32(state.ChainLength), + Standings: r.scoreRows(order, state, s.id, ranks), + }, + }}) } // 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. +// scoreRows renders the players table for one recipient. // -// The loser is told what could have been played from the position the game -// ended on. The winner is not: they are not the one who was stuck, and it is -// the loser for whom an empty list answers the question — nothing could have -// been played, so the position, not the player, ended the game. -func (r *room) gameOverFor(state game.State, id game.PlayerID, reason noituv1.GameEndReason) *noituv1.ServerMessage { - iWon := state.Winner == id - - var suggestions []string - if !iWon { - suggestions = r.engine.Suggestions(maxSuggestions) +// order is the sequence to report them in — turn order while a game runs, +// finishing order once one has ended — and ranks is empty until there is a +// result, which is what makes a rank of zero mean "still playing" rather than +// needing a field of its own to say so. +func (r *room) scoreRows(order []game.PlayerID, state game.State, me game.PlayerID, ranks map[game.PlayerID]int) []*noituv1.PlayerScore { + rows := make([]*noituv1.PlayerScore, 0, len(order)) + for _, id := range order { + row := &noituv1.PlayerScore{ + PlayerId: string(id), + IsMe: id == me, + Score: uint32(state.Scores[id]), + // A player the engine no longer knows is a seat that was vacated + // mid-game, which only happens to somebody already out. + Eliminated: !state.Alive[id], + // The bot has no socket to lose, so it is never the one keeping + // the room waiting. + Connected: id == botPlayerID, + Rank: uint32(ranks[id]), + } + if s := r.seatOf(id); s != nil { + row.Name = s.nickname + row.Connected = row.Connected || s.sess != nil + } + rows = append(rows, row) } - - return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ - GameOver: &noituv1.GameOver{ - IWon: iWon, - Reason: reason, - MyScore: uint32(state.Scores[id]), - ChainLength: uint32(state.ChainLength), - Suggestions: suggestions, - }, - }} + return rows } -// handleDisconnect holds the seat open and reports whether the notice applied. +// handleDisconnect holds the seat open for the player who dropped out of it. // // 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 { +// +// The turn clock is deliberately not paused. A player who drops on their own +// turn loses it the way anybody else would; the window decides only whether +// they are still in the game afterwards. +func (r *room) handleDisconnect(m disconnectInput) { 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. + // A stale notice from a connection the player already replaced. Acting on + // it would evict the seat the new socket is sitting in. if s == nil || s.sess == nil || s.sess != m.sess { - return false - } - s.sess = nil - - // 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 false - } - r.disconnected = s - r.lobbyChanged = true - - // 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 -} - -// handleGraceExpiry decides what a reconnect window running out means. -func (r *room) handleGraceExpiry() { - if r.disconnected == nil { return } - // 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) + s.sess = nil + s.graceUntil = time.Now().Add(r.graceFor) + // Presence is part of the room's state, and the run loop is what sends it. + // There is nothing extra to say to the players who are still here. r.lobbyChanged = true } +// nextGraceExpiry is the earliest reconnect window still open. +func (r *room) nextGraceExpiry() (time.Time, bool) { + var next time.Time + for _, s := range r.seats { + if s == nil || s.sess != nil || s.graceUntil.IsZero() { + continue + } + if next.IsZero() || s.graceUntil.Before(next) { + next = s.graceUntil + } + } + return next, !next.IsZero() +} + +// handleGraceExpiry frees every seat whose reconnect window has run out. +// +// The engine goes first, while the seats are still here to be named: once one +// is vacated there is nobody left to attribute the elimination to, and the +// players who stayed would be told that somebody with no name went out. +func (r *room) handleGraceExpiry() { + now := time.Now() + + var expired []*seat + for _, s := range r.seats { + if s == nil || s.sess != nil || s.graceUntil.IsZero() || s.graceUntil.After(now) { + continue + } + expired = append(expired, s) + } + if len(expired) == 0 { + return + } + + before := r.eliminatedCount() + for _, s := range expired { + r.eliminateAbsent(s, now) + } + r.applyEliminations(before) + + for _, s := range expired { + r.vacate(s) + } + r.lobbyChanged = true +} + +// eliminateAbsent takes a seat out of a live game once nobody is coming back +// to it. +// +// The engine is told this is a resignation, because that is the only shape it +// has for a player who stops playing. What the room reports is the transport +// fact instead: from everybody else's side this is somebody who left, not +// somebody who chose to give up. +func (r *room) eliminateAbsent(s *seat, now time.Time) { + if r.engine == nil || r.engine.Over() || !r.engine.Alive(s.id) { + return + } + r.outWire[s.id] = noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT + r.engine.Resign(s.id, now) +} + // handleResume rebinds a seat to a new connection and replays the position. // // The replay is built from the engine, never from stored copies of past @@ -925,16 +1142,14 @@ func (r *room) handleResume(m resumeInput) { m.prior.close() } s.sess = m.sess + s.graceUntil = time.Time{} // The seat keeps the name it was given. Re-reading it from the new // connection would let a reconnect rename a player mid-game, including - // into their opponent's name. - if r.disconnected == s { - r.disconnected = nil - } + // into somebody else's name. - // 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. + // Everybody needs the room's state again: this player to render the lobby + // they came back to, the rest to stop watching a disconnect banner for + // somebody who is already back. The run loop sends it to all of them. r.lobbyChanged = true // Before the lobby return below, not after it: a refresh in the lobby is @@ -947,48 +1162,15 @@ func (r *room) handleResume(m resumeInput) { if r.inLobby() { return } - r.sendGameStarted(s) - if state := r.engine.Snapshot(); len(state.History) > 0 { - last := state.History[len(state.History)-1] - opponent := r.opponentSeat(s.id) - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{ - TurnUpdate: &noituv1.TurnUpdate{ - Played: PlayedWord(last, last.Player == s.id), - CurrentSyllable: state.Current, - MyTurn: state.Turn == s.id, - DeadlineUnixMs: state.Deadline.UnixMilli(), - TurnSeq: r.turnSeq, - MyScore: uint32(state.Scores[s.id]), - OpponentScore: uint32(state.Scores[opponent.id]), - ChainLength: uint32(state.ChainLength), - }, - }}) - } -} - -// 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 - } - // Resign on the absent player's behalf, then report the transport reason - // rather than the engine's: from the winner's side this is an opponent who - // left, not one who chose to give up. - r.engine.Resign(r.disconnected.id) - state := r.engine.Snapshot() - for _, s := range r.seats { - if s.sess == nil { - continue - } - s.sess.send(r.gameOverFor(state, s.id, noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT)) + r.sendGameStarted(s, state) + if len(state.History) > 0 { + last := state.History[len(state.History)-1] + r.sendTurnUpdate(s, state, &last) } - r.lobbyChanged = true } -// handleChat delivers one line of text to both seats. +// handleChat delivers one line of text to everybody in the room. func (r *room) handleChat(m chatInput) { // The seat, not the claimed id. A connection the room has already retired // - kicked, or replaced by a reconnect - can still have a frame in flight, @@ -1109,33 +1291,55 @@ func (r *room) freeSeat() int { return -1 } -// seatIDs are the two engine seat names, indexed by position. An id says which +// seatIDs are the 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"} +var seatIDs = [maxPlayers]game.PlayerID{"p1", "p2", "p3", "p4"} -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 { +// seatedCount is how many seats are held, including by players inside their +// reconnect window. +func (r *room) seatedCount() int { + n := 0 for _, s := range r.seats { - if s != nil && s.id != r.owner { - return s + if s != nil { + n++ } } - return nil + return n } -// 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 { +// allConnected reports whether every seated player has a socket. A game cannot +// start without one, because the first thing it does is deal everybody a turn. +func (r *room) allConnected() bool { for _, s := range r.seats { - if s != nil && s.id != mine { - return s.nickname + if s != nil && s.sess == nil { + return false } } - return "" + return true +} + +// guestsReady reports whether every seat but the owner's has said yes. The +// owner's readiness is StartGame itself, which is why they are not counted. +func (r *room) guestsReady() bool { + for _, s := range r.seats { + if s != nil && s.id != r.owner && !s.ready { + return false + } + } + return true +} + +// takenNicknames is every name already in this room except one seat's own, so +// a joiner can be told apart from all of them. +func (r *room) takenNicknames(except game.PlayerID) []string { + names := make([]string, 0, maxPlayers) + for _, s := range r.seats { + if s != nil && s.id != except { + names = append(names, s.nickname) + } + } + return names } // canStart reports whether StartGame would be accepted. The server answers @@ -1144,9 +1348,7 @@ func (r *room) canStart() bool { if r.strategy != nil || !r.inLobby() { return false } - owner, guest := r.ownerSeat(), r.guestSeat() - return owner != nil && owner.sess != nil && - guest != nil && guest.sess != nil && guest.ready + return r.seatedCount() >= minPlayers && r.allConnected() && r.guestsReady() } // vacate frees a seat for good - the player left, was kicked, or never came @@ -1167,9 +1369,6 @@ func (r *room) vacate(s *seat) { r.seats[i] = nil } } - if r.disconnected == s { - r.disconnected = nil - } // The words stay; the attribution goes. Both fields, not just the id: a // retained name lets the next person to ask for that nickname inherit // these messages, because distinguish only compares against the seat that @@ -1213,36 +1412,52 @@ func (r *room) promote() { r.owner = "" } -// broadcastRoomState sends the whole lobby to each occupant. +// broadcastRoomState sends the whole room 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. +// Built per recipient because the field that matters most in it — which of +// these players is you — is relative to who is being told. 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 { + 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, - } - 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}}) + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomState{ + RoomState: &noituv1.RoomState{ + RoomCode: r.code, + CanStart: canStart, + Players: r.playerSlots(s.id), + MaxPlayers: maxPlayers, + MinPlayers: minPlayers, + GraceMs: uint32(r.graceFor.Milliseconds()), + }, + }}) } } +// playerSlots renders the seating for one recipient, in seat order — which is +// also the turn order a game started from this lobby will use. +func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot { + slots := make([]*noituv1.PlayerSlot, 0, maxPlayers) + for _, s := range r.seats { + if s == nil { + continue + } + slots = append(slots, &noituv1.PlayerSlot{ + PlayerId: string(s.id), + Name: s.nickname, + IsMe: s.id == me, + IsOwner: s.id == r.owner, + Ready: s.ready, + Connected: s.sess != nil, + }) + } + return slots +} + func (r *room) broadcastError(code string) { for _, s := range r.seats { if s != nil && s.sess != nil { @@ -1266,15 +1481,6 @@ func (r *room) seatOf(p game.PlayerID) *seat { return nil } -func (r *room) opponentSeat(p game.PlayerID) *seat { - for i, s := range r.seats { - if s != nil && s.id == p { - return r.seats[1-i] - } - } - return nil -} - // frozenBoard is an immutable position for a bot worker to search. // // It satisfies bot.Board without holding the engine. The dictionary is safe to diff --git a/server/internal/wsapi/session.go b/server/internal/wsapi/session.go index 480a669..addfda8 100644 --- a/server/internal/wsapi/session.go +++ b/server/internal/wsapi/session.go @@ -460,7 +460,7 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error { s.toRoom(lobbyInput{sess: s, action: lobbyStart}) case *noituv1.ClientMessage_KickPlayer: - s.toRoom(lobbyInput{sess: s, action: lobbyKick}) + s.toRoom(lobbyInput{sess: s, action: lobbyKick, target: playerIDFor(p.KickPlayer.GetPlayerId())}) case *noituv1.ClientMessage_LeaveRoom: s.toRoom(lobbyInput{sess: s, action: lobbyLeave}) @@ -492,8 +492,8 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error { // 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 +// Rate-limited like a submission: every accepted action is broadcast to every +// seat, 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) { diff --git a/server/internal/wsapi/wire_test.go b/server/internal/wsapi/wire_test.go index 4ce313e..2e0320b 100644 --- a/server/internal/wsapi/wire_test.go +++ b/server/internal/wsapi/wire_test.go @@ -51,7 +51,9 @@ func clientVariants() map[string]*noituv1.ClientMessage { Ready: true, }}}, "client_start_game": {Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}}, - "client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{}}}, + "client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{ + PlayerId: "p3", + }}}, "client_leave_room": {Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}}, "client_send_chat": {Payload: &noituv1.ClientMessage_SendChat{SendChat: &noituv1.SendChat{ Text: "Chào bạn, ván này khó thật!", @@ -65,7 +67,7 @@ func serverVariants() map[string]*noituv1.ServerMessage { "server_welcome": {Payload: &noituv1.ServerMessage_Welcome{Welcome: &noituv1.Welcome{ SessionId: "s-1a2b3c", ResumeToken: "r-8f2c", - ProtocolVersion: 1, + ProtocolVersion: ProtocolVersion, AcceptedNickname: "Người chơi ẩn danh", }}}, "server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{ @@ -75,6 +77,8 @@ func serverVariants() map[string]*noituv1.ServerMessage { DeadlineUnixMs: 1756998020000, TurnSeq: 1, TurnLimitMs: 20000, + Players: fixtureScores(), + TurnPlayerId: "p1", }}}, "server_turn_update": {Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: &noituv1.TurnUpdate{ Played: &noituv1.PlayedWord{ @@ -83,14 +87,15 @@ func serverVariants() map[string]*noituv1.ServerMessage { ByMe: false, Points: 2, Syllables: 2, + PlayerId: "p2", }, CurrentSyllable: "yên", MyTurn: true, DeadlineUnixMs: 1756998040000, TurnSeq: 2, - MyScore: 3, - OpponentScore: 5, ChainLength: 2, + Players: fixtureScores(), + TurnPlayerId: "p3", }}}, "server_move_rejected": {Payload: &noituv1.ServerMessage_MoveRejected{MoveRejected: &noituv1.MoveRejected{ Reason: noituv1.RejectReason_REJECT_REASON_WRONG_LINK, @@ -100,16 +105,26 @@ func serverVariants() map[string]*noituv1.ServerMessage { "server_game_over": {Payload: &noituv1.ServerMessage_GameOver{GameOver: &noituv1.GameOver{ IWon: false, Reason: noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE, - MyScore: 7, ChainLength: 11, - // A repeated string of Vietnamese words: the one field in the - // contract whose encoding is neither a scalar nor a submessage. - Suggestions: []string{"sinh viên", "sinh sôi"}, - }}}, - "server_opponent_left": {Payload: &noituv1.ServerMessage_OpponentLeft{OpponentLeft: &noituv1.OpponentLeft{ - CanReconnect: true, - GraceMs: 30000, + // Ranked, so a fixture that quietly reordered the table would show + // up as ranks that no longer match their positions. + Standings: []*noituv1.PlayerScore{ + {PlayerId: "p2", Name: "Khách mời", Score: 42, Connected: true, Rank: 1}, + {PlayerId: "p1", Name: "Người chơi", IsMe: true, Score: 7, Eliminated: true, Connected: true, Rank: 2}, + {PlayerId: "p3", Name: "Khách 2", Score: 3, Eliminated: true, Rank: 3}, + }, }}}, + "server_player_eliminated": {Payload: &noituv1.ServerMessage_PlayerEliminated{ + PlayerEliminated: &noituv1.PlayerEliminated{ + PlayerId: "p1", + Name: "Người chơi", + IsMe: true, + Reason: noituv1.GameEndReason_GAME_END_REASON_TIMEOUT, + // A repeated string of Vietnamese words: the one field in the + // contract whose encoding is neither a scalar nor a submessage. + Suggestions: []string{"sinh viên", "sinh sôi"}, + }, + }}, "server_error": {Payload: &noituv1.ServerMessage_Error{Error: &noituv1.ServerError{ Code: "room_not_found", Message: "room_not_found", @@ -137,21 +152,35 @@ func serverVariants() map[string]*noituv1.ServerMessage { {FromMe: false, Author: "", Text: "Tôi phải đi", SentUnixMs: 1756998000789}, }, }}}, + // An owner looking at two guests, one ready and here and one ready but + // away. Every boolean in a slot is load-bearing in at least one row, + // and no two rows agree on all of them. "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, + RoomCode: "K7QX", + CanStart: true, + MaxPlayers: 4, + MinPlayers: 2, + GraceMs: 30000, + Players: []*noituv1.PlayerSlot{ + {PlayerId: "p1", Name: "Người chơi", IsMe: true, IsOwner: true, Connected: true}, + {PlayerId: "p2", Name: "Khách mời", Ready: true, Connected: true}, + {PlayerId: "p3", Name: "Khách 2", Ready: true}, + }, }}}, } } +// fixtureScores is the players table both in-game messages carry. Three seats +// in three different states, so a fixture that dropped or reordered a row +// cannot still decode to something plausible. +func fixtureScores() []*noituv1.PlayerScore { + return []*noituv1.PlayerScore{ + {PlayerId: "p1", Name: "Người chơi", IsMe: true, Score: 7, Connected: true}, + {PlayerId: "p2", Name: "Khách mời", Score: 42, Eliminated: true, Connected: true}, + {PlayerId: "p3", Name: "Khách 2", Score: 3}, + } +} + // TestRoundTripEveryVariant marshals and unmarshals each oneof arm. Equality // alone is not enough: an empty arm such as CreateRoom encodes to a payload of // zero bytes, so checking that the case survived is what proves the arm is diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go index ba2a468..4fd0139 100644 --- a/server/internal/wsapi/wsapi_test.go +++ b/server/internal/wsapi/wsapi_test.go @@ -186,6 +186,59 @@ func (c *testClient) await(want string) *noituv1.ServerMessage { return nil } +// mySlot is the recipient's own row in a RoomState, which is the only place +// their role and their readiness live now. +func mySlot(state *noituv1.RoomState) *noituv1.PlayerSlot { + for _, p := range state.GetPlayers() { + if p.GetIsMe() { + return p + } + } + return nil +} + +// otherSlot is the one other player in a two-player room, or nil when the +// recipient is alone in it. Most of the lobby tests below are about two +// people, so this is the shape they read the list in. +func otherSlot(state *noituv1.RoomState) *noituv1.PlayerSlot { + for _, p := range state.GetPlayers() { + if !p.GetIsMe() { + return p + } + } + return nil +} + +// slotFor finds one named seat, for the tests that seat more than two. +func slotFor(state *noituv1.RoomState, id string) *noituv1.PlayerSlot { + for _, p := range state.GetPlayers() { + if p.GetPlayerId() == id { + return p + } + } + return nil +} + +// myScore and otherScore read a two-player turn update the way it used to +// carry the numbers, out of the table that replaced the two fields. +func myScore(u *noituv1.TurnUpdate) uint32 { + for _, p := range u.GetPlayers() { + if p.GetIsMe() { + return p.GetScore() + } + } + return 0 +} + +func otherScore(u *noituv1.TurnUpdate) uint32 { + for _, p := range u.GetPlayers() { + if !p.GetIsMe() { + return p.GetScore() + } + } + return 0 +} + func payloadCase(m *noituv1.ServerMessage) string { switch m.GetPayload().(type) { case *noituv1.ServerMessage_Welcome: @@ -198,8 +251,8 @@ func payloadCase(m *noituv1.ServerMessage) string { return "move_rejected" case *noituv1.ServerMessage_GameOver: return "game_over" - case *noituv1.ServerMessage_OpponentLeft: - return "opponent_left" + case *noituv1.ServerMessage_PlayerEliminated: + return "player_eliminated" case *noituv1.ServerMessage_Error: return "error" case *noituv1.ServerMessage_Pong: @@ -326,9 +379,9 @@ func TestPvPGameAlternatesTurns(t *testing.T) { if !guestUpdate.GetMyTurn() { t.Error("opponent should now be on turn") } - if hostUpdate.GetMyScore() != guestUpdate.GetOpponentScore() { + if myScore(hostUpdate) != otherScore(guestUpdate) { t.Errorf("scores disagree across recipients: %d vs %d", - hostUpdate.GetMyScore(), guestUpdate.GetOpponentScore()) + myScore(hostUpdate), otherScore(guestUpdate)) } } @@ -474,8 +527,11 @@ func TestResumeWithinGraceRestoresGame(t *testing.T) { _ = host.conn.Close(websocket.StatusGoingAway, "") - if left := guest.await("opponent_left").GetOpponentLeft(); !left.GetCanReconnect() { - t.Error("opponent should be told the seat is being held") + // Presence is part of the room's state now, so the seat being held is + // something the other player reads there rather than in a message of its + // own. + if away := otherSlot(guest.await("room_state").GetRoomState()); away == nil || away.GetConnected() { + t.Error("opponent should be shown as away while the seat is held") } // Reconnect with the token and expect the position back. @@ -797,19 +853,31 @@ func TestSanitizeNicknameCountsRunesNotBytes(t *testing.T) { } // TestDistinguishSeparatesIdenticalNames covers the collision the fallback -// creates — two players who both send nothing — and the one it always could: -// two players who choose the same name. +// creates — players who all send nothing — and the one it always could: +// players who choose the same name. func TestDistinguishSeparatesIdenticalNames(t *testing.T) { - if got := distinguish(defaultNickname, defaultNickname); got == defaultNickname { - t.Error("two unnamed players must not render identically") - } - if got := distinguish("Minh", "Thuý"); got != "Minh" { + if got := distinguish("Minh", []string{"Thuý"}); got != "Minh" { t.Errorf("distinct names should be left alone, got %q", got) } - // The suffix must not push the name past the cap. + // A whole room of unnamed players, seated one at a time. Every one of them + // has to end up with a name none of the others is already using: two + // suffixed identically is the same failure as two unsuffixed. + var taken []string + for range maxPlayers { + got := distinguish(defaultNickname, taken) + if slices.Contains(taken, got) { + t.Fatalf("distinguish returned %q, which is already in %v", got, taken) + } + if n := len([]rune(got)); n > maxNicknameRunes { + t.Errorf("distinguished name %q is %d runes, over the %d cap", got, n, maxNicknameRunes) + } + taken = append(taken, got) + } + + // The suffix must not push a name that is already at the cap past it. long := strings.Repeat("a", maxNicknameRunes) - if got := distinguish(long, long); len([]rune(got)) > maxNicknameRunes { + if got := distinguish(long, []string{long}); len([]rune(got)) > maxNicknameRunes { t.Errorf("distinguished name is %d runes, over the %d cap", len([]rune(got)), maxNicknameRunes) } } @@ -988,9 +1056,11 @@ func (c *testClient) startGame() { c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}}) } -func (c *testClient) kickPlayer() { +func (c *testClient) kickPlayer(target string) { c.t.Helper() - c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{}}}) + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_KickPlayer{ + KickPlayer: &noituv1.KickPlayer{PlayerId: target}, + }}) } func (c *testClient) say(text string) { @@ -1037,16 +1107,16 @@ func TestLobbyOpensWithNobodyReady(t *testing.T) { hostState := host.await("room_state").GetRoomState() guestState := guest.await("room_state").GetRoomState() - if !hostState.GetIAmOwner() { + if !mySlot(hostState).GetIsOwner() { t.Error("the player who created the room does not own it") } - if guestState.GetIAmOwner() { + if mySlot(guestState).GetIsOwner() { 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() == "" { + if otherSlot(hostState) == nil || otherSlot(hostState).GetName() == "" { t.Errorf("the owner cannot see who joined: %+v", hostState) } // Nothing starts on its own. Joining used to be the start signal, and a @@ -1065,10 +1135,10 @@ func TestReadyIsRenderedPerRecipient(t *testing.T) { guestState := guest.await("room_state").GetRoomState() hostState := host.await("room_state").GetRoomState() - if !guestState.GetIAmReady() || guestState.GetOpponentReady() { + if !mySlot(guestState).GetReady() || otherSlot(guestState).GetReady() { t.Errorf("the guest should see only their own readiness, got %+v", guestState) } - if hostState.GetIAmReady() || !hostState.GetOpponentReady() { + if mySlot(hostState).GetReady() || !otherSlot(hostState).GetReady() { t.Errorf("the owner should see only the guest's readiness, got %+v", hostState) } if !hostState.GetCanStart() { @@ -1101,8 +1171,8 @@ func TestStartIsRefusedUntilTheGuestIsReady(t *testing.T) { // 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) + if got := host.await("error").GetError().GetCode(); got != "need_more_players" { + t.Errorf("starting alone returned %q, want need_more_players", got) } guest := dial(t, url) @@ -1141,7 +1211,7 @@ func TestOnlyTheOwnerStartsAndKicks(t *testing.T) { 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() + guest.kickPlayer("p1") if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" { t.Errorf("a guest kicking returned %q, want not_the_owner", got) } @@ -1155,7 +1225,7 @@ func TestNextGameNeedsAFreshReady(t *testing.T) { host, guest, first := pvpRoom(t, url) hostState, guestState := resignAndSettle(t, host, guest) - if hostState.GetOpponentReady() || guestState.GetIAmReady() { + if otherSlot(hostState).GetReady() || mySlot(guestState).GetReady() { t.Error("the readiness that started the last game survived it") } if hostState.GetCanStart() { @@ -1194,7 +1264,7 @@ func TestLobbyActionsAreRefusedDuringAGame(t *testing.T) { 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() + host.kickPlayer("p2") if got := host.await("error").GetError().GetCode(); got != "game_in_progress" { t.Errorf("kicking mid-game returned %q, want game_in_progress", got) } @@ -1226,10 +1296,10 @@ func TestLeavingNeedsAnUnreadyFirst(t *testing.T) { // The room survives: the owner is still in it, now on their own. alone := host.await("room_state").GetRoomState() - if alone.GetOpponentPresent() { + if otherSlot(alone) != nil { t.Errorf("the owner still sees a guest who left: %+v", alone) } - if !alone.GetIAmOwner() || alone.GetCanStart() { + if !mySlot(alone).GetIsOwner() || alone.GetCanStart() { t.Errorf("the room the owner is left with is wrong: %+v", alone) } } @@ -1242,19 +1312,19 @@ func TestKickFreesAnUnreadySeatOnly(t *testing.T) { guest.setReady(true) host.await("room_state") - host.kickPlayer() + host.kickPlayer("p2") 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() + host.kickPlayer("p2") 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() { + if got := host.await("room_state").GetRoomState(); otherSlot(got) != nil { t.Errorf("the kicked seat is still occupied: %+v", got) } @@ -1262,7 +1332,7 @@ func TestKickFreesAnUnreadySeatOnly(t *testing.T) { guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ JoinRoom: &noituv1.JoinRoom{RoomCode: code}, }}) - if got := guest.await("room_state").GetRoomState(); !got.GetOpponentPresent() { + if got := guest.await("room_state").GetRoomState(); otherSlot(got) == nil { t.Errorf("a kicked player could not come back: %+v", got) } } @@ -1276,10 +1346,10 @@ func TestOwnerLeavingPromotesTheOtherPlayer(t *testing.T) { host.leaveRoom() promoted := guest.await("room_state").GetRoomState() - if !promoted.GetIAmOwner() { + if !mySlot(promoted).GetIsOwner() { t.Errorf("the player left behind was not promoted: %+v", promoted) } - if promoted.GetOpponentPresent() { + if otherSlot(promoted) != nil { t.Errorf("the owner who left is still shown as present: %+v", promoted) } @@ -1314,7 +1384,7 @@ func TestPromotedOwnerLosesTheirReadiness(t *testing.T) { host.leaveRoom() promoted := guest.await("room_state").GetRoomState() - if promoted.GetIAmReady() { + if mySlot(promoted).GetReady() { t.Errorf("the promoted owner is still carrying a guest's readiness: %+v", promoted) } } @@ -1563,7 +1633,7 @@ func TestChatHistoryIsCappedAndOrdered(t *testing.T) { sess := offlineSession(t, chatHistoryLimit+64) r := &room{ code: "TESTRM", - seats: [2]*seat{{id: "p1", nickname: "Chủ phòng", sess: sess}}, + seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: sess}}, owner: "p1", } @@ -1716,7 +1786,7 @@ func TestChatFromASeatlessConnectionIsRefused(t *testing.T) { _, url := newTestServer(t, chainDict(), Config{}) host, guest, _ := pvpLobby(t, url) - host.kickPlayer() + host.kickPlayer("p2") if got := guest.await("error").GetError().GetCode(); got != "kicked" { t.Fatalf("the guest was told %q rather than being kicked", got) } @@ -1889,7 +1959,7 @@ func TestChatFromAConnectionThatLostItsSeatIsRefused(t *testing.T) { r := &room{ code: "TESTRM", - seats: [2]*seat{{id: "p1", nickname: "Chủ phòng", sess: evicted}}, + seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: evicted}}, owner: "p1", } @@ -1925,7 +1995,7 @@ func TestChatToAFullOutboxIsDroppedNotFatal(t *testing.T) { r := &room{ code: "TESTRM", - seats: [2]*seat{ + seats: [maxPlayers]*seat{ {id: "p1", nickname: "Chủ phòng", sess: sender}, {id: "p2", nickname: "Khách", sess: slow}, }, @@ -2002,10 +2072,10 @@ func TestResumeReclaimsALobbySeat(t *testing.T) { second.await("welcome") back := second.await("room_state").GetRoomState() - if !back.GetIAmOwner() { + if !mySlot(back).GetIsOwner() { t.Errorf("the owner came back as a guest: %+v", back) } - if !back.GetOpponentPresent() || back.GetRoomCode() != code { + if otherSlot(back) == nil || back.GetRoomCode() != code { t.Errorf("the resumed lobby is not the one they left: %+v", back) } diff --git a/web/e2e/helpers.js b/web/e2e/helpers.js index 76adedc..f0c8646 100644 --- a/web/e2e/helpers.js +++ b/web/e2e/helpers.js @@ -100,13 +100,26 @@ export async function say(page, text) { * @param {import('@playwright/test').Page} owner * @param {import('@playwright/test').Page} guest */ -export async function readyAndStart(owner, guest) { - await guest.getByTestId('ready').click(); +export async function readyAndStart(owner, ...guests) { + for (const guest of guests) { + await guest.getByTestId('ready').click(); + } const start = owner.getByTestId('start-game'); await expect(start).toBeEnabled(); await start.click(); } +/** + * The seat rows in the lobby, one per player who is actually in the room. The + * free seats are drawn too, so counting rows would count the room's size + * rather than its occupants. + * + * @param {import('@playwright/test').Page} page + */ +export function seats(page) { + return page.locator('.seat:not(.empty)'); +} + /** * Picks a difficulty by clicking its card. * diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js index 3459727..13978b9 100644 --- a/web/e2e/pvp-game.spec.js +++ b/web/e2e/pvp-game.spec.js @@ -6,6 +6,7 @@ import { playLegalMove, readyAndStart, say, + seats, setNickname, waitForMyTurn } from './helpers.js'; @@ -61,14 +62,16 @@ test.describe('playing a stranger', () => { const code = await createRoom(host, 'Minh'); expect(code).toMatch(/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/); - await expect(host.getByText('Còn trống')).toBeVisible(); + await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi'); await joinRoom(guest, 'Lan', code); await readyAndStart(host, guest); // Both boards come up, and exactly one player is on turn. await waitForMyTurn(host); - await expect(board(guest).turn).toHaveText('Đối thủ đang suy nghĩ…'); + // Whose turn it is, by name: with four seats "the opponent" would stop + // naming anybody. + await expect(board(guest).turn).toHaveText('Đến lượt Minh…'); // Each side is shown the other's server-sanitized name. await expect(host.locator('.who', { hasText: 'Lan' })).toBeVisible(); @@ -150,7 +153,7 @@ test.describe('playing a stranger', () => { 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('ready-p2')).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. @@ -160,8 +163,8 @@ test.describe('playing a stranger', () => { 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(); + // The room survives with its owner in it, and the rest going spare. + await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi'); 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(); @@ -188,7 +191,7 @@ test.describe('playing a stranger', () => { 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 expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi'); await close(); }); @@ -205,7 +208,7 @@ test.describe('playing a stranger', () => { // 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(); + await expect(guest.getByTestId('player-count')).toHaveText('1/4 người chơi'); // And the promotion is real: the next person in can be played against. const thirdContext = await browser.newContext(); @@ -371,7 +374,9 @@ test.describe('playing a stranger', () => { await expect(page.getByTestId('join-error')).toHaveCount(0); }); - test('a full room turns a third player away', async ({ browser }) => { + test('a game in progress turns a latecomer away', async ({ browser }) => { + // The room has seats going spare; arriving in the middle of a game is + // what is refused, not the room being full. const { host, code, close } = await playingPair(browser); void host; @@ -379,7 +384,93 @@ test.describe('playing a stranger', () => { const third = await thirdContext.newPage(); await joinRoom(third, 'Nam', code); - await expect(third.getByTestId('join-error')).toHaveText('Phòng đã đủ người.'); + await expect(third.getByTestId('join-error')).toHaveText('Ván đấu đang diễn ra.'); + + await thirdContext.close(); + await close(); + }); + + test('four players fill the room, and a fifth is turned away', async ({ browser }) => { + const { host, guest, code, close } = await twoPlayers(browser).then(async (pair) => ({ + ...pair, + code: await createRoom(pair.host, 'Minh') + })); + await joinRoom(guest, 'Lan', code); + + /** @type {import('@playwright/test').BrowserContext[]} */ + const extra = []; + /** @type {import('@playwright/test').Page[]} */ + const players = []; + for (const name of ['Nam', 'Hà']) { + const context = await browser.newContext(); + extra.push(context); + const page = await context.newPage(); + players.push(page); + await joinRoom(page, name, code); + } + + await expect(host.getByTestId('player-count')).toHaveText('4/4 người chơi'); + await expect(seats(host)).toHaveCount(4); + + const fifthContext = await browser.newContext(); + const fifth = await fifthContext.newPage(); + await joinRoom(fifth, 'Bình', code); + await expect(fifth.getByTestId('join-error')).toHaveText('Phòng đã đủ người.'); + + // Every guest has to say yes, not just the first one. + await guest.getByTestId('ready').click(); + await expect(host.getByTestId('start-game')).toBeDisabled(); + await readyAndStart(host, ...players); + + // Four boards, and exactly one player on turn. + await waitForMyTurn(host); + for (const page of [guest, ...players]) { + await expect(board(page).turn).toHaveText('Đến lượt Minh…'); + } + + await fifthContext.close(); + for (const context of extra) await context.close(); + await close(); + }); + + test('a player who goes out is a spectator, and the rest play on', async ({ browser }) => { + // Three players, so the game outlives the first knockout. Resignation + // rather than the clock: the turn limit on the test server is two + // minutes, and both go through the same elimination. + const { host, guest, code, close } = await twoPlayers(browser).then(async (pair) => ({ + ...pair, + code: await createRoom(pair.host, 'Minh') + })); + await joinRoom(guest, 'Lan', code); + + const thirdContext = await browser.newContext(); + const third = await thirdContext.newPage(); + await joinRoom(third, 'Nam', code); + + await readyAndStart(host, guest, third); + await waitForMyTurn(host); + + host.on('dialog', (dialog) => dialog.accept()); + await host.getByRole('button', { name: 'Đầu hàng' }).click(); + + // Out, but still in the room: no input, no result screen, and the game + // carrying on in front of them. + await expect(host.getByTestId('eliminated')).toBeVisible(); + await expect(host.getByRole('textbox', { name: 'Nhập từ của bạn' })).toHaveCount(0); + await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toHaveCount(0); + await expect(host.getByTestId('turn-indicator')).toHaveText('Đến lượt Lan…'); + + // The others are told who went out, and one of them is now on turn. + await expect(guest.getByTestId('player-out')).toContainText('Minh'); + await waitForMyTurn(guest); + + // The last two settle it, and everybody sees the same table. + guest.on('dialog', (dialog) => dialog.accept()); + await guest.getByRole('button', { name: 'Đầu hàng' }).click(); + + await expect(third.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible(); + await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible(); + await expect(host.getByTestId('standings').locator('li')).toHaveCount(3); await thirdContext.close(); await close(); diff --git a/web/e2e/reconnect.spec.js b/web/e2e/reconnect.spec.js index 0f40802..59a053b 100644 --- a/web/e2e/reconnect.spec.js +++ b/web/e2e/reconnect.spec.js @@ -67,7 +67,7 @@ test.describe('losing the connection', () => { // this tab's session storage, so coming back can reclaim the seat. await guest.goto('about:blank'); - await expect(host.getByTestId('opponent-status')).toContainText('Đối thủ mất kết nối', { + await expect(host.getByTestId('away-p2')).toContainText('Lan mất kết nối', { timeout: 20_000 }); @@ -76,7 +76,7 @@ test.describe('losing the connection', () => { // The seat is restored: the host stops waiting, and the returning player // is looking at the same position rather than the lobby. - await expect(host.getByTestId('opponent-status')).toHaveCount(0, { timeout: 20_000 }); + await expect(host.getByTestId('away-p2')).toHaveCount(0, { timeout: 20_000 }); await expect(board(guest).syllable).toHaveText(hostSyllable, { timeout: 20_000 }); await expect(board(guest).turn).toHaveText('Đến lượt bạn'); @@ -94,7 +94,7 @@ test.describe('losing the connection', () => { await expect(host.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible({ timeout: 45_000 }); - await expect(host.getByText('Đối thủ đã rời trận.')).toBeVisible(); + await expect(host.getByText('Có người đã rời trận.')).toBeVisible(); await close(); }); diff --git a/web/src/lib/components/ChainHistory.svelte b/web/src/lib/components/ChainHistory.svelte index b94d7ab..2a1ce53 100644 --- a/web/src/lib/components/ChainHistory.svelte +++ b/web/src/lib/components/ChainHistory.svelte @@ -27,6 +27,11 @@ {#each entries as entry, index}
  • {entry.word} + + {#if !entry.opening && !entry.byMe && game.nameOf(entry.playerId)} + {game.nameOf(entry.playerId)} + {/if} {#if entry.syllables > 2} {entry.syllables} {t.syllableUnit} @@ -116,6 +121,11 @@ font-size: 0.8rem; } + .by { + color: var(--text-muted); + font-size: 0.8rem; + } + .badge { padding: 1px 7px; border-radius: 999px; diff --git a/web/src/lib/components/GameBoard.svelte b/web/src/lib/components/GameBoard.svelte index e1e2d15..0c63f40 100644 --- a/web/src/lib/components/GameBoard.svelte +++ b/web/src/lib/components/GameBoard.svelte @@ -4,7 +4,7 @@ import CountdownRing from '$lib/components/CountdownRing.svelte'; import ScoreBoard from '$lib/components/ScoreBoard.svelte'; import WordInput from '$lib/components/WordInput.svelte'; - import { t } from '$lib/i18n/vi.js'; + import { fill, t } from '$lib/i18n/vi.js'; import { game } from '$lib/stores/game.svelte.js'; /** @@ -13,7 +13,6 @@ * that arrives as a snippet rather than as a branch in here. * * @type {{ - * opponentLabel: string, * modeLabel?: string, * onsubmit: (word: string) => boolean, * onresign: () => void, @@ -22,7 +21,15 @@ * chat?: import('svelte').Snippet * }} */ - let { opponentLabel, modeLabel = '', onsubmit, onresign, gameOver, banner, chat } = $props(); + let { modeLabel = '', onsubmit, onresign, gameOver, banner, chat } = $props(); + + // Whose turn it is, said by name. With four people at the table "the + // opponent is thinking" stops naming anybody. + const turnLabel = $derived.by(() => { + if (game.state.myTurn) return t.yourTurn; + const name = game.nameOf(game.state.turnPlayerId); + return name ? fill(t.playerTurn, { name }) : t.opponentTurn; + });
    @@ -31,7 +38,7 @@ {#if modeLabel}{modeLabel}{/if} - + {#if banner}{@render banner()}{/if} @@ -48,9 +55,7 @@
    -

    - {game.state.myTurn ? t.yourTurn : t.opponentTurn} -

    +

    {turnLabel}

    {t.currentSyllable} {game.state.currentSyllable || '…'} @@ -58,7 +63,14 @@

    - + + {#if game.iAmOut} +

    {t.spectating}

    + {:else} + + {/if} {/if} @@ -68,7 +80,7 @@ a condition somebody has to remember to check. --> {#if chat}{@render chat()}{/if} - {#if game.state.phase === 'playing'} + {#if game.state.phase === 'playing' && !game.iAmOut} {/if}
    @@ -147,6 +159,15 @@ line-height: 1; } + .spectating { + margin: 0; + padding: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + text-align: center; + } + .resign { align-self: center; padding: 8px 16px; diff --git a/web/src/lib/components/GameOverPanel.svelte b/web/src/lib/components/GameOverPanel.svelte index ec8aed9..8648fd3 100644 --- a/web/src/lib/components/GameOverPanel.svelte +++ b/web/src/lib/components/GameOverPanel.svelte @@ -12,8 +12,13 @@ */ let { isRecord, onrematch, onhome } = $props(); - /** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number, suggestions: string[] } | null} */ const result = $derived(game.state.result); + const standings = $derived(game.state.standings); + // What the position still had when this player lost it. It arrives with + // their knockout rather than with the result, because by the time a game + // with four people in it ends, the position that beat them is nobody + // else's position. + const elimination = $derived(game.state.elimination); /** Hands the finished chain to the player as a text file to keep. */ function exportHistory() { @@ -21,7 +26,7 @@ const text = chainToText({ chain: game.state.chain, result, - opponentLabel: game.state.opponentName || t.opponent, + nameOf: (id) => game.nameOf(id), at }); downloadText(historyFilename(at), text); @@ -36,6 +41,23 @@

    {endReasonMessages[result.reason]}

    {/if} + {#if standings.length > 0} + +
      + {#each standings as player (player.playerId)} +
    1. + {player.rank} + + {player.isMe ? game.state.nickname || t.you : player.name || t.someone} + + {player.score} {t.pointsUnit} + {#if player.rank === 1}🏆{/if} +
    2. + {/each} +
    + {/if} +
    {t.finalScore}
    @@ -47,16 +69,16 @@
    - {#if !result.iWon} - - {#if result.suggestions.length > 0} + {#if elimination} + + {#if elimination.suggestions.length > 0}

    {t.suggestionsTitle}

      - {#each result.suggestions as word} + {#each elimination.suggestions as word}
    • {word}
    • {/each}
    @@ -111,6 +133,53 @@ color: var(--text-muted); } + .standings { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + text-align: left; + } + + .standings li { + display: flex; + align-items: baseline; + gap: 10px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-alt); + } + + .standings li.me { + border-color: var(--accent); + } + + .standings li.winner { + background: var(--accent-soft); + } + + .rank { + color: var(--text-muted); + font-variant-numeric: tabular-nums; + } + + .standings .name { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .points { + margin-left: auto; + color: var(--text-muted); + font-size: 0.85rem; + font-variant-numeric: tabular-nums; + } + .stats { display: flex; justify-content: center; diff --git a/web/src/lib/components/Lobby.svelte b/web/src/lib/components/Lobby.svelte index d478037..4612f4d 100644 --- a/web/src/lib/components/Lobby.svelte +++ b/web/src/lib/components/Lobby.svelte @@ -1,6 +1,6 @@
    @@ -30,37 +34,57 @@ {/if} -
      -
    • - {s.nickname || t.you} - {s.isOwner ? t.owner : t.guest} - - {#if !s.isOwner} - {s.isReady ? t.isReady : t.notReady} - {/if} -
    • +

      + {fill(t.playerCount, { n: s.roomPlayers.length, max: s.maxPlayers })} +

      -
    • - {#if s.opponentPresent} - {s.opponentName || t.opponent} - {s.isOwner ? t.guest : t.owner} - {#if !s.opponentConnected} - {t.offline} - {:else if s.isOwner} - - {s.opponentReady ? t.isReady : t.notReady} - - {/if} - {:else} +
        + {#each s.roomPlayers as player (player.playerId)} +
      • + {player.isMe ? s.nickname || t.you : player.name} + {player.isOwner ? t.owner : t.guest} + + + {#if !player.connected} + {t.offline} + {:else if !player.isOwner} + + + {player.ready ? t.isReady : t.notReady} + + {/if} + + + {#if game.isOwner && !player.isMe} + + {/if} + +
      • + {/each} + + {#each empties as index (index)} +
      • {t.emptySeat} - {/if} -
      • + + {/each}

      - {#if s.isOwner} - {s.opponentPresent ? t.ownerStartsHint : t.waitingForOpponent} - {:else if s.isReady} + {#if game.isOwner && shortHanded} + {fill(t.ownerNeedsMore, { n: s.minPlayers })} + {:else if game.isOwner} + {t.ownerStartsHint} + {:else if game.isReady} {t.waitingForStart} {:else} {t.guestReadyHint} @@ -68,7 +92,7 @@

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

      {t.unreadyToLeave}

      {/if}
    @@ -114,6 +133,13 @@ gap: 14px; } + .count { + margin: 0; + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 600; + } + .seats { display: flex; flex-direction: column; @@ -161,8 +187,16 @@ font-size: 0.75rem; } - .state { + /* One right-hand group, so a row keeps its shape whether or not it has a + readiness to show and whether or not the reader may kick it. */ + .right { + display: inline-flex; + align-items: center; + gap: 8px; margin-left: auto; + } + + .state { color: var(--text-muted); font-size: 0.8rem; } @@ -171,6 +205,22 @@ color: var(--danger); } + .kick { + width: 26px; + height: 26px; + padding: 0; + border: 1px solid var(--border); + border-radius: 999px; + background: transparent; + color: var(--text-muted); + font-size: 1rem; + line-height: 1; + } + + .kick:disabled { + opacity: 0.35; + } + .hint { margin: 0; color: var(--text-muted); diff --git a/web/src/lib/components/OpponentStatus.svelte b/web/src/lib/components/OpponentStatus.svelte deleted file mode 100644 index 1780178..0000000 --- a/web/src/lib/components/OpponentStatus.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -{#if away} - -{/if} - - diff --git a/web/src/lib/components/PlayerStatus.svelte b/web/src/lib/components/PlayerStatus.svelte new file mode 100644 index 0000000..021914a --- /dev/null +++ b/web/src/lib/components/PlayerStatus.svelte @@ -0,0 +1,88 @@ + + +{#if game.iAmOut} + + +{:else if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'} + +{/if} + +{#each away as player (player.playerId)} + {@const seconds = secondsLeft(player.playerId)} + +{/each} + + diff --git a/web/src/lib/components/ScoreBoard.svelte b/web/src/lib/components/ScoreBoard.svelte index 09b154b..86c8de8 100644 --- a/web/src/lib/components/ScoreBoard.svelte +++ b/web/src/lib/components/ScoreBoard.svelte @@ -2,41 +2,63 @@ import { t } from '$lib/i18n/vi.js'; import { game } from '$lib/stores/game.svelte.js'; - /** @type {{ opponentLabel?: string }} */ - let { opponentLabel = t.opponent } = $props(); - - const me = $derived(game.state.nickname || t.you); + /** + * The table of the game on screen: one row per player, in turn order. + * + * It doubles as the turn indicator — the row that is lit is the player to + * act — which is why there is no second one anywhere on the board. + */ + const s = $derived(game.state); + // Standings once the game is over, so the board settles into the result + // rather than freezing on the last position. + const players = $derived(s.phase === 'over' && s.standings.length ? s.standings : s.gamePlayers); -
    -
    - {me} - {game.state.myScore} -
    - -
    - {opponentLabel} - {game.state.opponentScore} -
    -
    +
      + {#each players as player (player.playerId)} +
    • + + {player.isMe ? s.nickname || t.you : player.name || t.someone} + {#if !player.connected && !player.eliminated} + ⚠ + {/if} + + {player.score} + {#if player.rank === 1} + {t.winnerBadge} + {:else if player.eliminated} + {t.eliminated} + {/if} +
    • + {/each} +
    diff --git a/web/src/lib/history-export.js b/web/src/lib/history-export.js index 5b37180..551d8fd 100644 --- a/web/src/lib/history-export.js +++ b/web/src/lib/history-export.js @@ -38,14 +38,18 @@ export function historyFilename(at = new Date()) { * the newest-first ordering is a reading aid for the live board, not the * shape of the game. * + * `nameOf` resolves a seat id to the name that seat was playing under. It is + * passed in rather than read from the store so the transcript can be built and + * tested without one. + * * @param {object} args * @param {import('$lib/stores/game.svelte.js').ChainEntry[]} args.chain * @param {{ iWon: boolean, myScore: number, chainLength: number } | null} [args.result] - * @param {string} [args.opponentLabel] + * @param {(playerId: string) => string} [args.nameOf] * @param {Date} [args.at] * @returns {string} */ -export function chainToText({ chain, result = null, opponentLabel = t.opponent, at = new Date() }) { +export function chainToText({ chain, result = null, nameOf = () => '', at = new Date() }) { const lines = [`${t.appName} — ${stamp(at)}`]; if (result) { @@ -63,7 +67,9 @@ export function chainToText({ chain, result = null, opponentLabel = t.opponent, lines.push(`${number} (${t.exportOpening})`); return; } - const who = entry.byMe ? t.you : opponentLabel; + // A four-way chain has to say which of the others played a word, not + // merely that it was not this player's. + const who = entry.byMe ? t.you : nameOf(entry.playerId) || t.opponent; const points = entry.points > 0 ? ` +${entry.points}` : ''; lines.push(`${number} — ${who}${points}`); }); diff --git a/web/src/lib/i18n/vi.js b/web/src/lib/i18n/vi.js index 489519b..c56d3d6 100644 --- a/web/src/lib/i18n/vi.js +++ b/web/src/lib/i18n/vi.js @@ -25,6 +25,7 @@ export const t = { yourTurn: 'Đến lượt bạn', opponentTurn: 'Đối thủ đang suy nghĩ…', + playerTurn: 'Đến lượt {name}…', currentSyllable: 'Nối tiếp tiếng', wordInputPlaceholder: 'Nhập từ của bạn', submit: 'Gửi', @@ -32,6 +33,7 @@ export const t = { chainEmpty: 'Chưa có từ nào.', you: 'Bạn', opponent: 'Đối thủ', + someone: 'Người chơi', scoreLabel: 'Điểm', syllableUnit: 'tiếng', correctedFrom: 'Bạn gõ “{typed}”, từ đúng là “{word}”.', @@ -47,6 +49,9 @@ export const t = { lost: 'Bạn thua.', finalScore: 'Điểm cuối cùng', chainLength: 'Số từ trong chuỗi', + standingsTitle: 'Kết quả', + winnerBadge: 'Vô địch', + pointsUnit: 'điểm', newRecord: 'Kỷ lục mới!', suggestionsTitle: 'Bạn có thể nối', noSuggestions: 'Không còn từ nào bắt đầu bằng tiếng “{syllable}”. Ai gặp thế này cũng chịu thôi!', @@ -72,7 +77,7 @@ export const t = { copyLink: 'Sao chép liên kết mời', shareLink: 'Chia sẻ', copied: 'Đã sao chép', - waitingForOpponent: 'Đang chờ đối thủ vào phòng…', + waitingForOpponent: 'Đang chờ người chơi vào phòng…', leaveRoom: 'Rời phòng', shareInvite: 'Vào chơi nối từ với tôi', @@ -85,6 +90,7 @@ export const t = { lobbyTitle: 'Phòng chờ', owner: 'Chủ phòng', guest: 'Khách', + playerCount: '{n}/{max} người chơi', ready: 'Sẵn sàng', unready: 'Chưa sẵn sàng', isReady: 'Đã sẵn sàng', @@ -94,14 +100,18 @@ export const t = { 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.', + ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi mọi người đã sẵn sàng.', + ownerNeedsMore: 'Cần ít nhất {n} người mới bắt đầu được. Gửi mã phòng cho bạn bè!', 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.', + playerDisconnected: '{name} mất kết nối…', + playerDisconnectedIn: '{name} mất kết nối… ({n}s)', + youAreOut: 'Bạn đã bị loại. Ván đấu vẫn đang tiếp tục.', + spectating: 'Bạn đang xem ván đấu.', + playerOut: '{name} đã bị loại.', + eliminated: 'Đã bị loại', attributionIntro: 'Từ điển dựa trên', attributionSource: 'minhqnd/dictionary', @@ -149,7 +159,7 @@ export const endReasonMessages = { [GameEndReason.UNSPECIFIED]: '', [GameEndReason.TIMEOUT]: 'Hết thời gian suy nghĩ.', [GameEndReason.NO_LEGAL_MOVE]: 'Không còn từ nào nối được.', - [GameEndReason.OPPONENT_LEFT]: 'Đối thủ đã rời trận.', + [GameEndReason.OPPONENT_LEFT]: 'Có người đã rời trận.', [GameEndReason.RESIGNED]: 'Có người đầu hàng.' }; @@ -171,15 +181,16 @@ export const errorMessages = { 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.', kicked: 'Bạn đã bị mời ra khỏi phòng.', + cannot_kick_self: 'Bạn không thể tự mời mình 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.', + need_more_players: 'Cần ít nhất hai người chơi mới bắt đầu được.', + not_everyone_ready: 'Vẫn còn người 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.', + player_offline: 'Vẫn còn người đ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.', diff --git a/web/src/lib/proto/noitu/v1/game_pb.d.ts b/web/src/lib/proto/noitu/v1/game_pb.d.ts index 374fd3c..0aae5b7 100644 --- a/web/src/lib/proto/noitu/v1/game_pb.d.ts +++ b/web/src/lib/proto/noitu/v1/game_pb.d.ts @@ -136,9 +136,9 @@ export declare type Resign = Message<"noitu.v1.Resign"> & { export declare const ResignSchema: GenMessage; /** - * SetReady is the guest declaring themselves ready, or taking it back. + * SetReady is a guest declaring themselves ready, or taking it back. * - * Only the guest has a readiness to set. The owner's is implied by StartGame: + * Only guests have 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. * @@ -159,7 +159,8 @@ export declare const SetReadySchema: GenMessage; /** * StartGame is the owner beginning the game the lobby has agreed on. It is - * refused unless the guest is seated, connected and ready. + * refused unless at least one guest is seated and every seated guest is + * connected and ready. * * @generated from message noitu.v1.StartGame */ @@ -173,13 +174,20 @@ export declare type StartGame = Message<"noitu.v1.StartGame"> & { export declare const StartGameSchema: GenMessage; /** - * KickPlayer is the owner freeing the guest's seat. Refused while the guest is + * KickPlayer is the owner freeing one seat. Refused while that player 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"> & { + /** + * Which seat, from RoomState.players. A room holds up to four people, so + * "the other one" stopped being an answer. + * + * @generated from field: string player_id = 1; + */ + playerId: string; }; /** @@ -394,6 +402,15 @@ export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & { * @generated from field: string typed = 5; */ typed: string; + + /** + * Which seat played it. by_me answers "was this mine"; with four people at + * the table the chain also has to say whose the other words were, and a seat + * id says that without the client matching display names. + * + * @generated from field: string player_id = 6; + */ + playerId: string; }; /** @@ -403,8 +420,125 @@ export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & { export declare const PlayedWordSchema: GenMessage; /** - * GameStarted is rendered per recipient: my_turn is true for exactly one of - * the two players. + * PlayerSlot is one seat in the room, rendered for one recipient. + * + * The recipient's own row is in the list like everybody else's, marked by + * is_me. That is deliberately the only way to find yourself: a separate + * i_am_owner alongside an is_owner in the list would be two encodings of one + * fact, and two ways for a client to disagree with the server. + * + * @generated from message noitu.v1.PlayerSlot + */ +export declare type PlayerSlot = Message<"noitu.v1.PlayerSlot"> & { + /** + * Stable for as long as this player holds the seat. Not stable across a + * seat being vacated and refilled, which is exactly when a name stops + * meaning the same person too. + * + * @generated from field: string player_id = 1; + */ + playerId: string; + + /** + * Always server-sanitized, as everywhere else another player's name appears. + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: bool is_me = 3; + */ + isMe: boolean; + + /** + * @generated from field: bool is_owner = 4; + */ + isOwner: boolean; + + /** + * Always false for the owner, whose readiness is StartGame itself. + * + * @generated from field: bool ready = 5; + */ + ready: boolean; + + /** + * False while this player is inside their reconnect window. + * + * @generated from field: bool connected = 6; + */ + connected: boolean; +}; + +/** + * Describes the message noitu.v1.PlayerSlot. + * Use `create(PlayerSlotSchema)` to create a new message. + */ +export declare const PlayerSlotSchema: GenMessage; + +/** + * PlayerScore is one player in a running or finished game. + * + * Separate from PlayerSlot because they answer different questions: a slot is + * about the room, a score is about the game being played in it. A player who + * has been eliminated still has both — being out of the game is not being out + * of the room. + * + * @generated from message noitu.v1.PlayerScore + */ +export declare type PlayerScore = Message<"noitu.v1.PlayerScore"> & { + /** + * @generated from field: string player_id = 1; + */ + playerId: string; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: bool is_me = 3; + */ + isMe: boolean; + + /** + * @generated from field: uint32 score = 4; + */ + score: number; + + /** + * True once this player has been knocked out. They keep their seat, their + * score and their words; they simply no longer get a turn. + * + * @generated from field: bool eliminated = 5; + */ + eliminated: boolean; + + /** + * @generated from field: bool connected = 6; + */ + connected: boolean; + + /** + * Final placing, 1 for the winner. Zero while the game is still running, + * which is what tells the two apart without a second field. + * + * @generated from field: uint32 rank = 7; + */ + rank: number; +}; + +/** + * Describes the message noitu.v1.PlayerScore. + * Use `create(PlayerScoreSchema)` to create a new message. + */ +export declare const PlayerScoreSchema: GenMessage; + +/** + * GameStarted is rendered per recipient: my_turn is true for exactly one + * player. * * @generated from message noitu.v1.GameStarted */ @@ -441,6 +575,21 @@ export declare type GameStarted = Message<"noitu.v1.GameStarted"> & { * @generated from field: uint32 turn_limit_ms = 6; */ turnLimitMs: number; + + /** + * Everyone playing, in turn order. + * + * @generated from field: repeated noitu.v1.PlayerScore players = 7; + */ + players: PlayerScore[]; + + /** + * Whose turn it is. my_turn above says whether it is yours; this says whose + * it is when it is not, which a two-player game never had to. + * + * @generated from field: string turn_player_id = 8; + */ + turnPlayerId: string; }; /** @@ -450,9 +599,13 @@ export declare type GameStarted = Message<"noitu.v1.GameStarted"> & { export declare const GameStartedSchema: GenMessage; /** - * TurnUpdate follows every accepted move and goes to both players, serialized + * TurnUpdate follows every turn change and goes to every player, serialized * once per recipient so by_me and my_turn are correct for each. * + * played is absent when the turn moved without a word being played, which is + * what an elimination does: the syllable and the used set survive the player + * who could not answer them. + * * @generated from message noitu.v1.TurnUpdate */ export declare type TurnUpdate = Message<"noitu.v1.TurnUpdate"> & { @@ -481,20 +634,22 @@ export declare type TurnUpdate = Message<"noitu.v1.TurnUpdate"> & { */ turnSeq: number; - /** - * @generated from field: uint32 my_score = 6; - */ - myScore: number; - - /** - * @generated from field: uint32 opponent_score = 7; - */ - opponentScore: number; - /** * @generated from field: uint32 chain_length = 8; */ chainLength: number; + + /** + * Everyone playing, in turn order, with scores as they stand. + * + * @generated from field: repeated noitu.v1.PlayerScore players = 9; + */ + players: PlayerScore[]; + + /** + * @generated from field: string turn_player_id = 10; + */ + turnPlayerId: string; }; /** @@ -530,8 +685,8 @@ export declare type MoveRejected = Message<"noitu.v1.MoveRejected"> & { export declare const MoveRejectedSchema: GenMessage; /** - * GameOver is rendered per recipient: i_won is true for exactly one of the two - * players. + * GameOver is rendered per recipient: i_won is true for exactly one player, + * the one still standing when everybody else had been eliminated. * * @generated from message noitu.v1.GameOver */ @@ -546,25 +701,20 @@ export declare type GameOver = Message<"noitu.v1.GameOver"> & { */ reason: GameEndReason; - /** - * @generated from field: uint32 my_score = 3; - */ - myScore: number; - /** * @generated from field: uint32 chain_length = 4; */ chainLength: number; /** - * A few words that could still have been played from the position the game - * ended on, filled only for the player who lost — the winner is not the one - * who needed them. An empty list on a loss is itself the answer: the - * position was a dead end and nobody could have answered it. + * The final table, best first: the player left standing, then the others in + * reverse order of elimination. Outlasting somebody is what beats them, so + * the ranking is finishing order and each score is reported beside it rather + * than deciding it. * - * @generated from field: repeated string suggestions = 5; + * @generated from field: repeated noitu.v1.PlayerScore standings = 6; */ - suggestions: string[]; + standings: PlayerScore[]; }; /** @@ -574,25 +724,49 @@ export declare type GameOver = Message<"noitu.v1.GameOver"> & { export declare const GameOverSchema: GenMessage; /** - * @generated from message noitu.v1.OpponentLeft + * PlayerEliminated is one player knocked out of a game that is still running. + * + * Rendered per recipient like everything else in a room, and the only message + * whose contents differ by more than a flag: suggestions are filled in solely + * for the player who went out, because they are the one who was stuck. + * + * @generated from message noitu.v1.PlayerEliminated */ -export declare type OpponentLeft = Message<"noitu.v1.OpponentLeft"> & { +export declare type PlayerEliminated = Message<"noitu.v1.PlayerEliminated"> & { /** - * @generated from field: bool can_reconnect = 1; + * @generated from field: string player_id = 1; */ - canReconnect: boolean; + playerId: string; /** - * @generated from field: uint32 grace_ms = 2; + * @generated from field: string name = 2; */ - graceMs: number; + name: string; + + /** + * @generated from field: bool is_me = 3; + */ + isMe: boolean; + + /** + * @generated from field: noitu.v1.GameEndReason reason = 4; + */ + reason: GameEndReason; + + /** + * A few words the position still had, for the player who just lost it. An + * empty list is itself the answer: nobody could have answered that syllable. + * + * @generated from field: repeated string suggestions = 5; + */ + suggestions: string[]; }; /** - * Describes the message noitu.v1.OpponentLeft. - * Use `create(OpponentLeftSchema)` to create a new message. + * Describes the message noitu.v1.PlayerEliminated. + * Use `create(PlayerEliminatedSchema)` to create a new message. */ -export declare const OpponentLeftSchema: GenMessage; +export declare const PlayerEliminatedSchema: GenMessage; /** * ServerError.message is a UI key such as "room_not_found", never prose: all @@ -640,15 +814,16 @@ export declare type Pong = Message<"noitu.v1.Pong"> & { export declare const PongSchema: GenMessage; /** - * RoomState is the whole lobby, rendered for one recipient, and it is the only + * RoomState is the whole room, 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. + * see — a seat filled or freed, a readiness set, an owner promoted, somebody + * dropping or coming back — 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. + * It describes the room, not the game, so it is meaningful during one too: + * while a game runs this is what carries presence, which is why there is no + * separate message for a player disconnecting. * * @generated from message noitu.v1.RoomState */ @@ -658,13 +833,6 @@ export declare type RoomState = Message<"noitu.v1.RoomState"> & { */ roomCode: string; - /** - * True for the player who may start the game and kick the other. - * - * @generated from field: bool i_am_owner = 2; - */ - iAmOwner: boolean; - /** * Whether StartGame would be accepted right now. The server decides this * because it owns every condition that feeds it. @@ -674,38 +842,35 @@ export declare type RoomState = Message<"noitu.v1.RoomState"> & { canStart: boolean; /** - * The recipient's own readiness. Always false for the owner, whose readiness - * is StartGame itself. + * Everyone seated, in seat order, which is also the turn order a game will + * use. Always includes the recipient, marked is_me. * - * @generated from field: bool i_am_ready = 4; + * @generated from field: repeated noitu.v1.PlayerSlot players = 9; */ - iAmReady: boolean; + players: PlayerSlot[]; /** - * False when the other seat is empty; the fields below are then meaningless. + * How many seats the room has and how many players a game needs. Sent + * rather than compiled in, so the lobby draws whatever the server allows and + * raising the limit does not need a client deploy. * - * @generated from field: bool opponent_present = 5; + * @generated from field: uint32 max_players = 10; */ - opponentPresent: boolean; + maxPlayers: number; /** - * Always server-sanitized, as everywhere else another player's name appears. - * - * @generated from field: string opponent_name = 6; + * @generated from field: uint32 min_players = 11; */ - opponentName: string; + minPlayers: number; /** - * @generated from field: bool opponent_ready = 7; - */ - opponentReady: boolean; - - /** - * False while the other player is inside their reconnect window. + * How long a seat is held for a player who has dropped. The client counts + * down against it for anybody whose connected is false; the server still + * decides when the seat is actually forfeit. * - * @generated from field: bool opponent_connected = 8; + * @generated from field: uint32 grace_ms = 12; */ - opponentConnected: boolean; + graceMs: number; }; /** @@ -816,12 +981,6 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & { */ value: GameOver; case: "gameOver"; - } | { - /** - * @generated from field: noitu.v1.OpponentLeft opponent_left = 8; - */ - value: OpponentLeft; - case: "opponentLeft"; } | { /** * @generated from field: noitu.v1.ServerError error = 9; @@ -852,6 +1011,12 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & { */ value: ChatHistory; case: "chatHistory"; + } | { + /** + * @generated from field: noitu.v1.PlayerEliminated player_eliminated = 15; + */ + value: PlayerEliminated; + case: "playerEliminated"; } | { case: undefined; value?: undefined }; }; diff --git a/web/src/lib/proto/noitu/v1/game_pb.js b/web/src/lib/proto/noitu/v1/game_pb.js index cc2124e..4e85b97 100644 --- a/web/src/lib/proto/noitu/v1/game_pb.js +++ b/web/src/lib/proto/noitu/v1/game_pb.js @@ -21,7 +21,7 @@ import { enumDesc, fileDesc, messageDesc, tsEnum } from "@bufbuild/protobuf/code * Describes the file noitu/v1/game.proto. */ export const file_noitu_v1_game = /*@__PURE__*/ - fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiDAoKS2lja1BsYXllciILCglMZWF2ZVJvb20iGAoIU2VuZENoYXQSDAoEdGV4dBgBIAEoCSIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIpAECg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIABInCglzZXRfcmVhZHkYCSABKAsyEi5ub2l0dS52MS5TZXRSZWFkeUgAEikKCnN0YXJ0X2dhbWUYCiABKAsyEy5ub2l0dS52MS5TdGFydEdhbWVIABIrCgtraWNrX3BsYXllchgLIAEoCzIULm5vaXR1LnYxLktpY2tQbGF5ZXJIABIpCgpsZWF2ZV9yb29tGAwgASgLMhMubm9pdHUudjEuTGVhdmVSb29tSAASJwoJc2VuZF9jaGF0GA0gASgLMhIubm9pdHUudjEuU2VuZENoYXRIAEIJCgdwYXlsb2FkSgQICBAJImgKB1dlbGNvbWUSEgoKc2Vzc2lvbl9pZBgBIAEoCRIUCgxyZXN1bWVfdG9rZW4YAiABKAkSGAoQcHJvdG9jb2xfdmVyc2lvbhgDIAEoDRIZChFhY2NlcHRlZF9uaWNrbmFtZRgEIAEoCSJbCgpQbGF5ZWRXb3JkEgwKBHdvcmQYASABKAkSDQoFYnlfbWUYAiABKAgSDgoGcG9pbnRzGAMgASgNEhEKCXN5bGxhYmxlcxgEIAEoDRINCgV0eXBlZBgFIAEoCSKRAQoLR2FtZVN0YXJ0ZWQSFAoMb3BlbmluZ193b3JkGAEgASgJEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhUKDXR1cm5fbGltaXRfbXMYBiABKA0iyQEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIQCghteV9zY29yZRgGIAEoDRIWCg5vcHBvbmVudF9zY29yZRgHIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0iVgoMTW92ZVJlamVjdGVkEiYKBnJlYXNvbhgBIAEoDjIWLm5vaXR1LnYxLlJlamVjdFJlYXNvbhIMCgR3b3JkGAIgASgJEhAKCHR1cm5fc2VxGAMgASgNIn8KCEdhbWVPdmVyEg0KBWlfd29uGAEgASgIEicKBnJlYXNvbhgCIAEoDjIXLm5vaXR1LnYxLkdhbWVFbmRSZWFzb24SEAoIbXlfc2NvcmUYAyABKA0SFAoMY2hhaW5fbGVuZ3RoGAQgASgNEhMKC3N1Z2dlc3Rpb25zGAUgAygJIjcKDE9wcG9uZW50TGVmdBIVCg1jYW5fcmVjb25uZWN0GAEgASgIEhAKCGdyYWNlX21zGAIgASgNIiwKC1NlcnZlckVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSI2CgRQb25nEhYKDmNsaWVudF90aW1lX21zGAEgASgDEhYKDnNlcnZlcl90aW1lX21zGAIgASgDIr4BCglSb29tU3RhdGUSEQoJcm9vbV9jb2RlGAEgASgJEhIKCmlfYW1fb3duZXIYAiABKAgSEQoJY2FuX3N0YXJ0GAMgASgIEhIKCmlfYW1fcmVhZHkYBCABKAgSGAoQb3Bwb25lbnRfcHJlc2VudBgFIAEoCBIVCg1vcHBvbmVudF9uYW1lGAYgASgJEhYKDm9wcG9uZW50X3JlYWR5GAcgASgIEhoKEm9wcG9uZW50X2Nvbm5lY3RlZBgIIAEoCCJSCgtDaGF0TWVzc2FnZRIPCgdmcm9tX21lGAEgASgIEg4KBmF1dGhvchgCIAEoCRIMCgR0ZXh0GAMgASgJEhQKDHNlbnRfdW5peF9tcxgEIAEoAyI2CgtDaGF0SGlzdG9yeRInCghtZXNzYWdlcxgBIAMoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlIooECg1TZXJ2ZXJNZXNzYWdlEiQKB3dlbGNvbWUYASABKAsyES5ub2l0dS52MS5XZWxjb21lSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lHTkVEEARCOlo4Z2l0aHViLmNvbS90aWVubm05OWRldi9ub2l0dS9zZXJ2ZXIvZ2VuL25vaXR1L3YxO25vaXR1djFiBnByb3RvMw"); + fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiHwoKS2lja1BsYXllchIRCglwbGF5ZXJfaWQYASABKAkiCwoJTGVhdmVSb29tIhgKCFNlbmRDaGF0EgwKBHRleHQYASABKAkiHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyKQBAoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAEicKCXNlbmRfY2hhdBgNIAEoCzISLm5vaXR1LnYxLlNlbmRDaGF0SABCCQoHcGF5bG9hZEoECAgQCSJoCgdXZWxjb21lEhIKCnNlc3Npb25faWQYASABKAkSFAoMcmVzdW1lX3Rva2VuGAIgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YAyABKA0SGQoRYWNjZXB0ZWRfbmlja25hbWUYBCABKAkibgoKUGxheWVkV29yZBIMCgR3b3JkGAEgASgJEg0KBWJ5X21lGAIgASgIEg4KBnBvaW50cxgDIAEoDRIRCglzeWxsYWJsZXMYBCABKA0SDQoFdHlwZWQYBSABKAkSEQoJcGxheWVyX2lkGAYgASgJInAKClBsYXllclNsb3QSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSEAoIaXNfb3duZXIYBCABKAgSDQoFcmVhZHkYBSABKAgSEQoJY29ubmVjdGVkGAYgASgIIoEBCgtQbGF5ZXJTY29yZRIRCglwbGF5ZXJfaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgVpc19tZRgDIAEoCBINCgVzY29yZRgEIAEoDRISCgplbGltaW5hdGVkGAUgASgIEhEKCWNvbm5lY3RlZBgGIAEoCBIMCgRyYW5rGAcgASgNItEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDRImCgdwbGF5ZXJzGAcgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmUSFgoOdHVybl9wbGF5ZXJfaWQYCCABKAki6wEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0SJgoHcGxheWVycxgJIAMoCzIVLm5vaXR1LnYxLlBsYXllclNjb3JlEhYKDnR1cm5fcGxheWVyX2lkGAogASgJSgQIBhAHSgQIBxAIIlYKDE1vdmVSZWplY3RlZBImCgZyZWFzb24YASABKA4yFi5ub2l0dS52MS5SZWplY3RSZWFzb24SDAoEd29yZBgCIAEoCRIQCgh0dXJuX3NlcRgDIAEoDSKOAQoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIUCgxjaGFpbl9sZW5ndGgYBCABKA0SKAoJc3RhbmRpbmdzGAYgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmVKBAgDEARKBAgFEAYigAEKEFBsYXllckVsaW1pbmF0ZWQSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSJwoGcmVhc29uGAQgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhITCgtzdWdnZXN0aW9ucxgFIAMoCSIsCgtTZXJ2ZXJFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiNgoEUG9uZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAxIWCg5zZXJ2ZXJfdGltZV9tcxgCIAEoAyK4AQoJUm9vbVN0YXRlEhEKCXJvb21fY29kZRgBIAEoCRIRCgljYW5fc3RhcnQYAyABKAgSJQoHcGxheWVycxgJIAMoCzIULm5vaXR1LnYxLlBsYXllclNsb3QSEwoLbWF4X3BsYXllcnMYCiABKA0SEwoLbWluX3BsYXllcnMYCyABKA0SEAoIZ3JhY2VfbXMYDCABKA1KBAgCEANKBAgEEAVKBAgFEAZKBAgGEAdKBAgHEAhKBAgIEAkiUgoLQ2hhdE1lc3NhZ2USDwoHZnJvbV9tZRgBIAEoCBIOCgZhdXRob3IYAiABKAkSDAoEdGV4dBgDIAEoCRIUCgxzZW50X3VuaXhfbXMYBCABKAMiNgoLQ2hhdEhpc3RvcnkSJwoIbWVzc2FnZXMYASADKAsyFS5ub2l0dS52MS5DaGF0TWVzc2FnZSKYBAoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDGdhbWVfc3RhcnRlZBgEIAEoCzIVLm5vaXR1LnYxLkdhbWVTdGFydGVkSAASKwoLdHVybl91cGRhdGUYBSABKAsyFC5ub2l0dS52MS5UdXJuVXBkYXRlSAASLwoNbW92ZV9yZWplY3RlZBgGIAEoCzIWLm5vaXR1LnYxLk1vdmVSZWplY3RlZEgAEicKCWdhbWVfb3ZlchgHIAEoCzISLm5vaXR1LnYxLkdhbWVPdmVySAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIABI3ChFwbGF5ZXJfZWxpbWluYXRlZBgPIAEoCzIaLm5vaXR1LnYxLlBsYXllckVsaW1pbmF0ZWRIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICBAJSgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lHTkVEEARCOlo4Z2l0aHViLmNvbS90aWVubm05OWRldi9ub2l0dS9zZXJ2ZXIvZ2VuL25vaXR1L3YxO25vaXR1djFiBnByb3RvMw"); /** * Describes the message noitu.v1.Hello. @@ -128,82 +128,96 @@ export const WelcomeSchema = /*@__PURE__*/ export const PlayedWordSchema = /*@__PURE__*/ messageDesc(file_noitu_v1_game, 14); +/** + * Describes the message noitu.v1.PlayerSlot. + * Use `create(PlayerSlotSchema)` to create a new message. + */ +export const PlayerSlotSchema = /*@__PURE__*/ + messageDesc(file_noitu_v1_game, 15); + +/** + * Describes the message noitu.v1.PlayerScore. + * Use `create(PlayerScoreSchema)` to create a new message. + */ +export const PlayerScoreSchema = /*@__PURE__*/ + messageDesc(file_noitu_v1_game, 16); + /** * Describes the message noitu.v1.GameStarted. * Use `create(GameStartedSchema)` to create a new message. */ export const GameStartedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 15); + messageDesc(file_noitu_v1_game, 17); /** * Describes the message noitu.v1.TurnUpdate. * Use `create(TurnUpdateSchema)` to create a new message. */ export const TurnUpdateSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 16); + messageDesc(file_noitu_v1_game, 18); /** * Describes the message noitu.v1.MoveRejected. * Use `create(MoveRejectedSchema)` to create a new message. */ export const MoveRejectedSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 17); + messageDesc(file_noitu_v1_game, 19); /** * Describes the message noitu.v1.GameOver. * Use `create(GameOverSchema)` to create a new message. */ export const GameOverSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 18); + messageDesc(file_noitu_v1_game, 20); /** - * Describes the message noitu.v1.OpponentLeft. - * Use `create(OpponentLeftSchema)` to create a new message. + * Describes the message noitu.v1.PlayerEliminated. + * Use `create(PlayerEliminatedSchema)` to create a new message. */ -export const OpponentLeftSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 19); +export const PlayerEliminatedSchema = /*@__PURE__*/ + messageDesc(file_noitu_v1_game, 21); /** * Describes the message noitu.v1.ServerError. * Use `create(ServerErrorSchema)` to create a new message. */ export const ServerErrorSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 20); + messageDesc(file_noitu_v1_game, 22); /** * Describes the message noitu.v1.Pong. * Use `create(PongSchema)` to create a new message. */ export const PongSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 21); + messageDesc(file_noitu_v1_game, 23); /** * Describes the message noitu.v1.RoomState. * Use `create(RoomStateSchema)` to create a new message. */ export const RoomStateSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 22); + messageDesc(file_noitu_v1_game, 24); /** * Describes the message noitu.v1.ChatMessage. * Use `create(ChatMessageSchema)` to create a new message. */ export const ChatMessageSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 23); + messageDesc(file_noitu_v1_game, 25); /** * Describes the message noitu.v1.ChatHistory. * Use `create(ChatHistorySchema)` to create a new message. */ export const ChatHistorySchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 24); + messageDesc(file_noitu_v1_game, 26); /** * Describes the message noitu.v1.ServerMessage. * Use `create(ServerMessageSchema)` to create a new message. */ export const ServerMessageSchema = /*@__PURE__*/ - messageDesc(file_noitu_v1_game, 25); + messageDesc(file_noitu_v1_game, 27); /** * Describes the enum noitu.v1.Difficulty. diff --git a/web/src/lib/stores/game.svelte.js b/web/src/lib/stores/game.svelte.js index 118e1ab..b8502b5 100644 --- a/web/src/lib/stores/game.svelte.js +++ b/web/src/lib/stores/game.svelte.js @@ -16,9 +16,27 @@ export const CHAT_WINDOW = 20; * @property {string} word - the canonical spelling * @property {string} typed - what the player actually typed, when it differed * @property {boolean} byMe + * @property {string} playerId - the seat that played it, empty for the opening * @property {number} points * @property {number} syllables * @property {boolean} opening - the seed word, played by neither side + * + * @typedef {object} PlayerSlot + * @property {string} playerId + * @property {string} name + * @property {boolean} isMe + * @property {boolean} isOwner + * @property {boolean} ready + * @property {boolean} connected + * + * @typedef {object} PlayerScore + * @property {string} playerId + * @property {string} name + * @property {boolean} isMe + * @property {number} score + * @property {boolean} eliminated + * @property {boolean} connected + * @property {number} rank - final placing, 1 for the winner; 0 while in play */ /** @returns {any} */ @@ -42,25 +60,64 @@ function initialState() { deadlineMs: 0, turnSeq: 0, turnLimitMs: 0, - myScore: 0, - opponentScore: 0, chainLength: 0, nickname: '', - 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. + * The room, exactly as the server last described it. Every field is + * server-owned: the client never decides who is seated, who owns the + * room, who is ready, or whether a game may start. + * + * The recipient's own row is in `roomPlayers` like everybody else's, + * marked `isMe`, which is what the derived accessors below read. + * + * @type {PlayerSlot[]} */ - isOwner: false, - isReady: false, + roomPlayers: [], canStart: false, - opponentPresent: false, - opponentReady: false, - opponentConnected: false, + /** + * How many seats the room has and how many a game needs. Sent by the + * server rather than compiled in here, so widening a room is a server + * change alone. + */ + maxPlayers: 0, + minPlayers: 0, + /** How long a seat is held for somebody who dropped. */ + graceMs: 0, + + /** + * The table of a running game, in turn order, and who is on turn. + * + * @type {PlayerScore[]} + */ + gamePlayers: [], + turnPlayerId: '', + /** + * The final table, best first: the player left standing, then the rest + * in reverse order of elimination. + * + * @type {PlayerScore[]} + */ + standings: [], + + /** + * This player's own knockout, and nobody else's. `suggestions` is what + * the position still had when they lost it; empty means it was a dead + * end, which is a different thing to say than "here is what you + * missed". + * + * @type {{ playerId: string, name: string, reason: number, suggestions: string[] } | null} + */ + elimination: null, + /** + * The last player to go out, whoever they were. It is what a spectator + * is shown; the client's own knockout is `elimination` above. + * + * @type {{ playerId: string, name: string, isMe: boolean, reason: number } | null} + */ + lastOut: null, /** * The room's conversation, oldest first, capped at CHAT_WINDOW. Chat @@ -84,21 +141,35 @@ function initialState() { /** @type {{ word: string, message: string } | null} */ rejection: null, /** - * The finished game. `suggestions` is what the position still had to - * offer and arrives only for the player who lost; empty on a loss - * means the position was a dead end, which is a different thing to - * say than "here is what you missed". + * The finished game, from this player's side. The table it came with + * is `standings`; this is the part about them. * - * @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number, suggestions: string[] } | null} + * @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null} */ result: null, - /** @type {{ canReconnect: boolean, graceMs: number } | null} */ - opponentLeft: null, /** @type {string | null} */ error: null }; } +/** + * Reads one PlayerScore off the wire. + * + * @param {any} p + * @returns {PlayerScore} + */ +function toScore(p) { + return { + playerId: p.playerId, + name: p.name, + isMe: p.isMe, + score: p.score, + eliminated: p.eliminated, + connected: p.connected, + rank: p.rank + }; +} + /** * Builds a store instance. Tests construct their own rather than sharing the * module singleton, so one test's game cannot leak into the next. @@ -108,19 +179,17 @@ export function createGameStore() { /** * 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 + * the room. 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', + 'roomPlayers', 'canStart', - 'opponentPresent', - 'opponentReady', - 'opponentConnected', + 'maxPlayers', + 'minPlayers', + 'graceMs', 'chat', 'chatCount' ]); @@ -166,16 +235,18 @@ export function createGameStore() { // 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; - 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; + state.maxPlayers = value.maxPlayers; + state.minPlayers = value.minPlayers; + state.graceMs = value.graceMs; + state.roomPlayers = value.players.map((/** @type {any} */ p) => ({ + playerId: p.playerId, + name: p.name, + isMe: p.isMe, + isOwner: p.isOwner, + ready: p.ready, + connected: p.connected + })); // 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'; @@ -183,7 +254,7 @@ export function createGameStore() { case 'gameStarted': // reset() clears the readiness that led here, along with the - // last game's board. + // last game's board and its knockouts. reset(); state.phase = 'playing'; state.chain = [ @@ -191,6 +262,7 @@ export function createGameStore() { word: value.openingWord, typed: '', byMe: false, + playerId: '', points: 0, syllables: 0, opening: true @@ -202,15 +274,21 @@ export function createGameStore() { state.turnSeq = value.turnSeq; state.turnLimitMs = value.turnLimitMs; state.chainLength = 1; + state.gamePlayers = value.players.map(toScore); + state.turnPlayerId = value.turnPlayerId; break; case 'turnUpdate': { const played = value.played; + // A turn update with no word is an elimination moving the turn + // on: the syllable and the chain survive the player who could + // not answer them, so there is nothing to append. if (played) { state.chain.push({ word: played.word, typed: played.typed, byMe: played.byMe, + playerId: played.playerId, points: played.points, syllables: played.syllables, opening: false @@ -220,9 +298,9 @@ export function createGameStore() { state.myTurn = value.myTurn; state.deadlineMs = Number(value.deadlineUnixMs); state.turnSeq = value.turnSeq; - state.myScore = value.myScore; - state.opponentScore = value.opponentScore; state.chainLength = value.chainLength; + state.gamePlayers = value.players.map(toScore); + state.turnPlayerId = value.turnPlayerId; // An accepted move answers the previous rejection. state.rejection = null; break; @@ -235,27 +313,40 @@ export function createGameStore() { }; break; - case 'gameOver': + case 'playerEliminated': + state.lastOut = { + playerId: value.playerId, + name: value.name, + isMe: value.isMe, + reason: value.reason + }; + // Only the player who went out is sent suggestions, and only + // they have a use for them: they describe the position that + // beat them, which is nobody else's position. + if (value.isMe) { + state.myTurn = false; + state.elimination = { + playerId: value.playerId, + name: value.name, + reason: value.reason, + suggestions: value.suggestions ?? [] + }; + } + break; + + case 'gameOver': { state.phase = 'over'; state.myTurn = false; + state.standings = value.standings.map(toScore); + const mine = state.standings.find((/** @type {PlayerScore} */ p) => p.isMe); state.result = { iWon: value.iWon, reason: value.reason, - myScore: value.myScore, - chainLength: value.chainLength, - suggestions: value.suggestions ?? [] + myScore: mine?.score ?? 0, + chainLength: value.chainLength }; 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 - }; - state.opponentConnected = false; - break; + } case 'chatMessage': state.chat.push({ @@ -313,15 +404,68 @@ export function createGameStore() { apply, reset, leave, + + /** + * The recipient's own row in the room. Their role and their readiness + * live there and nowhere else: a second copy alongside the list is a + * second thing to keep in step with the server. + * + * @returns {PlayerSlot | null} + */ + get me() { + return state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.isMe) ?? null; + }, + get isOwner() { + return this.me?.isOwner ?? false; + }, + get isReady() { + return this.me?.ready ?? false; + }, + /** How many seats are still free, for a lobby that draws the empty ones. */ + get freeSeats() { + return Math.max(0, state.maxPlayers - state.roomPlayers.length); + }, + /** Whether this player has been knocked out of the game still running. */ + get iAmOut() { + return state.phase === 'playing' && state.elimination !== null; + }, + /** This player's score in the game on screen, finished or not. */ + get myScore() { + const table = state.phase === 'over' ? state.standings : state.gamePlayers; + return table.find((/** @type {PlayerScore} */ p) => p.isMe)?.score ?? 0; + }, + /** + * Everybody whose reconnect window is currently running. The lobby and + * the board both wait on the same list. + * + * @returns {PlayerSlot[]} + */ + get awayPlayers() { + return state.roomPlayers.filter((/** @type {PlayerSlot} */ p) => !p.isMe && !p.connected); + }, + /** + * The name behind a seat id, for the chain and the board. Falls back to + * the id's absence rather than inventing a label: an empty string is + * something a caller can substitute its own copy for. + * + * @param {string} playerId + * @returns {string} + */ + nameOf(playerId) { + const from = state.gamePlayers.length ? state.gamePlayers : state.standings; + return ( + from.find((/** @type {PlayerScore} */ p) => p.playerId === playerId)?.name ?? + state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.playerId === playerId)?.name ?? + '' + ); + }, + clearRejection() { state.rejection = null; }, clearError() { state.error = null; }, - clearOpponentLeft() { - state.opponentLeft = null; - }, /** * Forgets the conversation without forgetting the room. The screen * calls this when it is entered and left: chat survives reset() so a diff --git a/web/src/lib/ws/messages.js b/web/src/lib/ws/messages.js index 598aa52..4ff12fe 100644 --- a/web/src/lib/ws/messages.js +++ b/web/src/lib/ws/messages.js @@ -20,7 +20,7 @@ import { * with `protocol_version_mismatch` rather than failing to decode, so this * constant is the client half of that contract. */ -export const PROTOCOL_VERSION = 1; +export const PROTOCOL_VERSION = 2; /** * Thin builders, one per client message. They exist so no other module has to @@ -80,8 +80,8 @@ export function submitWord(word, turnSeq) { } /** - * 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. + * Declares this player ready for the next game, or takes it back. Only guests + * have a readiness to declare: the owner's is Start itself. * * @param {boolean} ready */ @@ -91,17 +91,22 @@ export function setReady(ready) { }); } -/** Begins the game the lobby has agreed on. Refused unless the guest is ready. */ +/** Begins the game the lobby has agreed on. Refused unless every 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() { +/** + * Frees one named seat. Refused while that player is ready, and refused on the + * owner's own seat — leaving is what an owner who wants out does. + * + * @param {string} playerId + */ +export function kickPlayer(playerId) { return create(ClientMessageSchema, { - payload: { case: 'kickPlayer', value: create(KickPlayerSchema, {}) } + payload: { case: 'kickPlayer', value: create(KickPlayerSchema, { playerId }) } }); } @@ -113,7 +118,7 @@ export function leaveRoom() { } /** - * One line of chat to the other player. The server sanitizes and caps it, so + * One line of chat to the rest of the room. The server sanitizes and caps it, so * this sends what was typed and lets the copy that comes back be the truth. * * @param {string} text diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte index ac0f2c4..f5aed7e 100644 --- a/web/src/routes/online/+page.svelte +++ b/web/src/routes/online/+page.svelte @@ -7,7 +7,7 @@ import ChatPanel from '$lib/components/ChatPanel.svelte'; import Lobby from '$lib/components/Lobby.svelte'; import NicknameInput from '$lib/components/NicknameInput.svelte'; - import OpponentStatus from '$lib/components/OpponentStatus.svelte'; + import PlayerStatus from '$lib/components/PlayerStatus.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'; @@ -161,8 +161,9 @@ send(startGame()); } - function kick() { - if (confirm(t.kickConfirm)) send(kickPlayer()); + /** @param {string} playerId */ + function kick(playerId) { + if (confirm(t.kickConfirm)) send(kickPlayer(playerId)); } function leave() { @@ -191,14 +192,9 @@
    {#if playing} - + {#snippet banner()} - + {/snippet} {#snippet chat()} diff --git a/web/src/routes/play/+page.svelte b/web/src/routes/play/+page.svelte index 98060a7..442aa9f 100644 --- a/web/src/routes/play/+page.svelte +++ b/web/src/routes/play/+page.svelte @@ -91,12 +91,7 @@ } - + {#snippet gameOver()} diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js index cb2ec09..493d2fa 100644 --- a/web/tests/game-store.test.js +++ b/web/tests/game-store.test.js @@ -18,6 +18,14 @@ function msg(kind, value) { return create(ServerMessageSchema, { payload: { case: kind, value } }); } +/** The two players every in-game fixture below is scored for. */ +function table(mine = 0, theirs = 0) { + return [ + { playerId: 'p1', name: 'Minh', isMe: true, score: mine, connected: true }, + { playerId: 'p2', name: 'Lan', isMe: false, score: theirs, connected: true } + ]; +} + function started(overrides = {}) { return msg('gameStarted', { openingWord: 'học sinh', @@ -26,6 +34,8 @@ function started(overrides = {}) { deadlineUnixMs: 1_700_000_020_000n, turnSeq: 1, turnLimitMs: 20_000, + players: table(), + turnPlayerId: 'p1', ...overrides }); } @@ -82,14 +92,21 @@ describe('turnUpdate', () => { store.apply(started()); store.apply( msg('turnUpdate', { - played: { word: 'sinh viên', byMe: true, points: 12, syllables: 2, typed: 'sinh vien' }, + played: { + word: 'sinh viên', + byMe: true, + points: 12, + syllables: 2, + typed: 'sinh vien', + playerId: 'p1' + }, currentSyllable: 'viên', myTurn: false, deadlineUnixMs: 1_700_000_040_000n, turnSeq: 2, - myScore: 12, - opponentScore: 0, - chainLength: 2 + chainLength: 2, + players: table(12, 0), + turnPlayerId: 'p2' }) ); @@ -103,11 +120,39 @@ describe('turnUpdate', () => { }); expect(store.state.currentSyllable).toBe('viên'); expect(store.state.myTurn).toBe(false); - expect(store.state.myScore).toBe(12); + expect(store.myScore).toBe(12); + expect(store.state.turnPlayerId).toBe('p2'); expect(store.state.chainLength).toBe(2); expect(store.state.turnSeq).toBe(2); }); + it('moves the turn on without a word when an elimination did it', () => { + // The syllable and the chain survive the player who could not answer + // them, so there is nothing to append — but the deadline and the player + // to act are both new. + const store = createGameStore(); + store.apply(started()); + store.apply( + msg('turnUpdate', { + currentSyllable: 'sinh', + myTurn: true, + deadlineUnixMs: 1_700_000_050_000n, + turnSeq: 3, + chainLength: 1, + players: [ + { playerId: 'p1', name: 'Minh', isMe: true, score: 0, connected: true }, + { playerId: 'p2', name: 'Lan', score: 0, eliminated: true, connected: true } + ], + turnPlayerId: 'p1' + }) + ); + + expect(store.state.chain).toHaveLength(1); + expect(store.state.turnSeq).toBe(3); + expect(store.state.deadlineMs).toBe(1_700_000_050_000); + expect(store.state.gamePlayers[1].eliminated).toBe(true); + }); + it('clears the standing rejection, because an accepted move answers it', () => { const store = createGameStore(); store.apply(started()); @@ -156,8 +201,11 @@ describe('gameOver', () => { msg('gameOver', { iWon: true, reason: GameEndReason.NO_LEGAL_MOVE, - myScore: 42, - chainLength: 7 + chainLength: 7, + standings: [ + { playerId: 'p1', name: 'Minh', isMe: true, score: 42, connected: true, rank: 1 }, + { playerId: 'p2', name: 'Lan', score: 8, eliminated: true, connected: true, rank: 2 } + ] }) ); @@ -167,35 +215,81 @@ describe('gameOver', () => { iWon: true, reason: GameEndReason.NO_LEGAL_MOVE, myScore: 42, - chainLength: 7, - suggestions: [] + chainLength: 7 }); }); - it('keeps the words the losing side could have played', () => { + it('keeps the final table in the order the server ranked it', () => { const store = createGameStore(); store.apply(started()); store.apply( msg('gameOver', { iWon: false, reason: GameEndReason.TIMEOUT, - myScore: 12, - chainLength: 3, + chainLength: 9, + standings: [ + { playerId: 'p3', name: 'Hà', score: 60, connected: true, rank: 1 }, + { playerId: 'p1', name: 'Minh', isMe: true, score: 40, eliminated: true, rank: 2 }, + { playerId: 'p2', name: 'Lan', score: 55, eliminated: true, rank: 3 } + ] + }) + ); + + expect(store.state.standings.map((p) => p.playerId)).toEqual(['p3', 'p1', 'p2']); + // Rank is finishing order, not score order: Lan outscored Minh and + // still placed below him, because he outlasted her. + expect(store.state.standings[2].score).toBeGreaterThan(store.state.standings[1].score); + expect(store.state.result?.myScore).toBe(40); + }); +}); + +describe('playerEliminated', () => { + it('keeps the words this player could have played', () => { + const store = createGameStore(); + store.apply(started()); + store.apply( + msg('playerEliminated', { + playerId: 'p1', + name: 'Minh', + isMe: true, + reason: GameEndReason.TIMEOUT, suggestions: ['sinh viên', 'sinh sôi'] }) ); - expect(store.state.result?.suggestions).toEqual(['sinh viên', 'sinh sôi']); + expect(store.state.elimination?.suggestions).toEqual(['sinh viên', 'sinh sôi']); + expect(store.iAmOut).toBe(true); + expect(store.state.myTurn).toBe(false); }); it('reads an absent list as a position that had nothing left', () => { - // A dead end and an older server that never sends the field arrive the - // same way, and both mean "no words to offer" rather than undefined. + // A dead end arrives as an empty list, which means "no words to offer" + // rather than undefined. const store = createGameStore(); store.apply(started()); - store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.NO_LEGAL_MOVE })); + store.apply( + msg('playerEliminated', { playerId: 'p1', isMe: true, reason: GameEndReason.NO_LEGAL_MOVE }) + ); - expect(store.state.result?.suggestions).toEqual([]); + expect(store.state.elimination?.suggestions).toEqual([]); + }); + + it('records somebody else going out without claiming this player did', () => { + const store = createGameStore(); + store.apply(started()); + store.apply( + msg('playerEliminated', { + playerId: 'p2', + name: 'Lan', + isMe: false, + reason: GameEndReason.RESIGNED, + suggestions: [] + }) + ); + + expect(store.state.lastOut?.name).toBe('Lan'); + expect(store.state.elimination).toBeNull(); + expect(store.iAmOut).toBe(false); }); }); @@ -214,50 +308,70 @@ describe('error', () => { }); }); -describe('opponentLeft', () => { - it('exposes the grace window so the UI can say how long to wait', () => { - const store = createGameStore(); - store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30_000 })); - expect(store.state.opponentLeft).toEqual({ canReconnect: true, graceMs: 30_000 }); - }); -}); +/** One seat, with the fields a test does not care about defaulted. */ +function seat(fields = {}) { + return { + playerId: 'p1', + name: 'Minh', + isMe: false, + isOwner: false, + ready: false, + connected: true, + ...fields + }; +} -/** One lobby snapshot, with the fields a test does not care about defaulted. */ +/** One lobby snapshot. `players` is the whole room, the recipient included. */ function lobby(fields = {}) { return msg('roomState', { roomCode: 'ABCD', - iAmOwner: false, canStart: false, - iAmReady: false, - opponentPresent: false, - opponentName: '', - opponentReady: false, - opponentConnected: false, + maxPlayers: 4, + minPlayers: 2, + graceMs: 30_000, + players: [seat({ isMe: true })], + ...fields + }); +} + +/** A two-player room, seen by the guest. */ +function pair(fields = {}) { + return lobby({ + players: [ + seat({ playerId: 'p1', name: 'Chủ', isOwner: true }), + seat({ playerId: 'p2', name: 'Lan', isMe: true }) + ], ...fields }); } describe('room messages', () => { - it('keeps the room code and the sanitized opponent name', () => { + it('keeps the room code and the sanitized names', () => { const store = createGameStore(); - store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true })); + store.apply(pair()); expect(store.state.roomCode).toBe('ABCD'); - expect(store.state.opponentName).toBe('Lan'); + expect(store.state.roomPlayers.map((p) => p.name)).toEqual(['Chủ', 'Lan']); + expect(store.me?.playerId).toBe('p2'); }); it('survives a reset, because identity outlives one game', () => { const store = createGameStore(); store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's', resumeToken: 't' })); - store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true })); + store.apply(pair()); 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.roomPlayers).toHaveLength(2); expect(store.state.chain).toEqual([]); }); + + it('counts the seats nobody is in yet', () => { + const store = createGameStore(); + store.apply(pair()); + expect(store.freeSeats).toBe(2); + }); }); describe('pong', () => { @@ -379,36 +493,48 @@ describe('chat', () => { describe('the lobby', () => { it('opens the lobby when the room appears', () => { const store = createGameStore(); - store.apply(lobby({ roomCode: 'K7M2QP', iAmOwner: true })); + store.apply(lobby({ roomCode: 'K7M2QP', players: [seat({ isMe: true, isOwner: true })] })); expect(store.state.phase).toBe('lobby'); expect(store.state.roomCode).toBe('K7M2QP'); - expect(store.state.isOwner).toBe(true); + expect(store.isOwner).toBe(true); }); - it('takes every field from the server rather than deriving any', () => { + it("reads this player’s role and readiness off their own row", () => { + // There is one encoding of each, so a client cannot end up believing a + // role the list it is rendering disagrees with. const store = createGameStore(); store.apply( - lobby({ - iAmOwner: false, - iAmReady: true, + pair({ canStart: true, - opponentPresent: true, - opponentName: 'Lan', - opponentReady: false, - opponentConnected: true + players: [ + seat({ playerId: 'p1', name: 'Chủ', isOwner: true }), + seat({ playerId: 'p2', name: 'Lan', isMe: true, ready: true }) + ] }) ); - expect(store.state).toMatchObject({ - isOwner: false, - isReady: true, - canStart: true, - opponentPresent: true, - opponentName: 'Lan', - opponentReady: false, - opponentConnected: true - }); + expect(store.isOwner).toBe(false); + expect(store.isReady).toBe(true); + expect(store.state.canStart).toBe(true); + expect(store.state.maxPlayers).toBe(4); + expect(store.state.minPlayers).toBe(2); + }); + + it('lists everybody whose reconnect window is running, and nobody else', () => { + const store = createGameStore(); + store.apply( + lobby({ + players: [ + seat({ playerId: 'p1', name: 'Chủ', isOwner: true }), + seat({ playerId: 'p2', name: 'Lan', isMe: true }), + seat({ playerId: 'p3', name: 'Hà', connected: false }) + ] + }) + ); + + expect(store.awayPlayers.map((p) => p.name)).toEqual(['Hà']); + expect(store.state.graceMs).toBe(30_000); }); it('does not drop a live game back into the lobby', () => { @@ -416,7 +542,7 @@ describe('the lobby', () => { // Acting on it would replace the board with the lobby mid-turn. const store = createGameStore(); store.apply(started()); - store.apply(lobby({ opponentPresent: true, opponentConnected: true })); + store.apply(pair()); expect(store.state.phase).toBe('playing'); }); @@ -425,25 +551,32 @@ describe('the lobby', () => { const store = createGameStore(); store.apply(started()); store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED })); - store.apply(lobby({ opponentPresent: true, opponentConnected: true })); + store.apply(pair()); expect(store.state.phase).toBe('over'); expect(store.state.result).not.toBeNull(); }); - it('stops waiting for an opponent who is connected again', () => { + it('stops waiting for a player 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 })); + store.apply( + pair({ + players: [ + seat({ playerId: 'p1', name: 'Chủ', isOwner: true, connected: false }), + seat({ playerId: 'p2', name: 'Lan', isMe: true }) + ] + }) + ); + expect(store.awayPlayers).toHaveLength(1); - expect(store.state.opponentLeft).toBeNull(); - expect(store.state.opponentConnected).toBe(true); + store.apply(pair()); + expect(store.awayPlayers).toHaveLength(0); }); it('forgets the room when this player is kicked out of it', () => { const store = createGameStore(); - store.apply(lobby({ opponentPresent: true, opponentConnected: true })); + store.apply(pair()); store.apply(msg('error', { code: 'kicked', message: '' })); expect(store.state.phase).toBe('idle'); @@ -453,7 +586,7 @@ describe('the lobby', () => { it('forgets a room that closed for sitting idle', () => { const store = createGameStore(); - store.apply(lobby({ iAmOwner: true })); + store.apply(lobby()); store.apply(msg('error', { code: 'room_idle_closed', message: '' })); expect(store.state.phase).toBe('idle'); diff --git a/web/tests/game-wire.test.js b/web/tests/game-wire.test.js index 9baeffc..8ac3029 100644 --- a/web/tests/game-wire.test.js +++ b/web/tests/game-wire.test.js @@ -11,6 +11,7 @@ import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js'; import { fromBinary } from '@bufbuild/protobuf'; import { ClientMessageSchema, @@ -48,7 +49,7 @@ describe('generated wire types', () => { const msg = decode('server_welcome'); expect(msg.payload.case).toBe('welcome'); expect(msg.payload.value.acceptedNickname).toBe('Người chơi ẩn danh'); - expect(msg.payload.value.protocolVersion).toBe(1); + expect(msg.payload.value.protocolVersion).toBe(PROTOCOL_VERSION); }); // int64 is a bigint in this runtime. Reading it as a Number would silently @@ -71,9 +72,13 @@ describe('generated wire types', () => { expect(over.payload.case).toBe('gameOver'); expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE); expect(over.payload.value.iWon).toBe(false); - // The only repeated field in the contract, and the one the losing + // The final table, in the order the server ranked it. + expect(over.payload.value.standings.map((/** @type {any} */ p) => p.rank)).toEqual([1, 2, 3]); + + const out = decode('server_player_eliminated'); + // The only repeated string in the contract, and the one the losing // player's screen is built from. - expect(over.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']); + expect(out.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']); const chat = decode('server_chat_message'); expect(chat.payload.case).toBe('chatMessage'); diff --git a/web/tests/history-export.test.js b/web/tests/history-export.test.js index 7391238..fdecefa 100644 --- a/web/tests/history-export.test.js +++ b/web/tests/history-export.test.js @@ -9,15 +9,27 @@ import { chainToText, historyFilename } from '../src/lib/history-export.js'; * @param {Partial} entry */ function link(entry) { - return { word: '', typed: '', byMe: false, points: 0, syllables: 2, opening: false, ...entry }; + return { + word: '', + typed: '', + byMe: false, + playerId: '', + points: 0, + syllables: 2, + opening: false, + ...entry + }; } const chain = [ link({ word: 'học sinh', opening: true, syllables: 0 }), - link({ word: 'sinh viên', byMe: true, points: 2 }), - link({ word: 'viên chức', points: 2 }) + link({ word: 'sinh viên', byMe: true, playerId: 'p1', points: 2 }), + link({ word: 'viên chức', playerId: 'p2', points: 2 }) ]; +/** Resolves a seat to the name that seat was playing under. */ +const nameOf = (/** @type {string} */ id) => (id === 'p2' ? 'Minh' : ''); + const at = new Date(2026, 8, 7, 15, 25); describe('chainToText', () => { @@ -39,12 +51,21 @@ describe('chainToText', () => { }); it('names who played each word and what it scored', () => { - const text = chainToText({ chain, at, opponentLabel: 'Minh' }); + const text = chainToText({ chain, at, nameOf }); expect(text).toContain('2. sinh viên — Bạn +2'); expect(text).toContain('3. viên chức — Minh +2'); }); + it('falls back to a generic label for a seat whose name is gone', () => { + // A player who left takes their name with them, and the transcript is + // written long after. The line still has to say it was not this + // player's word. + const text = chainToText({ chain, at }); + + expect(text).toContain('3. viên chức — Đối thủ +2'); + }); + it('heads the file with the result when there is one', () => { const text = chainToText({ chain, diff --git a/web/tests/ws-client.test.js b/web/tests/ws-client.test.js index 40b7fe9..917fd21 100644 --- a/web/tests/ws-client.test.js +++ b/web/tests/ws-client.test.js @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { create, toBinary } from '@bufbuild/protobuf'; +import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js'; import { ClientMessageSchema, ServerMessageSchema @@ -171,7 +172,7 @@ describe('handshake', () => { const [first] = await sentMessages(h.last()); expect(first.payload.case).toBe('hello'); - expect(first.payload.value.protocolVersion).toBe(1); + expect(first.payload.value.protocolVersion).toBe(PROTOCOL_VERSION); expect(first.payload.value.nickname).toBe('Minh'); expect(first.payload.value.resumeToken).toBe(''); });