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:
tiennm99 committed 2026-09-08 10:56:14 +07:00
1 parent 0b507c3537
commit 4e2e9e1433
54 files changed
+3692 -1129

No files matched your search

@@ -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.