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 +p2Khá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: +p2Khá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(2Khách mời8@ \ No newline at end of file +bV +K7QXJ +p1Người chơi 0J +p2Khá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 +p2Khá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}