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 839cc09..1463f29 100644 Binary files a/proto/testdata/client_kick_player.bin and b/proto/testdata/client_kick_player.bin differ 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(''); });