mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-05 12:14:11 +00:00
feat(online)!: seat two to four players in a room
A room holds up to four people and needs two to start. Both numbers are server constants sent to the client in RoomState, so the lobby draws whatever the server allows and widening a room is a server change alone. Failing a turn eliminates that player rather than ending the game. The syllable and the used words survive them, the turn passes to whoever is next, and the last player standing wins. Two seats is that same rule seen from close up, which is why there is one implementation of it and not two. A dead end still costs the first player to face it their own clock, as before: they get their turn, and lose it. Everyone behind them has already seen that board, so they go out together rather than each sitting out a turn limit they cannot use — which leaves the player who closed the position standing, the same outcome two players get. A player who is knocked out keeps their seat. They watch the rest of the game, chat included, with only the word input gone, and everybody lands back in the same lobby when it ends. The result screen is the whole table, ranked by who outlasted whom, with each score reported beside the place rather than deciding it. The turn clock is deliberately not paused for a seat that has dropped, so a player who loses their connection on their own turn loses it the way anybody else would. Their reconnect window decides only whether they are still in the game afterwards. Any number of windows can be open at once, settled by one timer armed for the nearest of them. Starting waits for every guest, not merely the first: a room of four that began on one yes would have dealt three people a turn they never agreed to. Kicking names a seat and is still refused on a player who is ready, and on the owner's own — leaving is what an owner who wants out does, and it hands the room on. Joining stays a lobby thing: a room with a game running turns a latecomer away even with seats going spare, because there is no way to hand somebody a game already in progress. BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that could only ever describe a second player, OpponentLeft is retired in favour of presence on RoomState, and suggestions move to the new PlayerEliminated — they describe the position that beat a player, which by the end of a longer game is nobody else's position. ProtocolVersion goes to 2, so a client built against 1 is refused with a readable error rather than decoding a frame that now means something else.
This commit is contained in:
1 parent
0b507c3537
commit
4e2e9e1433
54 files changed
+3692
-1129
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`. |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+131
-43
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Vendored
+4
-2
@@ -1,2 +1,4 @@
|
||||
: *
|
||||
sinh viên* sinh sôi
|
||||
:T 2
|
||||
p2Khách mời *082
|
||||
p1Người chơi (082
|
||||
p3Khách 2 (8
|
||||
+5
-2
@@ -1,3 +1,6 @@
|
||||
""
|
||||
"n
|
||||
|
||||
hòa bìnhbình ×ø©‘3(0 œ
|
||||
hòa bìnhbình ×ø©‘3(0 œ:
|
||||
p1Ngư�i chơi 0:
|
||||
p2Khách m�i *(0:
|
||||
p3Khách 2 Bp1
|
||||
-1
@@ -1 +0,0 @@
|
||||
B國
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
z/
|
||||
p1Người chơi *
|
||||
sinh viên* sinh sôi
|
||||
Vendored
+5
-2
@@ -1,2 +1,5 @@
|
||||
b
|
||||
K7QX(2Khách mời8@
|
||||
bV
|
||||
K7QXJ
|
||||
p1Ng⑹廙𩥉 ch①i 0J
|
||||
p2Kh獺ch m廙𩥉(0J
|
||||
p3Kh獺ch 2(PX`國
|
||||
+6
-3
@@ -1,4 +1,7 @@
|
||||
*4
|
||||
|
||||
*€
|
||||
|
||||
|
||||
bình yên * binh yênyên Àóù©‘3(08@
|
||||
bình yên * binh yên2p2yên Àóù©‘3(@J
|
||||
p1Ngư�i chơi 0J
|
||||
p2Khách m�i *(0J
|
||||
p3Khách 2 Rp3
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
|
||||
.
|
||||
s-1a2b3cr-8f2c"Người chơi ẩn danh
|
||||
s-1a2b3cr-8f2c"Người chơi ẩn danh
|
||||
Generated
+534
-249
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+165
-30
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+462
-256
File diff suppressed because it is too large.
Load diff
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -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.
|
||||
*
|
||||
|
||||
+100
-9
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
{#each entries as entry, index}
|
||||
<li class:mine={entry.byMe} class:opening={entry.opening} class:latest={index === 0}>
|
||||
<span class="word">{entry.word}</span>
|
||||
<!-- Who played it, not merely whether it was mine: a chain
|
||||
four people built is unreadable without the names. -->
|
||||
{#if !entry.opening && !entry.byMe && game.nameOf(entry.playerId)}
|
||||
<span class="by">{game.nameOf(entry.playerId)}</span>
|
||||
{/if}
|
||||
<span class="meta">
|
||||
{#if entry.syllables > 2}
|
||||
<span class="badge">{entry.syllables} {t.syllableUnit}</span>
|
||||
@@ -116,6 +121,11 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.by {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import CountdownRing from '$lib/components/CountdownRing.svelte';
|
||||
import ScoreBoard from '$lib/components/ScoreBoard.svelte';
|
||||
import WordInput from '$lib/components/WordInput.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
@@ -13,7 +13,6 @@
|
||||
* that arrives as a snippet rather than as a branch in here.
|
||||
*
|
||||
* @type {{
|
||||
* opponentLabel: string,
|
||||
* modeLabel?: string,
|
||||
* onsubmit: (word: string) => boolean,
|
||||
* onresign: () => void,
|
||||
@@ -22,7 +21,15 @@
|
||||
* chat?: import('svelte').Snippet
|
||||
* }}
|
||||
*/
|
||||
let { opponentLabel, modeLabel = '', onsubmit, onresign, gameOver, banner, chat } = $props();
|
||||
let { modeLabel = '', onsubmit, onresign, gameOver, banner, chat } = $props();
|
||||
|
||||
// Whose turn it is, said by name. With four people at the table "the
|
||||
// opponent is thinking" stops naming anybody.
|
||||
const turnLabel = $derived.by(() => {
|
||||
if (game.state.myTurn) return t.yourTurn;
|
||||
const name = game.nameOf(game.state.turnPlayerId);
|
||||
return name ? fill(t.playerTurn, { name }) : t.opponentTurn;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="board" data-phase={game.state.phase}>
|
||||
@@ -31,7 +38,7 @@
|
||||
{#if modeLabel}<span class="mode">{modeLabel}</span>{/if}
|
||||
</div>
|
||||
|
||||
<ScoreBoard {opponentLabel} />
|
||||
<ScoreBoard />
|
||||
|
||||
{#if banner}{@render banner()}{/if}
|
||||
|
||||
@@ -48,9 +55,7 @@
|
||||
<div class="turn">
|
||||
<CountdownRing />
|
||||
<div class="prompt">
|
||||
<p class="who" data-testid="turn-indicator">
|
||||
{game.state.myTurn ? t.yourTurn : t.opponentTurn}
|
||||
</p>
|
||||
<p class="who" data-testid="turn-indicator">{turnLabel}</p>
|
||||
<p class="syllable">
|
||||
<span class="label">{t.currentSyllable}</span>
|
||||
<strong data-testid="current-syllable">{game.state.currentSyllable || '…'}</strong>
|
||||
@@ -58,7 +63,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WordInput {onsubmit} />
|
||||
<!-- A player who has been knocked out watches the rest of it: the chain,
|
||||
the clock and the chat all keep working, and only the one thing
|
||||
they can no longer do goes away. -->
|
||||
{#if game.iAmOut}
|
||||
<p class="spectating">{t.spectating}</p>
|
||||
{:else}
|
||||
<WordInput {onsubmit} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ChainHistory />
|
||||
@@ -68,7 +80,7 @@
|
||||
a condition somebody has to remember to check. -->
|
||||
{#if chat}{@render chat()}{/if}
|
||||
|
||||
{#if game.state.phase === 'playing'}
|
||||
{#if game.state.phase === 'playing' && !game.iAmOut}
|
||||
<button type="button" class="resign" onclick={onresign}>{t.resign}</button>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -147,6 +159,15 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spectating {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resign {
|
||||
align-self: center;
|
||||
padding: 8px 16px;
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
*/
|
||||
let { isRecord, onrematch, onhome } = $props();
|
||||
|
||||
/** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number, suggestions: string[] } | null} */
|
||||
const result = $derived(game.state.result);
|
||||
const standings = $derived(game.state.standings);
|
||||
// What the position still had when this player lost it. It arrives with
|
||||
// their knockout rather than with the result, because by the time a game
|
||||
// with four people in it ends, the position that beat them is nobody
|
||||
// else's position.
|
||||
const elimination = $derived(game.state.elimination);
|
||||
|
||||
/** Hands the finished chain to the player as a text file to keep. */
|
||||
function exportHistory() {
|
||||
@@ -21,7 +26,7 @@
|
||||
const text = chainToText({
|
||||
chain: game.state.chain,
|
||||
result,
|
||||
opponentLabel: game.state.opponentName || t.opponent,
|
||||
nameOf: (id) => game.nameOf(id),
|
||||
at
|
||||
});
|
||||
downloadText(historyFilename(at), text);
|
||||
@@ -36,6 +41,23 @@
|
||||
<p class="reason">{endReasonMessages[result.reason]}</p>
|
||||
{/if}
|
||||
|
||||
{#if standings.length > 0}
|
||||
<!-- Ranked by who outlasted whom, which is what the game is decided
|
||||
on. The score sits beside the place rather than setting it. -->
|
||||
<ol class="standings" aria-label={t.standingsTitle} data-testid="standings">
|
||||
{#each standings as player (player.playerId)}
|
||||
<li class:me={player.isMe} class:winner={player.rank === 1}>
|
||||
<span class="rank">{player.rank}</span>
|
||||
<span class="name">
|
||||
{player.isMe ? game.state.nickname || t.you : player.name || t.someone}
|
||||
</span>
|
||||
<span class="points">{player.score} {t.pointsUnit}</span>
|
||||
{#if player.rank === 1}<span class="trophy" aria-label={t.winnerBadge}>🏆</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
<dl class="stats">
|
||||
<div>
|
||||
<dt>{t.finalScore}</dt>
|
||||
@@ -47,16 +69,16 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if !result.iWon}
|
||||
<!-- Only the loser is shown this, and only they were sent it. Losing
|
||||
without ever learning what the position wanted is the part that
|
||||
stings; an empty list says the position had nothing, which is
|
||||
worth hearing too. -->
|
||||
{#if result.suggestions.length > 0}
|
||||
{#if elimination}
|
||||
<!-- Only the player who was stuck is sent this, and only they were
|
||||
stuck. Losing without ever learning what the position wanted is
|
||||
the part that stings; an empty list says the position had
|
||||
nothing, which is worth hearing too. -->
|
||||
{#if elimination.suggestions.length > 0}
|
||||
<div class="suggestions">
|
||||
<h3>{t.suggestionsTitle}</h3>
|
||||
<ul>
|
||||
{#each result.suggestions as word}
|
||||
{#each elimination.suggestions as word}
|
||||
<li>{word}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -111,6 +133,53 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.standings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.standings li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.standings li.me {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.standings li.winner {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.rank {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.standings .name {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.points {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
@@ -16,13 +16,17 @@
|
||||
* compact?: boolean,
|
||||
* onready: (ready: boolean) => void,
|
||||
* onstart: () => void,
|
||||
* onkick: () => void,
|
||||
* onkick: (playerId: string) => void,
|
||||
* onleave: () => void
|
||||
* }}
|
||||
*/
|
||||
let { compact = false, onready, onstart, onkick, onleave } = $props();
|
||||
|
||||
const s = $derived(game.state);
|
||||
// The seats nobody is in yet, drawn so a room that is waiting on people
|
||||
// looks like one rather than like a room that is simply small.
|
||||
const empties = $derived(Array.from({ length: game.freeSeats }, (_, i) => i));
|
||||
const shortHanded = $derived(s.roomPlayers.length < s.minPlayers);
|
||||
</script>
|
||||
|
||||
<section class="lobby" aria-label={t.lobbyTitle}>
|
||||
@@ -30,37 +34,57 @@
|
||||
<RoomCodePanel code={s.roomCode} />
|
||||
{/if}
|
||||
|
||||
<ul class="seats">
|
||||
<li class="seat" class:ready={s.isOwner || s.isReady}>
|
||||
<span class="name">{s.nickname || t.you}</span>
|
||||
<span class="role">{s.isOwner ? t.owner : t.guest}</span>
|
||||
<!-- The owner has no readiness to show: starting is the statement. -->
|
||||
{#if !s.isOwner}
|
||||
<span class="state" data-testid="my-ready">{s.isReady ? t.isReady : t.notReady}</span>
|
||||
{/if}
|
||||
</li>
|
||||
<p class="count" data-testid="player-count">
|
||||
{fill(t.playerCount, { n: s.roomPlayers.length, max: s.maxPlayers })}
|
||||
</p>
|
||||
|
||||
<li class="seat" class:empty={!s.opponentPresent} class:ready={s.opponentReady}>
|
||||
{#if s.opponentPresent}
|
||||
<span class="name">{s.opponentName || t.opponent}</span>
|
||||
<span class="role">{s.isOwner ? t.guest : t.owner}</span>
|
||||
{#if !s.opponentConnected}
|
||||
<span class="state offline">{t.offline}</span>
|
||||
{:else if s.isOwner}
|
||||
<span class="state" data-testid="opponent-ready">
|
||||
{s.opponentReady ? t.isReady : t.notReady}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="seats">
|
||||
{#each s.roomPlayers as player (player.playerId)}
|
||||
<li class="seat" class:ready={player.isOwner || player.ready} class:me={player.isMe}>
|
||||
<span class="name">{player.isMe ? s.nickname || t.you : player.name}</span>
|
||||
<span class="role">{player.isOwner ? t.owner : t.guest}</span>
|
||||
|
||||
<span class="right">
|
||||
{#if !player.connected}
|
||||
<span class="state offline">{t.offline}</span>
|
||||
{:else if !player.isOwner}
|
||||
<!-- The owner has no readiness to show: starting is the statement. -->
|
||||
<span class="state" data-testid={player.isMe ? 'my-ready' : `ready-${player.playerId}`}>
|
||||
{player.ready ? t.isReady : t.notReady}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Only the owner sees these, and never on their own row: leaving
|
||||
is what an owner who wants out does, and it hands the room on. -->
|
||||
{#if game.isOwner && !player.isMe}
|
||||
<button
|
||||
type="button"
|
||||
class="kick"
|
||||
disabled={player.ready}
|
||||
aria-label={t.kickPlayer}
|
||||
data-testid={`kick-${player.playerId}`}
|
||||
onclick={() => onkick(player.playerId)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#each empties as index (index)}
|
||||
<li class="seat empty">
|
||||
<span class="name muted">{t.emptySeat}</span>
|
||||
{/if}
|
||||
</li>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<p class="hint">
|
||||
{#if s.isOwner}
|
||||
{s.opponentPresent ? t.ownerStartsHint : t.waitingForOpponent}
|
||||
{:else if s.isReady}
|
||||
{#if game.isOwner && shortHanded}
|
||||
{fill(t.ownerNeedsMore, { n: s.minPlayers })}
|
||||
{:else if game.isOwner}
|
||||
{t.ownerStartsHint}
|
||||
{:else if game.isReady}
|
||||
{t.waitingForStart}
|
||||
{:else}
|
||||
{t.guestReadyHint}
|
||||
@@ -68,7 +92,7 @@
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if s.isOwner}
|
||||
{#if game.isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
@@ -78,30 +102,25 @@
|
||||
>
|
||||
{t.startGame}
|
||||
</button>
|
||||
{#if s.opponentPresent}
|
||||
<button type="button" disabled={s.opponentReady} onclick={onkick}>
|
||||
{t.kickPlayer}
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
class:on={s.isReady}
|
||||
class:on={game.isReady}
|
||||
data-testid="ready"
|
||||
onclick={() => onready(!s.isReady)}
|
||||
onclick={() => onready(!game.isReady)}
|
||||
>
|
||||
{s.isReady ? t.unready : t.ready}
|
||||
{game.isReady ? t.unready : t.ready}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Disabled rather than hidden while ready: the rule is worth seeing, and
|
||||
a button that vanishes reads as a bug. -->
|
||||
<button type="button" class="leave" disabled={s.isReady} onclick={onleave}>
|
||||
<button type="button" class="leave" disabled={game.isReady} onclick={onleave}>
|
||||
{t.leaveRoom}
|
||||
</button>
|
||||
{#if s.isReady}
|
||||
{#if game.isReady}
|
||||
<p class="note">{t.unreadyToLeave}</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -114,6 +133,13 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -161,8 +187,16 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.state {
|
||||
/* One right-hand group, so a row keeps its shape whether or not it has a
|
||||
readiness to show and whether or not the reader may kick it. */
|
||||
.right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -171,6 +205,22 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.kick {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kick:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
* The banner shown while an opponent is inside their reconnect window.
|
||||
*
|
||||
* The countdown is local because the server sends a duration, not a
|
||||
* deadline: it is a rough "how much longer" for the player watching, and
|
||||
* the server still decides when the seat is actually forfeit.
|
||||
*/
|
||||
let remainingMs = $state(0);
|
||||
|
||||
const away = $derived(game.state.opponentLeft);
|
||||
|
||||
$effect(() => {
|
||||
const left = away;
|
||||
if (!left?.canReconnect) {
|
||||
remainingMs = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const endsAt = Date.now() + left.graceMs;
|
||||
remainingMs = left.graceMs;
|
||||
|
||||
const tick = setInterval(() => {
|
||||
remainingMs = Math.max(0, endsAt - Date.now());
|
||||
if (remainingMs === 0) clearInterval(tick);
|
||||
}, 250);
|
||||
return () => clearInterval(tick);
|
||||
});
|
||||
|
||||
const seconds = $derived(Math.ceil(remainingMs / 1000));
|
||||
</script>
|
||||
|
||||
{#if away}
|
||||
<p class="banner" class:gone={!away.canReconnect} role="status" data-testid="opponent-status">
|
||||
{#if !away.canReconnect}
|
||||
{t.opponentGone}
|
||||
{:else if seconds > 0}
|
||||
{fill(t.opponentDisconnectedIn, { n: seconds })}
|
||||
{:else}
|
||||
{t.opponentDisconnected}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
color: var(--warn);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner.gone {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
* What is happening to the other people in the room: who has dropped and
|
||||
* how much longer their seat is held, and who has been knocked out.
|
||||
*
|
||||
* The countdown is local because the server sends a duration, not a
|
||||
* deadline: it is a rough "how much longer" for the players watching, and
|
||||
* the server still decides when a seat is actually forfeit.
|
||||
*
|
||||
* @type {Record<string, number>}
|
||||
*/
|
||||
let endsAt = $state({});
|
||||
let now = $state(Date.now());
|
||||
|
||||
const away = $derived(game.awayPlayers);
|
||||
|
||||
// One deadline per player, opened when they drop and forgotten when they
|
||||
// come back. Deliberately not keyed off the room state as a whole: it is
|
||||
// broadcast on every change a player can see, and rebuilding the deadlines
|
||||
// from it would restart every countdown each time somebody said they were
|
||||
// ready.
|
||||
$effect(() => {
|
||||
const present = away.map((p) => p.playerId);
|
||||
untrack(() => {
|
||||
/** @type {Record<string, number>} */
|
||||
const next = {};
|
||||
for (const id of present) next[id] = endsAt[id] ?? Date.now() + game.state.graceMs;
|
||||
|
||||
const before = Object.keys(endsAt);
|
||||
if (before.length !== present.length || before.some((id) => !(id in next))) {
|
||||
endsAt = next;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (away.length === 0) return;
|
||||
const tick = setInterval(() => (now = Date.now()), 250);
|
||||
return () => clearInterval(tick);
|
||||
});
|
||||
|
||||
/** @param {string} playerId */
|
||||
function secondsLeft(playerId) {
|
||||
return Math.max(0, Math.ceil(((endsAt[playerId] ?? 0) - now) / 1000));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if game.iAmOut}
|
||||
<!-- The game carries on without this player, and saying so is the whole
|
||||
difference between being knocked out and being disconnected. -->
|
||||
<p class="banner gone" role="status" data-testid="eliminated">{t.youAreOut}</p>
|
||||
{:else if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'}
|
||||
<p class="banner" role="status" data-testid="player-out">
|
||||
{fill(t.playerOut, { name: game.state.lastOut.name || t.someone })}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#each away as player (player.playerId)}
|
||||
{@const seconds = secondsLeft(player.playerId)}
|
||||
<p class="banner" role="status" data-testid={`away-${player.playerId}`}>
|
||||
{#if seconds > 0}
|
||||
{fill(t.playerDisconnectedIn, { name: player.name || t.someone, n: seconds })}
|
||||
{:else}
|
||||
{fill(t.playerDisconnected, { name: player.name || t.someone })}
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
color: var(--warn);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner.gone {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -2,41 +2,63 @@
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/** @type {{ opponentLabel?: string }} */
|
||||
let { opponentLabel = t.opponent } = $props();
|
||||
|
||||
const me = $derived(game.state.nickname || t.you);
|
||||
/**
|
||||
* The table of the game on screen: one row per player, in turn order.
|
||||
*
|
||||
* It doubles as the turn indicator — the row that is lit is the player to
|
||||
* act — which is why there is no second one anywhere on the board.
|
||||
*/
|
||||
const s = $derived(game.state);
|
||||
// Standings once the game is over, so the board settles into the result
|
||||
// rather than freezing on the last position.
|
||||
const players = $derived(s.phase === 'over' && s.standings.length ? s.standings : s.gamePlayers);
|
||||
</script>
|
||||
|
||||
<div class="board">
|
||||
<div class="side" class:active={game.state.myTurn}>
|
||||
<span class="who">{me}</span>
|
||||
<span class="score">{game.state.myScore}</span>
|
||||
</div>
|
||||
<span class="sep" aria-hidden="true">–</span>
|
||||
<div class="side" class:active={game.state.phase === 'playing' && !game.state.myTurn}>
|
||||
<span class="who">{opponentLabel}</span>
|
||||
<span class="score">{game.state.opponentScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="board" data-testid="scoreboard">
|
||||
{#each players as player (player.playerId)}
|
||||
<li
|
||||
class="side"
|
||||
class:active={s.phase === 'playing' && player.playerId === s.turnPlayerId}
|
||||
class:out={player.eliminated}
|
||||
class:me={player.isMe}
|
||||
>
|
||||
<span class="who">
|
||||
{player.isMe ? s.nickname || t.you : player.name || t.someone}
|
||||
{#if !player.connected && !player.eliminated}
|
||||
<span class="away" title={t.offline}>⚠</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="score">{player.score}</span>
|
||||
{#if player.rank === 1}
|
||||
<span class="badge win">{t.winnerBadge}</span>
|
||||
{:else if player.eliminated}
|
||||
<span class="badge">{t.eliminated}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.board {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 6px 12px;
|
||||
flex: 1 1 0;
|
||||
min-width: 72px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* The active side is whose turn it is, so the board doubles as the turn
|
||||
@@ -46,7 +68,14 @@
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.side.out {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
@@ -55,13 +84,36 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side.me .who {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.away {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sep {
|
||||
.side.out .score {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.badge.win {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -38,14 +38,18 @@ export function historyFilename(at = new Date()) {
|
||||
* the newest-first ordering is a reading aid for the live board, not the
|
||||
* shape of the game.
|
||||
*
|
||||
* `nameOf` resolves a seat id to the name that seat was playing under. It is
|
||||
* passed in rather than read from the store so the transcript can be built and
|
||||
* tested without one.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {import('$lib/stores/game.svelte.js').ChainEntry[]} args.chain
|
||||
* @param {{ iWon: boolean, myScore: number, chainLength: number } | null} [args.result]
|
||||
* @param {string} [args.opponentLabel]
|
||||
* @param {(playerId: string) => string} [args.nameOf]
|
||||
* @param {Date} [args.at]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function chainToText({ chain, result = null, opponentLabel = t.opponent, at = new Date() }) {
|
||||
export function chainToText({ chain, result = null, nameOf = () => '', at = new Date() }) {
|
||||
const lines = [`${t.appName} — ${stamp(at)}`];
|
||||
|
||||
if (result) {
|
||||
@@ -63,7 +67,9 @@ export function chainToText({ chain, result = null, opponentLabel = t.opponent,
|
||||
lines.push(`${number} (${t.exportOpening})`);
|
||||
return;
|
||||
}
|
||||
const who = entry.byMe ? t.you : opponentLabel;
|
||||
// A four-way chain has to say which of the others played a word, not
|
||||
// merely that it was not this player's.
|
||||
const who = entry.byMe ? t.you : nameOf(entry.playerId) || t.opponent;
|
||||
const points = entry.points > 0 ? ` +${entry.points}` : '';
|
||||
lines.push(`${number} — ${who}${points}`);
|
||||
});
|
||||
|
||||
+20
-9
@@ -25,6 +25,7 @@ export const t = {
|
||||
|
||||
yourTurn: 'Đến lượt bạn',
|
||||
opponentTurn: 'Đối thủ đang suy nghĩ…',
|
||||
playerTurn: 'Đến lượt {name}…',
|
||||
currentSyllable: 'Nối tiếp tiếng',
|
||||
wordInputPlaceholder: 'Nhập từ của bạn',
|
||||
submit: 'Gửi',
|
||||
@@ -32,6 +33,7 @@ export const t = {
|
||||
chainEmpty: 'Chưa có từ nào.',
|
||||
you: 'Bạn',
|
||||
opponent: 'Đối thủ',
|
||||
someone: 'Người chơi',
|
||||
scoreLabel: 'Điểm',
|
||||
syllableUnit: 'tiếng',
|
||||
correctedFrom: 'Bạn gõ “{typed}”, từ đúng là “{word}”.',
|
||||
@@ -47,6 +49,9 @@ export const t = {
|
||||
lost: 'Bạn thua.',
|
||||
finalScore: 'Điểm cuối cùng',
|
||||
chainLength: 'Số từ trong chuỗi',
|
||||
standingsTitle: 'Kết quả',
|
||||
winnerBadge: 'Vô địch',
|
||||
pointsUnit: 'điểm',
|
||||
newRecord: 'Kỷ lục mới!',
|
||||
suggestionsTitle: 'Bạn có thể nối',
|
||||
noSuggestions: 'Không còn từ nào bắt đầu bằng tiếng “{syllable}”. Ai gặp thế này cũng chịu thôi!',
|
||||
@@ -72,7 +77,7 @@ export const t = {
|
||||
copyLink: 'Sao chép liên kết mời',
|
||||
shareLink: 'Chia sẻ',
|
||||
copied: 'Đã sao chép',
|
||||
waitingForOpponent: 'Đang chờ đối thủ vào phòng…',
|
||||
waitingForOpponent: 'Đang chờ người chơi vào phòng…',
|
||||
leaveRoom: 'Rời phòng',
|
||||
shareInvite: 'Vào chơi nối từ với tôi',
|
||||
|
||||
@@ -85,6 +90,7 @@ export const t = {
|
||||
lobbyTitle: 'Phòng chờ',
|
||||
owner: 'Chủ phòng',
|
||||
guest: 'Khách',
|
||||
playerCount: '{n}/{max} người chơi',
|
||||
ready: 'Sẵn sàng',
|
||||
unready: 'Chưa sẵn sàng',
|
||||
isReady: 'Đã sẵn sàng',
|
||||
@@ -94,14 +100,18 @@ export const t = {
|
||||
kickConfirm: 'Mời người chơi này ra khỏi phòng?',
|
||||
emptySeat: 'Còn trống',
|
||||
offline: 'Mất kết nối',
|
||||
ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi đối thủ đã sẵn sàng.',
|
||||
ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi mọi người đã sẵn sàng.',
|
||||
ownerNeedsMore: 'Cần ít nhất {n} người mới bắt đầu được. Gửi mã phòng cho bạn bè!',
|
||||
guestReadyHint: 'Bấm sẵn sàng rồi chờ chủ phòng bắt đầu.',
|
||||
waitingForStart: 'Đang chờ chủ phòng bắt đầu…',
|
||||
unreadyToLeave: 'Bỏ sẵn sàng trước khi rời phòng.',
|
||||
|
||||
opponentDisconnected: 'Đối thủ mất kết nối…',
|
||||
opponentDisconnectedIn: 'Đối thủ mất kết nối… ({n}s)',
|
||||
opponentGone: 'Đối thủ đã rời trận.',
|
||||
playerDisconnected: '{name} mất kết nối…',
|
||||
playerDisconnectedIn: '{name} mất kết nối… ({n}s)',
|
||||
youAreOut: 'Bạn đã bị loại. Ván đấu vẫn đang tiếp tục.',
|
||||
spectating: 'Bạn đang xem ván đấu.',
|
||||
playerOut: '{name} đã bị loại.',
|
||||
eliminated: 'Đã bị loại',
|
||||
|
||||
attributionIntro: 'Từ điển dựa trên',
|
||||
attributionSource: 'minhqnd/dictionary',
|
||||
@@ -149,7 +159,7 @@ export const endReasonMessages = {
|
||||
[GameEndReason.UNSPECIFIED]: '',
|
||||
[GameEndReason.TIMEOUT]: 'Hết thời gian suy nghĩ.',
|
||||
[GameEndReason.NO_LEGAL_MOVE]: 'Không còn từ nào nối được.',
|
||||
[GameEndReason.OPPONENT_LEFT]: 'Đối thủ đã rời trận.',
|
||||
[GameEndReason.OPPONENT_LEFT]: 'Có người đã rời trận.',
|
||||
[GameEndReason.RESIGNED]: 'Có người đầu hàng.'
|
||||
};
|
||||
|
||||
@@ -171,15 +181,16 @@ export const errorMessages = {
|
||||
game_start_failed: 'Không thể bắt đầu ván đấu. Hãy thử lại.',
|
||||
handshake_required: 'Phiên chơi chưa sẵn sàng. Hãy tải lại trang.',
|
||||
kicked: 'Bạn đã bị mời ra khỏi phòng.',
|
||||
cannot_kick_self: 'Bạn không thể tự mời mình ra khỏi phòng.',
|
||||
must_unready_first: 'Hãy bỏ sẵn sàng trước khi rời phòng.',
|
||||
need_two_players: 'Cần hai người chơi mới bắt đầu được.',
|
||||
not_everyone_ready: 'Đối thủ chưa sẵn sàng.',
|
||||
need_more_players: 'Cần ít nhất hai người chơi mới bắt đầu được.',
|
||||
not_everyone_ready: 'Vẫn còn người chưa sẵn sàng.',
|
||||
not_in_a_game: 'Bạn không ở trong ván đấu nào.',
|
||||
not_in_a_room: 'Bạn không ở trong phòng nào.',
|
||||
not_the_owner: 'Chỉ chủ phòng làm được việc này.',
|
||||
not_your_seat: 'Bạn không phải người chơi trong ván này.',
|
||||
no_one_to_kick: 'Chưa có ai trong phòng để mời ra.',
|
||||
opponent_offline: 'Đối thủ đang mất kết nối.',
|
||||
player_offline: 'Vẫn còn người đang mất kết nối.',
|
||||
owner_needs_no_ready: 'Chủ phòng không cần bấm sẵn sàng.',
|
||||
player_is_ready: 'Không thể mời một người đã sẵn sàng ra khỏi phòng.',
|
||||
protocol_version_mismatch: 'Phiên bản đã cũ. Hãy tải lại trang.',
|
||||
|
||||
+243
-78
@@ -136,9 +136,9 @@ export declare type Resign = Message<"noitu.v1.Resign"> & {
|
||||
export declare const ResignSchema: GenMessage<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.
|
||||
*
|
||||
@@ -159,7 +159,8 @@ export declare const SetReadySchema: GenMessage<SetReady>;
|
||||
|
||||
/**
|
||||
* StartGame is the owner beginning the game the lobby has agreed on. It is
|
||||
* refused unless the guest is seated, connected and ready.
|
||||
* refused unless at least one guest is seated and every seated guest is
|
||||
* connected and ready.
|
||||
*
|
||||
* @generated from message noitu.v1.StartGame
|
||||
*/
|
||||
@@ -173,13 +174,20 @@ export declare type StartGame = Message<"noitu.v1.StartGame"> & {
|
||||
export declare const StartGameSchema: GenMessage<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.
|
||||
*
|
||||
* @generated from message noitu.v1.KickPlayer
|
||||
*/
|
||||
export declare type KickPlayer = Message<"noitu.v1.KickPlayer"> & {
|
||||
/**
|
||||
* Which seat, from RoomState.players. A room holds up to four people, so
|
||||
* "the other one" stopped being an answer.
|
||||
*
|
||||
* @generated from field: string player_id = 1;
|
||||
*/
|
||||
playerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -394,6 +402,15 @@ export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & {
|
||||
* @generated from field: string typed = 5;
|
||||
*/
|
||||
typed: string;
|
||||
|
||||
/**
|
||||
* Which seat played it. by_me answers "was this mine"; with four people at
|
||||
* the table the chain also has to say whose the other words were, and a seat
|
||||
* id says that without the client matching display names.
|
||||
*
|
||||
* @generated from field: string player_id = 6;
|
||||
*/
|
||||
playerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -403,8 +420,125 @@ export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & {
|
||||
export declare const PlayedWordSchema: GenMessage<PlayedWord>;
|
||||
|
||||
/**
|
||||
* GameStarted is rendered per recipient: my_turn is true for exactly one of
|
||||
* the two players.
|
||||
* PlayerSlot is one seat in the room, rendered for one recipient.
|
||||
*
|
||||
* The recipient's own row is in the list like everybody else's, marked by
|
||||
* is_me. That is deliberately the only way to find yourself: a separate
|
||||
* i_am_owner alongside an is_owner in the list would be two encodings of one
|
||||
* fact, and two ways for a client to disagree with the server.
|
||||
*
|
||||
* @generated from message noitu.v1.PlayerSlot
|
||||
*/
|
||||
export declare type PlayerSlot = Message<"noitu.v1.PlayerSlot"> & {
|
||||
/**
|
||||
* Stable for as long as this player holds the seat. Not stable across a
|
||||
* seat being vacated and refilled, which is exactly when a name stops
|
||||
* meaning the same person too.
|
||||
*
|
||||
* @generated from field: string player_id = 1;
|
||||
*/
|
||||
playerId: string;
|
||||
|
||||
/**
|
||||
* Always server-sanitized, as everywhere else another player's name appears.
|
||||
*
|
||||
* @generated from field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool is_me = 3;
|
||||
*/
|
||||
isMe: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: bool is_owner = 4;
|
||||
*/
|
||||
isOwner: boolean;
|
||||
|
||||
/**
|
||||
* Always false for the owner, whose readiness is StartGame itself.
|
||||
*
|
||||
* @generated from field: bool ready = 5;
|
||||
*/
|
||||
ready: boolean;
|
||||
|
||||
/**
|
||||
* False while this player is inside their reconnect window.
|
||||
*
|
||||
* @generated from field: bool connected = 6;
|
||||
*/
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayerSlot.
|
||||
* Use `create(PlayerSlotSchema)` to create a new message.
|
||||
*/
|
||||
export declare const PlayerSlotSchema: GenMessage<PlayerSlot>;
|
||||
|
||||
/**
|
||||
* PlayerScore is one player in a running or finished game.
|
||||
*
|
||||
* Separate from PlayerSlot because they answer different questions: a slot is
|
||||
* about the room, a score is about the game being played in it. A player who
|
||||
* has been eliminated still has both — being out of the game is not being out
|
||||
* of the room.
|
||||
*
|
||||
* @generated from message noitu.v1.PlayerScore
|
||||
*/
|
||||
export declare type PlayerScore = Message<"noitu.v1.PlayerScore"> & {
|
||||
/**
|
||||
* @generated from field: string player_id = 1;
|
||||
*/
|
||||
playerId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool is_me = 3;
|
||||
*/
|
||||
isMe: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 score = 4;
|
||||
*/
|
||||
score: number;
|
||||
|
||||
/**
|
||||
* True once this player has been knocked out. They keep their seat, their
|
||||
* score and their words; they simply no longer get a turn.
|
||||
*
|
||||
* @generated from field: bool eliminated = 5;
|
||||
*/
|
||||
eliminated: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: bool connected = 6;
|
||||
*/
|
||||
connected: boolean;
|
||||
|
||||
/**
|
||||
* Final placing, 1 for the winner. Zero while the game is still running,
|
||||
* which is what tells the two apart without a second field.
|
||||
*
|
||||
* @generated from field: uint32 rank = 7;
|
||||
*/
|
||||
rank: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayerScore.
|
||||
* Use `create(PlayerScoreSchema)` to create a new message.
|
||||
*/
|
||||
export declare const PlayerScoreSchema: GenMessage<PlayerScore>;
|
||||
|
||||
/**
|
||||
* GameStarted is rendered per recipient: my_turn is true for exactly one
|
||||
* player.
|
||||
*
|
||||
* @generated from message noitu.v1.GameStarted
|
||||
*/
|
||||
@@ -441,6 +575,21 @@ export declare type GameStarted = Message<"noitu.v1.GameStarted"> & {
|
||||
* @generated from field: uint32 turn_limit_ms = 6;
|
||||
*/
|
||||
turnLimitMs: number;
|
||||
|
||||
/**
|
||||
* Everyone playing, in turn order.
|
||||
*
|
||||
* @generated from field: repeated noitu.v1.PlayerScore players = 7;
|
||||
*/
|
||||
players: PlayerScore[];
|
||||
|
||||
/**
|
||||
* Whose turn it is. my_turn above says whether it is yours; this says whose
|
||||
* it is when it is not, which a two-player game never had to.
|
||||
*
|
||||
* @generated from field: string turn_player_id = 8;
|
||||
*/
|
||||
turnPlayerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -450,9 +599,13 @@ export declare type GameStarted = Message<"noitu.v1.GameStarted"> & {
|
||||
export declare const GameStartedSchema: GenMessage<GameStarted>;
|
||||
|
||||
/**
|
||||
* TurnUpdate follows every accepted move and goes to both players, serialized
|
||||
* TurnUpdate follows every turn change and goes to every player, serialized
|
||||
* once per recipient so by_me and my_turn are correct for each.
|
||||
*
|
||||
* played is absent when the turn moved without a word being played, which is
|
||||
* what an elimination does: the syllable and the used set survive the player
|
||||
* who could not answer them.
|
||||
*
|
||||
* @generated from message noitu.v1.TurnUpdate
|
||||
*/
|
||||
export declare type TurnUpdate = Message<"noitu.v1.TurnUpdate"> & {
|
||||
@@ -481,20 +634,22 @@ export declare type TurnUpdate = Message<"noitu.v1.TurnUpdate"> & {
|
||||
*/
|
||||
turnSeq: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 my_score = 6;
|
||||
*/
|
||||
myScore: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 opponent_score = 7;
|
||||
*/
|
||||
opponentScore: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 chain_length = 8;
|
||||
*/
|
||||
chainLength: number;
|
||||
|
||||
/**
|
||||
* Everyone playing, in turn order, with scores as they stand.
|
||||
*
|
||||
* @generated from field: repeated noitu.v1.PlayerScore players = 9;
|
||||
*/
|
||||
players: PlayerScore[];
|
||||
|
||||
/**
|
||||
* @generated from field: string turn_player_id = 10;
|
||||
*/
|
||||
turnPlayerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -530,8 +685,8 @@ export declare type MoveRejected = Message<"noitu.v1.MoveRejected"> & {
|
||||
export declare const MoveRejectedSchema: GenMessage<MoveRejected>;
|
||||
|
||||
/**
|
||||
* GameOver is rendered per recipient: i_won is true for exactly one of the two
|
||||
* players.
|
||||
* GameOver is rendered per recipient: i_won is true for exactly one player,
|
||||
* the one still standing when everybody else had been eliminated.
|
||||
*
|
||||
* @generated from message noitu.v1.GameOver
|
||||
*/
|
||||
@@ -546,25 +701,20 @@ export declare type GameOver = Message<"noitu.v1.GameOver"> & {
|
||||
*/
|
||||
reason: GameEndReason;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 my_score = 3;
|
||||
*/
|
||||
myScore: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 chain_length = 4;
|
||||
*/
|
||||
chainLength: number;
|
||||
|
||||
/**
|
||||
* A few words that could still have been played from the position the game
|
||||
* ended on, filled only for the player who lost — the winner is not the one
|
||||
* who needed them. An empty list on a loss is itself the answer: the
|
||||
* position was a dead end and nobody could have answered it.
|
||||
* The final table, best first: the player left standing, then the others in
|
||||
* reverse order of elimination. Outlasting somebody is what beats them, so
|
||||
* the ranking is finishing order and each score is reported beside it rather
|
||||
* than deciding it.
|
||||
*
|
||||
* @generated from field: repeated string suggestions = 5;
|
||||
* @generated from field: repeated noitu.v1.PlayerScore standings = 6;
|
||||
*/
|
||||
suggestions: string[];
|
||||
standings: PlayerScore[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -574,25 +724,49 @@ export declare type GameOver = Message<"noitu.v1.GameOver"> & {
|
||||
export declare const GameOverSchema: GenMessage<GameOver>;
|
||||
|
||||
/**
|
||||
* @generated from message noitu.v1.OpponentLeft
|
||||
* PlayerEliminated is one player knocked out of a game that is still running.
|
||||
*
|
||||
* Rendered per recipient like everything else in a room, and the only message
|
||||
* whose contents differ by more than a flag: suggestions are filled in solely
|
||||
* for the player who went out, because they are the one who was stuck.
|
||||
*
|
||||
* @generated from message noitu.v1.PlayerEliminated
|
||||
*/
|
||||
export declare type OpponentLeft = Message<"noitu.v1.OpponentLeft"> & {
|
||||
export declare type PlayerEliminated = Message<"noitu.v1.PlayerEliminated"> & {
|
||||
/**
|
||||
* @generated from field: bool can_reconnect = 1;
|
||||
* @generated from field: string player_id = 1;
|
||||
*/
|
||||
canReconnect: boolean;
|
||||
playerId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 grace_ms = 2;
|
||||
* @generated from field: string name = 2;
|
||||
*/
|
||||
graceMs: number;
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool is_me = 3;
|
||||
*/
|
||||
isMe: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: noitu.v1.GameEndReason reason = 4;
|
||||
*/
|
||||
reason: GameEndReason;
|
||||
|
||||
/**
|
||||
* A few words the position still had, for the player who just lost it. An
|
||||
* empty list is itself the answer: nobody could have answered that syllable.
|
||||
*
|
||||
* @generated from field: repeated string suggestions = 5;
|
||||
*/
|
||||
suggestions: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.OpponentLeft.
|
||||
* Use `create(OpponentLeftSchema)` to create a new message.
|
||||
* Describes the message noitu.v1.PlayerEliminated.
|
||||
* Use `create(PlayerEliminatedSchema)` to create a new message.
|
||||
*/
|
||||
export declare const OpponentLeftSchema: GenMessage<OpponentLeft>;
|
||||
export declare const PlayerEliminatedSchema: GenMessage<PlayerEliminated>;
|
||||
|
||||
/**
|
||||
* ServerError.message is a UI key such as "room_not_found", never prose: all
|
||||
@@ -640,15 +814,16 @@ export declare type Pong = Message<"noitu.v1.Pong"> & {
|
||||
export declare const PongSchema: GenMessage<Pong>;
|
||||
|
||||
/**
|
||||
* RoomState is the whole lobby, rendered for one recipient, and it is the only
|
||||
* RoomState is the whole room, rendered for one recipient, and it is the only
|
||||
* thing the lobby screen is built from. Sent on every change a player could
|
||||
* see — a seat filled or freed, a readiness set, an owner promoted — and again
|
||||
* on resume, so a client that missed a frame recovers by being told the state
|
||||
* rather than by replaying the events that led to it.
|
||||
* see — a seat filled or freed, a readiness set, an owner promoted, somebody
|
||||
* dropping or coming back — and again on resume, so a client that missed a
|
||||
* frame recovers by being told the state rather than by replaying the events
|
||||
* that led to it.
|
||||
*
|
||||
* The seat that is absent is reported as an unoccupied opponent rather than by
|
||||
* omitting the field, so "alone in the room" and "opponent still loading" are
|
||||
* never the same frame.
|
||||
* It describes the room, not the game, so it is meaningful during one too:
|
||||
* while a game runs this is what carries presence, which is why there is no
|
||||
* separate message for a player disconnecting.
|
||||
*
|
||||
* @generated from message noitu.v1.RoomState
|
||||
*/
|
||||
@@ -658,13 +833,6 @@ export declare type RoomState = Message<"noitu.v1.RoomState"> & {
|
||||
*/
|
||||
roomCode: string;
|
||||
|
||||
/**
|
||||
* True for the player who may start the game and kick the other.
|
||||
*
|
||||
* @generated from field: bool i_am_owner = 2;
|
||||
*/
|
||||
iAmOwner: boolean;
|
||||
|
||||
/**
|
||||
* Whether StartGame would be accepted right now. The server decides this
|
||||
* because it owns every condition that feeds it.
|
||||
@@ -674,38 +842,35 @@ export declare type RoomState = Message<"noitu.v1.RoomState"> & {
|
||||
canStart: boolean;
|
||||
|
||||
/**
|
||||
* The recipient's own readiness. Always false for the owner, whose readiness
|
||||
* is StartGame itself.
|
||||
* Everyone seated, in seat order, which is also the turn order a game will
|
||||
* use. Always includes the recipient, marked is_me.
|
||||
*
|
||||
* @generated from field: bool i_am_ready = 4;
|
||||
* @generated from field: repeated noitu.v1.PlayerSlot players = 9;
|
||||
*/
|
||||
iAmReady: boolean;
|
||||
players: PlayerSlot[];
|
||||
|
||||
/**
|
||||
* False when the other seat is empty; the fields below are then meaningless.
|
||||
* How many seats the room has and how many players a game needs. Sent
|
||||
* rather than compiled in, so the lobby draws whatever the server allows and
|
||||
* raising the limit does not need a client deploy.
|
||||
*
|
||||
* @generated from field: bool opponent_present = 5;
|
||||
* @generated from field: uint32 max_players = 10;
|
||||
*/
|
||||
opponentPresent: boolean;
|
||||
maxPlayers: number;
|
||||
|
||||
/**
|
||||
* Always server-sanitized, as everywhere else another player's name appears.
|
||||
*
|
||||
* @generated from field: string opponent_name = 6;
|
||||
* @generated from field: uint32 min_players = 11;
|
||||
*/
|
||||
opponentName: string;
|
||||
minPlayers: number;
|
||||
|
||||
/**
|
||||
* @generated from field: bool opponent_ready = 7;
|
||||
*/
|
||||
opponentReady: boolean;
|
||||
|
||||
/**
|
||||
* False while the other player is inside their reconnect window.
|
||||
* How long a seat is held for a player who has dropped. The client counts
|
||||
* down against it for anybody whose connected is false; the server still
|
||||
* decides when the seat is actually forfeit.
|
||||
*
|
||||
* @generated from field: bool opponent_connected = 8;
|
||||
* @generated from field: uint32 grace_ms = 12;
|
||||
*/
|
||||
opponentConnected: boolean;
|
||||
graceMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -816,12 +981,6 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & {
|
||||
*/
|
||||
value: GameOver;
|
||||
case: "gameOver";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.OpponentLeft opponent_left = 8;
|
||||
*/
|
||||
value: OpponentLeft;
|
||||
case: "opponentLeft";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.ServerError error = 9;
|
||||
@@ -852,6 +1011,12 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & {
|
||||
*/
|
||||
value: ChatHistory;
|
||||
case: "chatHistory";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.PlayerEliminated player_eliminated = 15;
|
||||
*/
|
||||
value: PlayerEliminated;
|
||||
case: "playerEliminated";
|
||||
} | { case: undefined; value?: undefined };
|
||||
};
|
||||
|
||||
|
||||
Generated
+29
-15
@@ -21,7 +21,7 @@ import { enumDesc, fileDesc, messageDesc, tsEnum } from "@bufbuild/protobuf/code
|
||||
* Describes the file noitu/v1/game.proto.
|
||||
*/
|
||||
export const file_noitu_v1_game = /*@__PURE__*/
|
||||
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiDAoKS2lja1BsYXllciILCglMZWF2ZVJvb20iGAoIU2VuZENoYXQSDAoEdGV4dBgBIAEoCSIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIpAECg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIABInCglzZXRfcmVhZHkYCSABKAsyEi5ub2l0dS52MS5TZXRSZWFkeUgAEikKCnN0YXJ0X2dhbWUYCiABKAsyEy5ub2l0dS52MS5TdGFydEdhbWVIABIrCgtraWNrX3BsYXllchgLIAEoCzIULm5vaXR1LnYxLktpY2tQbGF5ZXJIABIpCgpsZWF2ZV9yb29tGAwgASgLMhMubm9pdHUudjEuTGVhdmVSb29tSAASJwoJc2VuZF9jaGF0GA0gASgLMhIubm9pdHUudjEuU2VuZENoYXRIAEIJCgdwYXlsb2FkSgQICBAJImgKB1dlbGNvbWUSEgoKc2Vzc2lvbl9pZBgBIAEoCRIUCgxyZXN1bWVfdG9rZW4YAiABKAkSGAoQcHJvdG9jb2xfdmVyc2lvbhgDIAEoDRIZChFhY2NlcHRlZF9uaWNrbmFtZRgEIAEoCSJbCgpQbGF5ZWRXb3JkEgwKBHdvcmQYASABKAkSDQoFYnlfbWUYAiABKAgSDgoGcG9pbnRzGAMgASgNEhEKCXN5bGxhYmxlcxgEIAEoDRINCgV0eXBlZBgFIAEoCSKRAQoLR2FtZVN0YXJ0ZWQSFAoMb3BlbmluZ193b3JkGAEgASgJEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhUKDXR1cm5fbGltaXRfbXMYBiABKA0iyQEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIQCghteV9zY29yZRgGIAEoDRIWCg5vcHBvbmVudF9zY29yZRgHIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0iVgoMTW92ZVJlamVjdGVkEiYKBnJlYXNvbhgBIAEoDjIWLm5vaXR1LnYxLlJlamVjdFJlYXNvbhIMCgR3b3JkGAIgASgJEhAKCHR1cm5fc2VxGAMgASgNIn8KCEdhbWVPdmVyEg0KBWlfd29uGAEgASgIEicKBnJlYXNvbhgCIAEoDjIXLm5vaXR1LnYxLkdhbWVFbmRSZWFzb24SEAoIbXlfc2NvcmUYAyABKA0SFAoMY2hhaW5fbGVuZ3RoGAQgASgNEhMKC3N1Z2dlc3Rpb25zGAUgAygJIjcKDE9wcG9uZW50TGVmdBIVCg1jYW5fcmVjb25uZWN0GAEgASgIEhAKCGdyYWNlX21zGAIgASgNIiwKC1NlcnZlckVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSI2CgRQb25nEhYKDmNsaWVudF90aW1lX21zGAEgASgDEhYKDnNlcnZlcl90aW1lX21zGAIgASgDIr4BCglSb29tU3RhdGUSEQoJcm9vbV9jb2RlGAEgASgJEhIKCmlfYW1fb3duZXIYAiABKAgSEQoJY2FuX3N0YXJ0GAMgASgIEhIKCmlfYW1fcmVhZHkYBCABKAgSGAoQb3Bwb25lbnRfcHJlc2VudBgFIAEoCBIVCg1vcHBvbmVudF9uYW1lGAYgASgJEhYKDm9wcG9uZW50X3JlYWR5GAcgASgIEhoKEm9wcG9uZW50X2Nvbm5lY3RlZBgIIAEoCCJSCgtDaGF0TWVzc2FnZRIPCgdmcm9tX21lGAEgASgIEg4KBmF1dGhvchgCIAEoCRIMCgR0ZXh0GAMgASgJEhQKDHNlbnRfdW5peF9tcxgEIAEoAyI2CgtDaGF0SGlzdG9yeRInCghtZXNzYWdlcxgBIAMoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlIooECg1TZXJ2ZXJNZXNzYWdlEiQKB3dlbGNvbWUYASABKAsyES5ub2l0dS52MS5XZWxjb21lSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lHTkVEEARCOlo4Z2l0aHViLmNvbS90aWVubm05OWRldi9ub2l0dS9zZXJ2ZXIvZ2VuL25vaXR1L3YxO25vaXR1djFiBnByb3RvMw");
|
||||
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiHwoKS2lja1BsYXllchIRCglwbGF5ZXJfaWQYASABKAkiCwoJTGVhdmVSb29tIhgKCFNlbmRDaGF0EgwKBHRleHQYASABKAkiHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyKQBAoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAEicKCXNlbmRfY2hhdBgNIAEoCzISLm5vaXR1LnYxLlNlbmRDaGF0SABCCQoHcGF5bG9hZEoECAgQCSJoCgdXZWxjb21lEhIKCnNlc3Npb25faWQYASABKAkSFAoMcmVzdW1lX3Rva2VuGAIgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YAyABKA0SGQoRYWNjZXB0ZWRfbmlja25hbWUYBCABKAkibgoKUGxheWVkV29yZBIMCgR3b3JkGAEgASgJEg0KBWJ5X21lGAIgASgIEg4KBnBvaW50cxgDIAEoDRIRCglzeWxsYWJsZXMYBCABKA0SDQoFdHlwZWQYBSABKAkSEQoJcGxheWVyX2lkGAYgASgJInAKClBsYXllclNsb3QSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSEAoIaXNfb3duZXIYBCABKAgSDQoFcmVhZHkYBSABKAgSEQoJY29ubmVjdGVkGAYgASgIIoEBCgtQbGF5ZXJTY29yZRIRCglwbGF5ZXJfaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgVpc19tZRgDIAEoCBINCgVzY29yZRgEIAEoDRISCgplbGltaW5hdGVkGAUgASgIEhEKCWNvbm5lY3RlZBgGIAEoCBIMCgRyYW5rGAcgASgNItEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDRImCgdwbGF5ZXJzGAcgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmUSFgoOdHVybl9wbGF5ZXJfaWQYCCABKAki6wEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0SJgoHcGxheWVycxgJIAMoCzIVLm5vaXR1LnYxLlBsYXllclNjb3JlEhYKDnR1cm5fcGxheWVyX2lkGAogASgJSgQIBhAHSgQIBxAIIlYKDE1vdmVSZWplY3RlZBImCgZyZWFzb24YASABKA4yFi5ub2l0dS52MS5SZWplY3RSZWFzb24SDAoEd29yZBgCIAEoCRIQCgh0dXJuX3NlcRgDIAEoDSKOAQoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIUCgxjaGFpbl9sZW5ndGgYBCABKA0SKAoJc3RhbmRpbmdzGAYgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmVKBAgDEARKBAgFEAYigAEKEFBsYXllckVsaW1pbmF0ZWQSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSJwoGcmVhc29uGAQgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhITCgtzdWdnZXN0aW9ucxgFIAMoCSIsCgtTZXJ2ZXJFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiNgoEUG9uZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAxIWCg5zZXJ2ZXJfdGltZV9tcxgCIAEoAyK4AQoJUm9vbVN0YXRlEhEKCXJvb21fY29kZRgBIAEoCRIRCgljYW5fc3RhcnQYAyABKAgSJQoHcGxheWVycxgJIAMoCzIULm5vaXR1LnYxLlBsYXllclNsb3QSEwoLbWF4X3BsYXllcnMYCiABKA0SEwoLbWluX3BsYXllcnMYCyABKA0SEAoIZ3JhY2VfbXMYDCABKA1KBAgCEANKBAgEEAVKBAgFEAZKBAgGEAdKBAgHEAhKBAgIEAkiUgoLQ2hhdE1lc3NhZ2USDwoHZnJvbV9tZRgBIAEoCBIOCgZhdXRob3IYAiABKAkSDAoEdGV4dBgDIAEoCRIUCgxzZW50X3VuaXhfbXMYBCABKAMiNgoLQ2hhdEhpc3RvcnkSJwoIbWVzc2FnZXMYASADKAsyFS5ub2l0dS52MS5DaGF0TWVzc2FnZSKYBAoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDGdhbWVfc3RhcnRlZBgEIAEoCzIVLm5vaXR1LnYxLkdhbWVTdGFydGVkSAASKwoLdHVybl91cGRhdGUYBSABKAsyFC5ub2l0dS52MS5UdXJuVXBkYXRlSAASLwoNbW92ZV9yZWplY3RlZBgGIAEoCzIWLm5vaXR1LnYxLk1vdmVSZWplY3RlZEgAEicKCWdhbWVfb3ZlchgHIAEoCzISLm5vaXR1LnYxLkdhbWVPdmVySAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIABI3ChFwbGF5ZXJfZWxpbWluYXRlZBgPIAEoCzIaLm5vaXR1LnYxLlBsYXllckVsaW1pbmF0ZWRIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICBAJSgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lLine truncated
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Hello.
|
||||
@@ -128,82 +128,96 @@ export const WelcomeSchema = /*@__PURE__*/
|
||||
export const PlayedWordSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 14);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayerSlot.
|
||||
* Use `create(PlayerSlotSchema)` to create a new message.
|
||||
*/
|
||||
export const PlayerSlotSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 15);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayerScore.
|
||||
* Use `create(PlayerScoreSchema)` to create a new message.
|
||||
*/
|
||||
export const PlayerScoreSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 16);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.GameStarted.
|
||||
* Use `create(GameStartedSchema)` to create a new message.
|
||||
*/
|
||||
export const GameStartedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 15);
|
||||
messageDesc(file_noitu_v1_game, 17);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.TurnUpdate.
|
||||
* Use `create(TurnUpdateSchema)` to create a new message.
|
||||
*/
|
||||
export const TurnUpdateSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 16);
|
||||
messageDesc(file_noitu_v1_game, 18);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.MoveRejected.
|
||||
* Use `create(MoveRejectedSchema)` to create a new message.
|
||||
*/
|
||||
export const MoveRejectedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 17);
|
||||
messageDesc(file_noitu_v1_game, 19);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.GameOver.
|
||||
* Use `create(GameOverSchema)` to create a new message.
|
||||
*/
|
||||
export const GameOverSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 18);
|
||||
messageDesc(file_noitu_v1_game, 20);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.OpponentLeft.
|
||||
* Use `create(OpponentLeftSchema)` to create a new message.
|
||||
* Describes the message noitu.v1.PlayerEliminated.
|
||||
* Use `create(PlayerEliminatedSchema)` to create a new message.
|
||||
*/
|
||||
export const OpponentLeftSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 19);
|
||||
export const PlayerEliminatedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 21);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ServerError.
|
||||
* Use `create(ServerErrorSchema)` to create a new message.
|
||||
*/
|
||||
export const ServerErrorSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 20);
|
||||
messageDesc(file_noitu_v1_game, 22);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Pong.
|
||||
* Use `create(PongSchema)` to create a new message.
|
||||
*/
|
||||
export const PongSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 21);
|
||||
messageDesc(file_noitu_v1_game, 23);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RoomState.
|
||||
* Use `create(RoomStateSchema)` to create a new message.
|
||||
*/
|
||||
export const RoomStateSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 22);
|
||||
messageDesc(file_noitu_v1_game, 24);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ChatMessage.
|
||||
* Use `create(ChatMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ChatMessageSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 23);
|
||||
messageDesc(file_noitu_v1_game, 25);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ChatHistory.
|
||||
* Use `create(ChatHistorySchema)` to create a new message.
|
||||
*/
|
||||
export const ChatHistorySchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 24);
|
||||
messageDesc(file_noitu_v1_game, 26);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ServerMessage.
|
||||
* Use `create(ServerMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ServerMessageSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 25);
|
||||
messageDesc(file_noitu_v1_game, 27);
|
||||
|
||||
/**
|
||||
* Describes the enum noitu.v1.Difficulty.
|
||||
|
||||
@@ -16,9 +16,27 @@ export const CHAT_WINDOW = 20;
|
||||
* @property {string} word - the canonical spelling
|
||||
* @property {string} typed - what the player actually typed, when it differed
|
||||
* @property {boolean} byMe
|
||||
* @property {string} playerId - the seat that played it, empty for the opening
|
||||
* @property {number} points
|
||||
* @property {number} syllables
|
||||
* @property {boolean} opening - the seed word, played by neither side
|
||||
*
|
||||
* @typedef {object} PlayerSlot
|
||||
* @property {string} playerId
|
||||
* @property {string} name
|
||||
* @property {boolean} isMe
|
||||
* @property {boolean} isOwner
|
||||
* @property {boolean} ready
|
||||
* @property {boolean} connected
|
||||
*
|
||||
* @typedef {object} PlayerScore
|
||||
* @property {string} playerId
|
||||
* @property {string} name
|
||||
* @property {boolean} isMe
|
||||
* @property {number} score
|
||||
* @property {boolean} eliminated
|
||||
* @property {boolean} connected
|
||||
* @property {number} rank - final placing, 1 for the winner; 0 while in play
|
||||
*/
|
||||
|
||||
/** @returns {any} */
|
||||
@@ -42,25 +60,64 @@ function initialState() {
|
||||
deadlineMs: 0,
|
||||
turnSeq: 0,
|
||||
turnLimitMs: 0,
|
||||
myScore: 0,
|
||||
opponentScore: 0,
|
||||
chainLength: 0,
|
||||
|
||||
nickname: '',
|
||||
opponentName: '',
|
||||
roomCode: '',
|
||||
|
||||
/**
|
||||
* The lobby, exactly as the server last described it. Every field is
|
||||
* server-owned: the client never decides who owns the room, who is
|
||||
* ready, or whether a game may start.
|
||||
* The room, exactly as the server last described it. Every field is
|
||||
* server-owned: the client never decides who is seated, who owns the
|
||||
* room, who is ready, or whether a game may start.
|
||||
*
|
||||
* The recipient's own row is in `roomPlayers` like everybody else's,
|
||||
* marked `isMe`, which is what the derived accessors below read.
|
||||
*
|
||||
* @type {PlayerSlot[]}
|
||||
*/
|
||||
isOwner: false,
|
||||
isReady: false,
|
||||
roomPlayers: [],
|
||||
canStart: false,
|
||||
opponentPresent: false,
|
||||
opponentReady: false,
|
||||
opponentConnected: false,
|
||||
/**
|
||||
* How many seats the room has and how many a game needs. Sent by the
|
||||
* server rather than compiled in here, so widening a room is a server
|
||||
* change alone.
|
||||
*/
|
||||
maxPlayers: 0,
|
||||
minPlayers: 0,
|
||||
/** How long a seat is held for somebody who dropped. */
|
||||
graceMs: 0,
|
||||
|
||||
/**
|
||||
* The table of a running game, in turn order, and who is on turn.
|
||||
*
|
||||
* @type {PlayerScore[]}
|
||||
*/
|
||||
gamePlayers: [],
|
||||
turnPlayerId: '',
|
||||
/**
|
||||
* The final table, best first: the player left standing, then the rest
|
||||
* in reverse order of elimination.
|
||||
*
|
||||
* @type {PlayerScore[]}
|
||||
*/
|
||||
standings: [],
|
||||
|
||||
/**
|
||||
* This player's own knockout, and nobody else's. `suggestions` is what
|
||||
* the position still had when they lost it; empty means it was a dead
|
||||
* end, which is a different thing to say than "here is what you
|
||||
* missed".
|
||||
*
|
||||
* @type {{ playerId: string, name: string, reason: number, suggestions: string[] } | null}
|
||||
*/
|
||||
elimination: null,
|
||||
/**
|
||||
* The last player to go out, whoever they were. It is what a spectator
|
||||
* is shown; the client's own knockout is `elimination` above.
|
||||
*
|
||||
* @type {{ playerId: string, name: string, isMe: boolean, reason: number } | null}
|
||||
*/
|
||||
lastOut: null,
|
||||
|
||||
/**
|
||||
* The room's conversation, oldest first, capped at CHAT_WINDOW. Chat
|
||||
@@ -84,21 +141,35 @@ function initialState() {
|
||||
/** @type {{ word: string, message: string } | null} */
|
||||
rejection: null,
|
||||
/**
|
||||
* The finished game. `suggestions` is what the position still had to
|
||||
* offer and arrives only for the player who lost; empty on a loss
|
||||
* means the position was a dead end, which is a different thing to
|
||||
* say than "here is what you missed".
|
||||
* The finished game, from this player's side. The table it came with
|
||||
* is `standings`; this is the part about them.
|
||||
*
|
||||
* @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number, suggestions: string[] } | null}
|
||||
* @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null}
|
||||
*/
|
||||
result: null,
|
||||
/** @type {{ canReconnect: boolean, graceMs: number } | null} */
|
||||
opponentLeft: null,
|
||||
/** @type {string | null} */
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one PlayerScore off the wire.
|
||||
*
|
||||
* @param {any} p
|
||||
* @returns {PlayerScore}
|
||||
*/
|
||||
function toScore(p) {
|
||||
return {
|
||||
playerId: p.playerId,
|
||||
name: p.name,
|
||||
isMe: p.isMe,
|
||||
score: p.score,
|
||||
eliminated: p.eliminated,
|
||||
connected: p.connected,
|
||||
rank: p.rank
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a store instance. Tests construct their own rather than sharing the
|
||||
* module singleton, so one test's game cannot leak into the next.
|
||||
@@ -108,19 +179,17 @@ export function createGameStore() {
|
||||
|
||||
/**
|
||||
* Returns the model to its pre-game shape, keeping the identity fields and
|
||||
* the lobby. A game ending, or a new one starting, does not change which
|
||||
* the room. A game ending, or a new one starting, does not change which
|
||||
* room this is or who is in it — the server says so with its own message.
|
||||
*/
|
||||
const kept = new Set([
|
||||
'nickname',
|
||||
'roomCode',
|
||||
'opponentName',
|
||||
'isOwner',
|
||||
'isReady',
|
||||
'roomPlayers',
|
||||
'canStart',
|
||||
'opponentPresent',
|
||||
'opponentReady',
|
||||
'opponentConnected',
|
||||
'maxPlayers',
|
||||
'minPlayers',
|
||||
'graceMs',
|
||||
'chat',
|
||||
'chatCount'
|
||||
]);
|
||||
@@ -166,16 +235,18 @@ export function createGameStore() {
|
||||
// is how a client ends up believing a mixture of two states
|
||||
// the server was never in.
|
||||
state.roomCode = value.roomCode;
|
||||
state.isOwner = value.iAmOwner;
|
||||
state.isReady = value.iAmReady;
|
||||
state.canStart = value.canStart;
|
||||
state.opponentPresent = value.opponentPresent;
|
||||
state.opponentName = value.opponentName;
|
||||
state.opponentReady = value.opponentReady;
|
||||
state.opponentConnected = value.opponentConnected;
|
||||
// A room state arriving mid-game is a presence change, and an
|
||||
// opponent who is connected again is not one to wait for.
|
||||
if (value.opponentPresent && value.opponentConnected) state.opponentLeft = null;
|
||||
state.maxPlayers = value.maxPlayers;
|
||||
state.minPlayers = value.minPlayers;
|
||||
state.graceMs = value.graceMs;
|
||||
state.roomPlayers = value.players.map((/** @type {any} */ p) => ({
|
||||
playerId: p.playerId,
|
||||
name: p.name,
|
||||
isMe: p.isMe,
|
||||
isOwner: p.isOwner,
|
||||
ready: p.ready,
|
||||
connected: p.connected
|
||||
}));
|
||||
// The lobby is where a room sits when no game is on. `over`
|
||||
// keeps its result panel, which the lobby appears beneath.
|
||||
if (state.phase === 'idle') state.phase = 'lobby';
|
||||
@@ -183,7 +254,7 @@ export function createGameStore() {
|
||||
|
||||
case 'gameStarted':
|
||||
// reset() clears the readiness that led here, along with the
|
||||
// last game's board.
|
||||
// last game's board and its knockouts.
|
||||
reset();
|
||||
state.phase = 'playing';
|
||||
state.chain = [
|
||||
@@ -191,6 +262,7 @@ export function createGameStore() {
|
||||
word: value.openingWord,
|
||||
typed: '',
|
||||
byMe: false,
|
||||
playerId: '',
|
||||
points: 0,
|
||||
syllables: 0,
|
||||
opening: true
|
||||
@@ -202,15 +274,21 @@ export function createGameStore() {
|
||||
state.turnSeq = value.turnSeq;
|
||||
state.turnLimitMs = value.turnLimitMs;
|
||||
state.chainLength = 1;
|
||||
state.gamePlayers = value.players.map(toScore);
|
||||
state.turnPlayerId = value.turnPlayerId;
|
||||
break;
|
||||
|
||||
case 'turnUpdate': {
|
||||
const played = value.played;
|
||||
// A turn update with no word is an elimination moving the turn
|
||||
// on: the syllable and the chain survive the player who could
|
||||
// not answer them, so there is nothing to append.
|
||||
if (played) {
|
||||
state.chain.push({
|
||||
word: played.word,
|
||||
typed: played.typed,
|
||||
byMe: played.byMe,
|
||||
playerId: played.playerId,
|
||||
points: played.points,
|
||||
syllables: played.syllables,
|
||||
opening: false
|
||||
@@ -220,9 +298,9 @@ export function createGameStore() {
|
||||
state.myTurn = value.myTurn;
|
||||
state.deadlineMs = Number(value.deadlineUnixMs);
|
||||
state.turnSeq = value.turnSeq;
|
||||
state.myScore = value.myScore;
|
||||
state.opponentScore = value.opponentScore;
|
||||
state.chainLength = value.chainLength;
|
||||
state.gamePlayers = value.players.map(toScore);
|
||||
state.turnPlayerId = value.turnPlayerId;
|
||||
// An accepted move answers the previous rejection.
|
||||
state.rejection = null;
|
||||
break;
|
||||
@@ -235,27 +313,40 @@ export function createGameStore() {
|
||||
};
|
||||
break;
|
||||
|
||||
case 'gameOver':
|
||||
case 'playerEliminated':
|
||||
state.lastOut = {
|
||||
playerId: value.playerId,
|
||||
name: value.name,
|
||||
isMe: value.isMe,
|
||||
reason: value.reason
|
||||
};
|
||||
// Only the player who went out is sent suggestions, and only
|
||||
// they have a use for them: they describe the position that
|
||||
// beat them, which is nobody else's position.
|
||||
if (value.isMe) {
|
||||
state.myTurn = false;
|
||||
state.elimination = {
|
||||
playerId: value.playerId,
|
||||
name: value.name,
|
||||
reason: value.reason,
|
||||
suggestions: value.suggestions ?? []
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'gameOver': {
|
||||
state.phase = 'over';
|
||||
state.myTurn = false;
|
||||
state.standings = value.standings.map(toScore);
|
||||
const mine = state.standings.find((/** @type {PlayerScore} */ p) => p.isMe);
|
||||
state.result = {
|
||||
iWon: value.iWon,
|
||||
reason: value.reason,
|
||||
myScore: value.myScore,
|
||||
chainLength: value.chainLength,
|
||||
suggestions: value.suggestions ?? []
|
||||
myScore: mine?.score ?? 0,
|
||||
chainLength: value.chainLength
|
||||
};
|
||||
break;
|
||||
|
||||
case 'opponentLeft':
|
||||
// Only sent while a game is running: in a lobby the same fact
|
||||
// arrives as part of the room's own state.
|
||||
state.opponentLeft = {
|
||||
canReconnect: value.canReconnect,
|
||||
graceMs: value.graceMs
|
||||
};
|
||||
state.opponentConnected = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chatMessage':
|
||||
state.chat.push({
|
||||
@@ -313,15 +404,68 @@ export function createGameStore() {
|
||||
apply,
|
||||
reset,
|
||||
leave,
|
||||
|
||||
/**
|
||||
* The recipient's own row in the room. Their role and their readiness
|
||||
* live there and nowhere else: a second copy alongside the list is a
|
||||
* second thing to keep in step with the server.
|
||||
*
|
||||
* @returns {PlayerSlot | null}
|
||||
*/
|
||||
get me() {
|
||||
return state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.isMe) ?? null;
|
||||
},
|
||||
get isOwner() {
|
||||
return this.me?.isOwner ?? false;
|
||||
},
|
||||
get isReady() {
|
||||
return this.me?.ready ?? false;
|
||||
},
|
||||
/** How many seats are still free, for a lobby that draws the empty ones. */
|
||||
get freeSeats() {
|
||||
return Math.max(0, state.maxPlayers - state.roomPlayers.length);
|
||||
},
|
||||
/** Whether this player has been knocked out of the game still running. */
|
||||
get iAmOut() {
|
||||
return state.phase === 'playing' && state.elimination !== null;
|
||||
},
|
||||
/** This player's score in the game on screen, finished or not. */
|
||||
get myScore() {
|
||||
const table = state.phase === 'over' ? state.standings : state.gamePlayers;
|
||||
return table.find((/** @type {PlayerScore} */ p) => p.isMe)?.score ?? 0;
|
||||
},
|
||||
/**
|
||||
* Everybody whose reconnect window is currently running. The lobby and
|
||||
* the board both wait on the same list.
|
||||
*
|
||||
* @returns {PlayerSlot[]}
|
||||
*/
|
||||
get awayPlayers() {
|
||||
return state.roomPlayers.filter((/** @type {PlayerSlot} */ p) => !p.isMe && !p.connected);
|
||||
},
|
||||
/**
|
||||
* The name behind a seat id, for the chain and the board. Falls back to
|
||||
* the id's absence rather than inventing a label: an empty string is
|
||||
* something a caller can substitute its own copy for.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {string}
|
||||
*/
|
||||
nameOf(playerId) {
|
||||
const from = state.gamePlayers.length ? state.gamePlayers : state.standings;
|
||||
return (
|
||||
from.find((/** @type {PlayerScore} */ p) => p.playerId === playerId)?.name ??
|
||||
state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.playerId === playerId)?.name ??
|
||||
''
|
||||
);
|
||||
},
|
||||
|
||||
clearRejection() {
|
||||
state.rejection = null;
|
||||
},
|
||||
clearError() {
|
||||
state.error = null;
|
||||
},
|
||||
clearOpponentLeft() {
|
||||
state.opponentLeft = null;
|
||||
},
|
||||
/**
|
||||
* Forgets the conversation without forgetting the room. The screen
|
||||
* calls this when it is entered and left: chat survives reset() so a
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
* with `protocol_version_mismatch` rather than failing to decode, so this
|
||||
* constant is the client half of that contract.
|
||||
*/
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
export const PROTOCOL_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Thin builders, one per client message. They exist so no other module has to
|
||||
@@ -80,8 +80,8 @@ export function submitWord(word, turnSeq) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the guest ready for the next game, or takes it back. Only the guest
|
||||
* has a readiness to declare: the owner's is Start itself.
|
||||
* Declares this player ready for the next game, or takes it back. Only guests
|
||||
* have a readiness to declare: the owner's is Start itself.
|
||||
*
|
||||
* @param {boolean} ready
|
||||
*/
|
||||
@@ -91,17 +91,22 @@ export function setReady(ready) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Begins the game the lobby has agreed on. Refused unless the guest is ready. */
|
||||
/** Begins the game the lobby has agreed on. Refused unless every guest is ready. */
|
||||
export function startGame() {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'startGame', value: create(StartGameSchema, {}) }
|
||||
});
|
||||
}
|
||||
|
||||
/** Frees the guest's seat. Refused while they are ready. */
|
||||
export function kickPlayer() {
|
||||
/**
|
||||
* Frees one named seat. Refused while that player is ready, and refused on the
|
||||
* owner's own seat — leaving is what an owner who wants out does.
|
||||
*
|
||||
* @param {string} playerId
|
||||
*/
|
||||
export function kickPlayer(playerId) {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'kickPlayer', value: create(KickPlayerSchema, {}) }
|
||||
payload: { case: 'kickPlayer', value: create(KickPlayerSchema, { playerId }) }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ export function leaveRoom() {
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of chat to the other player. The server sanitizes and caps it, so
|
||||
* One line of chat to the rest of the room. The server sanitizes and caps it, so
|
||||
* this sends what was typed and lets the copy that comes back be the truth.
|
||||
*
|
||||
* @param {string} text
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import ChatPanel from '$lib/components/ChatPanel.svelte';
|
||||
import Lobby from '$lib/components/Lobby.svelte';
|
||||
import NicknameInput from '$lib/components/NicknameInput.svelte';
|
||||
import OpponentStatus from '$lib/components/OpponentStatus.svelte';
|
||||
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { isRoomCode, normalizeRoomCode, ROOM_CODE_LENGTH } from '$lib/room-code.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
@@ -161,8 +161,9 @@
|
||||
send(startGame());
|
||||
}
|
||||
|
||||
function kick() {
|
||||
if (confirm(t.kickConfirm)) send(kickPlayer());
|
||||
/** @param {string} playerId */
|
||||
function kick(playerId) {
|
||||
if (confirm(t.kickConfirm)) send(kickPlayer(playerId));
|
||||
}
|
||||
|
||||
function leave() {
|
||||
@@ -191,14 +192,9 @@
|
||||
|
||||
<section class="online">
|
||||
{#if playing}
|
||||
<GameBoard
|
||||
opponentLabel={game.state.opponentName || t.opponent}
|
||||
modeLabel={game.state.roomCode}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
>
|
||||
<GameBoard modeLabel={game.state.roomCode} onsubmit={play} onresign={giveUp}>
|
||||
{#snippet banner()}
|
||||
<OpponentStatus />
|
||||
<PlayerStatus />
|
||||
{/snippet}
|
||||
{#snippet chat()}
|
||||
<ChatPanel collapsible onsend={say} />
|
||||
|
||||
@@ -91,12 +91,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<GameBoard
|
||||
opponentLabel={t.opponent}
|
||||
modeLabel={difficultyLabels[difficulty]}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
>
|
||||
<GameBoard modeLabel={difficultyLabels[difficulty]} onsubmit={play} onresign={giveUp}>
|
||||
{#snippet gameOver()}
|
||||
<!-- A bot always plays again, so there is nothing to negotiate: the
|
||||
button starts the next game rather than offering one. -->
|
||||
|
||||
+198
-65
@@ -18,6 +18,14 @@ function msg(kind, value) {
|
||||
return create(ServerMessageSchema, { payload: { case: kind, value } });
|
||||
}
|
||||
|
||||
/** The two players every in-game fixture below is scored for. */
|
||||
function table(mine = 0, theirs = 0) {
|
||||
return [
|
||||
{ playerId: 'p1', name: 'Minh', isMe: true, score: mine, connected: true },
|
||||
{ playerId: 'p2', name: 'Lan', isMe: false, score: theirs, connected: true }
|
||||
];
|
||||
}
|
||||
|
||||
function started(overrides = {}) {
|
||||
return msg('gameStarted', {
|
||||
openingWord: 'học sinh',
|
||||
@@ -26,6 +34,8 @@ function started(overrides = {}) {
|
||||
deadlineUnixMs: 1_700_000_020_000n,
|
||||
turnSeq: 1,
|
||||
turnLimitMs: 20_000,
|
||||
players: table(),
|
||||
turnPlayerId: 'p1',
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
@@ -82,14 +92,21 @@ describe('turnUpdate', () => {
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
played: { word: 'sinh viên', byMe: true, points: 12, syllables: 2, typed: 'sinh vien' },
|
||||
played: {
|
||||
word: 'sinh viên',
|
||||
byMe: true,
|
||||
points: 12,
|
||||
syllables: 2,
|
||||
typed: 'sinh vien',
|
||||
playerId: 'p1'
|
||||
},
|
||||
currentSyllable: 'viên',
|
||||
myTurn: false,
|
||||
deadlineUnixMs: 1_700_000_040_000n,
|
||||
turnSeq: 2,
|
||||
myScore: 12,
|
||||
opponentScore: 0,
|
||||
chainLength: 2
|
||||
chainLength: 2,
|
||||
players: table(12, 0),
|
||||
turnPlayerId: 'p2'
|
||||
})
|
||||
);
|
||||
|
||||
@@ -103,11 +120,39 @@ describe('turnUpdate', () => {
|
||||
});
|
||||
expect(store.state.currentSyllable).toBe('viên');
|
||||
expect(store.state.myTurn).toBe(false);
|
||||
expect(store.state.myScore).toBe(12);
|
||||
expect(store.myScore).toBe(12);
|
||||
expect(store.state.turnPlayerId).toBe('p2');
|
||||
expect(store.state.chainLength).toBe(2);
|
||||
expect(store.state.turnSeq).toBe(2);
|
||||
});
|
||||
|
||||
it('moves the turn on without a word when an elimination did it', () => {
|
||||
// The syllable and the chain survive the player who could not answer
|
||||
// them, so there is nothing to append — but the deadline and the player
|
||||
// to act are both new.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
currentSyllable: 'sinh',
|
||||
myTurn: true,
|
||||
deadlineUnixMs: 1_700_000_050_000n,
|
||||
turnSeq: 3,
|
||||
chainLength: 1,
|
||||
players: [
|
||||
{ playerId: 'p1', name: 'Minh', isMe: true, score: 0, connected: true },
|
||||
{ playerId: 'p2', name: 'Lan', score: 0, eliminated: true, connected: true }
|
||||
],
|
||||
turnPlayerId: 'p1'
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.chain).toHaveLength(1);
|
||||
expect(store.state.turnSeq).toBe(3);
|
||||
expect(store.state.deadlineMs).toBe(1_700_000_050_000);
|
||||
expect(store.state.gamePlayers[1].eliminated).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the standing rejection, because an accepted move answers it', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
@@ -156,8 +201,11 @@ describe('gameOver', () => {
|
||||
msg('gameOver', {
|
||||
iWon: true,
|
||||
reason: GameEndReason.NO_LEGAL_MOVE,
|
||||
myScore: 42,
|
||||
chainLength: 7
|
||||
chainLength: 7,
|
||||
standings: [
|
||||
{ playerId: 'p1', name: 'Minh', isMe: true, score: 42, connected: true, rank: 1 },
|
||||
{ playerId: 'p2', name: 'Lan', score: 8, eliminated: true, connected: true, rank: 2 }
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
@@ -167,35 +215,81 @@ describe('gameOver', () => {
|
||||
iWon: true,
|
||||
reason: GameEndReason.NO_LEGAL_MOVE,
|
||||
myScore: 42,
|
||||
chainLength: 7,
|
||||
suggestions: []
|
||||
chainLength: 7
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the words the losing side could have played', () => {
|
||||
it('keeps the final table in the order the server ranked it', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('gameOver', {
|
||||
iWon: false,
|
||||
reason: GameEndReason.TIMEOUT,
|
||||
myScore: 12,
|
||||
chainLength: 3,
|
||||
chainLength: 9,
|
||||
standings: [
|
||||
{ playerId: 'p3', name: 'Hà', score: 60, connected: true, rank: 1 },
|
||||
{ playerId: 'p1', name: 'Minh', isMe: true, score: 40, eliminated: true, rank: 2 },
|
||||
{ playerId: 'p2', name: 'Lan', score: 55, eliminated: true, rank: 3 }
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.standings.map((p) => p.playerId)).toEqual(['p3', 'p1', 'p2']);
|
||||
// Rank is finishing order, not score order: Lan outscored Minh and
|
||||
// still placed below him, because he outlasted her.
|
||||
expect(store.state.standings[2].score).toBeGreaterThan(store.state.standings[1].score);
|
||||
expect(store.state.result?.myScore).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playerEliminated', () => {
|
||||
it('keeps the words this player could have played', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('playerEliminated', {
|
||||
playerId: 'p1',
|
||||
name: 'Minh',
|
||||
isMe: true,
|
||||
reason: GameEndReason.TIMEOUT,
|
||||
suggestions: ['sinh viên', 'sinh sôi']
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.result?.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
expect(store.state.elimination?.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
expect(store.iAmOut).toBe(true);
|
||||
expect(store.state.myTurn).toBe(false);
|
||||
});
|
||||
|
||||
it('reads an absent list as a position that had nothing left', () => {
|
||||
// A dead end and an older server that never sends the field arrive the
|
||||
// same way, and both mean "no words to offer" rather than undefined.
|
||||
// A dead end arrives as an empty list, which means "no words to offer"
|
||||
// rather than undefined.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.NO_LEGAL_MOVE }));
|
||||
store.apply(
|
||||
msg('playerEliminated', { playerId: 'p1', isMe: true, reason: GameEndReason.NO_LEGAL_MOVE })
|
||||
);
|
||||
|
||||
expect(store.state.result?.suggestions).toEqual([]);
|
||||
expect(store.state.elimination?.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('records somebody else going out without claiming this player did', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('playerEliminated', {
|
||||
playerId: 'p2',
|
||||
name: 'Lan',
|
||||
isMe: false,
|
||||
reason: GameEndReason.RESIGNED,
|
||||
suggestions: []
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.lastOut?.name).toBe('Lan');
|
||||
expect(store.state.elimination).toBeNull();
|
||||
expect(store.iAmOut).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,50 +308,70 @@ describe('error', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('opponentLeft', () => {
|
||||
it('exposes the grace window so the UI can say how long to wait', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30_000 }));
|
||||
expect(store.state.opponentLeft).toEqual({ canReconnect: true, graceMs: 30_000 });
|
||||
});
|
||||
});
|
||||
/** One seat, with the fields a test does not care about defaulted. */
|
||||
function seat(fields = {}) {
|
||||
return {
|
||||
playerId: 'p1',
|
||||
name: 'Minh',
|
||||
isMe: false,
|
||||
isOwner: false,
|
||||
ready: false,
|
||||
connected: true,
|
||||
...fields
|
||||
};
|
||||
}
|
||||
|
||||
/** One lobby snapshot, with the fields a test does not care about defaulted. */
|
||||
/** One lobby snapshot. `players` is the whole room, the recipient included. */
|
||||
function lobby(fields = {}) {
|
||||
return msg('roomState', {
|
||||
roomCode: 'ABCD',
|
||||
iAmOwner: false,
|
||||
canStart: false,
|
||||
iAmReady: false,
|
||||
opponentPresent: false,
|
||||
opponentName: '',
|
||||
opponentReady: false,
|
||||
opponentConnected: false,
|
||||
maxPlayers: 4,
|
||||
minPlayers: 2,
|
||||
graceMs: 30_000,
|
||||
players: [seat({ isMe: true })],
|
||||
...fields
|
||||
});
|
||||
}
|
||||
|
||||
/** A two-player room, seen by the guest. */
|
||||
function pair(fields = {}) {
|
||||
return lobby({
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true })
|
||||
],
|
||||
...fields
|
||||
});
|
||||
}
|
||||
|
||||
describe('room messages', () => {
|
||||
it('keeps the room code and the sanitized opponent name', () => {
|
||||
it('keeps the room code and the sanitized names', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
|
||||
expect(store.state.roomCode).toBe('ABCD');
|
||||
expect(store.state.opponentName).toBe('Lan');
|
||||
expect(store.state.roomPlayers.map((p) => p.name)).toEqual(['Chủ', 'Lan']);
|
||||
expect(store.me?.playerId).toBe('p2');
|
||||
});
|
||||
|
||||
it('survives a reset, because identity outlives one game', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's', resumeToken: 't' }));
|
||||
store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
store.reset();
|
||||
|
||||
expect(store.state.nickname).toBe('Minh');
|
||||
expect(store.state.roomCode).toBe('ABCD');
|
||||
expect(store.state.opponentName).toBe('Lan');
|
||||
expect(store.state.opponentPresent).toBe(true);
|
||||
expect(store.state.roomPlayers).toHaveLength(2);
|
||||
expect(store.state.chain).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts the seats nobody is in yet', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(pair());
|
||||
expect(store.freeSeats).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pong', () => {
|
||||
@@ -379,36 +493,48 @@ describe('chat', () => {
|
||||
describe('the lobby', () => {
|
||||
it('opens the lobby when the room appears', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ roomCode: 'K7M2QP', iAmOwner: true }));
|
||||
store.apply(lobby({ roomCode: 'K7M2QP', players: [seat({ isMe: true, isOwner: true })] }));
|
||||
|
||||
expect(store.state.phase).toBe('lobby');
|
||||
expect(store.state.roomCode).toBe('K7M2QP');
|
||||
expect(store.state.isOwner).toBe(true);
|
||||
expect(store.isOwner).toBe(true);
|
||||
});
|
||||
|
||||
it('takes every field from the server rather than deriving any', () => {
|
||||
it("reads this player’s role and readiness off their own row", () => {
|
||||
// There is one encoding of each, so a client cannot end up believing a
|
||||
// role the list it is rendering disagrees with.
|
||||
const store = createGameStore();
|
||||
store.apply(
|
||||
lobby({
|
||||
iAmOwner: false,
|
||||
iAmReady: true,
|
||||
pair({
|
||||
canStart: true,
|
||||
opponentPresent: true,
|
||||
opponentName: 'Lan',
|
||||
opponentReady: false,
|
||||
opponentConnected: true
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true, ready: true })
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state).toMatchObject({
|
||||
isOwner: false,
|
||||
isReady: true,
|
||||
canStart: true,
|
||||
opponentPresent: true,
|
||||
opponentName: 'Lan',
|
||||
opponentReady: false,
|
||||
opponentConnected: true
|
||||
});
|
||||
expect(store.isOwner).toBe(false);
|
||||
expect(store.isReady).toBe(true);
|
||||
expect(store.state.canStart).toBe(true);
|
||||
expect(store.state.maxPlayers).toBe(4);
|
||||
expect(store.state.minPlayers).toBe(2);
|
||||
});
|
||||
|
||||
it('lists everybody whose reconnect window is running, and nobody else', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(
|
||||
lobby({
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true }),
|
||||
seat({ playerId: 'p3', name: 'Hà', connected: false })
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.awayPlayers.map((p) => p.name)).toEqual(['Hà']);
|
||||
expect(store.state.graceMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it('does not drop a live game back into the lobby', () => {
|
||||
@@ -416,7 +542,7 @@ describe('the lobby', () => {
|
||||
// Acting on it would replace the board with the lobby mid-turn.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
|
||||
expect(store.state.phase).toBe('playing');
|
||||
});
|
||||
@@ -425,25 +551,32 @@ describe('the lobby', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
|
||||
expect(store.state.phase).toBe('over');
|
||||
expect(store.state.result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stops waiting for an opponent who is connected again', () => {
|
||||
it('stops waiting for a player who is connected again', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30_000 }));
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
store.apply(
|
||||
pair({
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true, connected: false }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true })
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(store.awayPlayers).toHaveLength(1);
|
||||
|
||||
expect(store.state.opponentLeft).toBeNull();
|
||||
expect(store.state.opponentConnected).toBe(true);
|
||||
store.apply(pair());
|
||||
expect(store.awayPlayers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('forgets the room when this player is kicked out of it', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
store.apply(msg('error', { code: 'kicked', message: '' }));
|
||||
|
||||
expect(store.state.phase).toBe('idle');
|
||||
@@ -453,7 +586,7 @@ describe('the lobby', () => {
|
||||
|
||||
it('forgets a room that closed for sitting idle', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ iAmOwner: true }));
|
||||
store.apply(lobby());
|
||||
store.apply(msg('error', { code: 'room_idle_closed', message: '' }));
|
||||
|
||||
expect(store.state.phase).toBe('idle');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js';
|
||||
import { fromBinary } from '@bufbuild/protobuf';
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
@@ -48,7 +49,7 @@ describe('generated wire types', () => {
|
||||
const msg = decode('server_welcome');
|
||||
expect(msg.payload.case).toBe('welcome');
|
||||
expect(msg.payload.value.acceptedNickname).toBe('Người chơi ẩn danh');
|
||||
expect(msg.payload.value.protocolVersion).toBe(1);
|
||||
expect(msg.payload.value.protocolVersion).toBe(PROTOCOL_VERSION);
|
||||
});
|
||||
|
||||
// int64 is a bigint in this runtime. Reading it as a Number would silently
|
||||
@@ -71,9 +72,13 @@ describe('generated wire types', () => {
|
||||
expect(over.payload.case).toBe('gameOver');
|
||||
expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE);
|
||||
expect(over.payload.value.iWon).toBe(false);
|
||||
// The only repeated field in the contract, and the one the losing
|
||||
// The final table, in the order the server ranked it.
|
||||
expect(over.payload.value.standings.map((/** @type {any} */ p) => p.rank)).toEqual([1, 2, 3]);
|
||||
|
||||
const out = decode('server_player_eliminated');
|
||||
// The only repeated string in the contract, and the one the losing
|
||||
// player's screen is built from.
|
||||
expect(over.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
expect(out.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
|
||||
const chat = decode('server_chat_message');
|
||||
expect(chat.payload.case).toBe('chatMessage');
|
||||
|
||||
@@ -9,15 +9,27 @@ import { chainToText, historyFilename } from '../src/lib/history-export.js';
|
||||
* @param {Partial<import('../src/lib/stores/game.svelte.js').ChainEntry>} entry
|
||||
*/
|
||||
function link(entry) {
|
||||
return { word: '', typed: '', byMe: false, points: 0, syllables: 2, opening: false, ...entry };
|
||||
return {
|
||||
word: '',
|
||||
typed: '',
|
||||
byMe: false,
|
||||
playerId: '',
|
||||
points: 0,
|
||||
syllables: 2,
|
||||
opening: false,
|
||||
...entry
|
||||
};
|
||||
}
|
||||
|
||||
const chain = [
|
||||
link({ word: 'học sinh', opening: true, syllables: 0 }),
|
||||
link({ word: 'sinh viên', byMe: true, points: 2 }),
|
||||
link({ word: 'viên chức', points: 2 })
|
||||
link({ word: 'sinh viên', byMe: true, playerId: 'p1', points: 2 }),
|
||||
link({ word: 'viên chức', playerId: 'p2', points: 2 })
|
||||
];
|
||||
|
||||
/** Resolves a seat to the name that seat was playing under. */
|
||||
const nameOf = (/** @type {string} */ id) => (id === 'p2' ? 'Minh' : '');
|
||||
|
||||
const at = new Date(2026, 8, 7, 15, 25);
|
||||
|
||||
describe('chainToText', () => {
|
||||
@@ -39,12 +51,21 @@ describe('chainToText', () => {
|
||||
});
|
||||
|
||||
it('names who played each word and what it scored', () => {
|
||||
const text = chainToText({ chain, at, opponentLabel: 'Minh' });
|
||||
const text = chainToText({ chain, at, nameOf });
|
||||
|
||||
expect(text).toContain('2. sinh viên — Bạn +2');
|
||||
expect(text).toContain('3. viên chức — Minh +2');
|
||||
});
|
||||
|
||||
it('falls back to a generic label for a seat whose name is gone', () => {
|
||||
// A player who left takes their name with them, and the transcript is
|
||||
// written long after. The line still has to say it was not this
|
||||
// player's word.
|
||||
const text = chainToText({ chain, at });
|
||||
|
||||
expect(text).toContain('3. viên chức — Đối thủ +2');
|
||||
});
|
||||
|
||||
it('heads the file with the result when there is one', () => {
|
||||
const text = chainToText({
|
||||
chain,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { create, toBinary } from '@bufbuild/protobuf';
|
||||
import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js';
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
ServerMessageSchema
|
||||
@@ -171,7 +172,7 @@ describe('handshake', () => {
|
||||
|
||||
const [first] = await sentMessages(h.last());
|
||||
expect(first.payload.case).toBe('hello');
|
||||
expect(first.payload.value.protocolVersion).toBe(1);
|
||||
expect(first.payload.value.protocolVersion).toBe(PROTOCOL_VERSION);
|
||||
expect(first.payload.value.nickname).toBe('Minh');
|
||||
expect(first.payload.value.resumeToken).toBe('');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user