mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-14 08:18:34 +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
@@ -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.
|
||||
Reference in new issue
Block a user