docs(rematch): add the spec

Rematch from the game over card, alternating first moves and a series
strip with the score of the games the same two players played in a row.
This commit is contained in:
Halit Aksoy 2026-10-17 01:58:58 +03:00
parent 4144805a95
commit c56d1f2c33
3 changed files with 237 additions and 0 deletions

View File

@ -0,0 +1,142 @@
# Rematch and series: design
See `requirements.md`; requirement ids (R1.1, …) are referenced below. Builds on `game-clock` (the end of a game),
`game-history` (saved games, replays), `game-bot` ("Play again") and `web-redesign` (the game over card, whose design
already has a rematch button).
## Decisions
| Decision | Reason |
|---|---|
| The server owns series and rematch requests; clients only ask, answer and show | Both players must see the same series, and the server already decides who moves first (R2). |
| A series is carried by its live game (`BackendGame.series`): its id and the results of the games before | No new store to keep in step: the series lives and is snapshotted with the game, so it survives restarts (R5.2), and is dropped with it. |
| Rematch requests are kept in memory only, keyed by the finished game's id | They last at most 10 minutes; losing them in a restart costs a click (R5.2). Valkey would add writes for little gain. |
| Series entries name seats (`player1`/`player2`), not user ids | The strip needs no ids, and nothing new can leak a legacy user's secret id (`PublicIds`). Seats stay the same through a series (R2.3). |
| A rematch starts with the usual `on_game_start`; the game page follows it to the new game | Clients already handle a game start; a new route per game keeps reloads, links and replays as they are. |
| "Play again" against the bot becomes a rematch the bot accepts at once | One flow for both kinds of games; bot series get the strip and alternating first moves (R4). |
| Every new game gets a series id, the first game too; rematches keep it | A game alone is a series of one, so grouping later needs no special case (R5.1). Games saved before have none. |
| No fixed series length | The user chose open series; "best of N" can be added on top (out of scope). |
## Backend
### Model
```java
// One finished game of a series, as the strip shows it.
record SeriesGame(String gameId, Seat firstSeat, GameResult result, EndReason endReason,
int player1Store, int player2Store) {}
// The series a live game belongs to: its id and the games played before this one, oldest first.
record Series(String id, List<SeriesGame> games) {
Series next(SeriesGame finished); // with `finished` appended
}
```
- `BackendGame` gains `series` (final; never null) and `firstSeat()`, the seat that moved first: the seat of the first
move's player, or of the player to move before any move. Derived rather than stored, so restored games and tests that
set the player to move need nothing more.
- `ClientGame` gains `series` (`@JsonInclude(NON_NULL)`, the games before this one) and `firstSeat`.
- `GameEndMessage` gains `series` (`NON_NULL`) with the ended game appended, and `rematch` (`NON_NULL`): `"available"`
when a rematch can be asked for, left out after a game a player left (R1.1).
- `LiveGameSnapshot` gains `series`, optional: snapshots without it restore as a new series of one game.
- `GameRecord` gains `seriesId` (`NON_NULL`); migration `004_series` adds `games.series_id uuid` (NULL for older
games) and an index on it.
### Who moves first (R2)
`createMancalaGame(userId1, userId2, bot, series, firstSeat)`: `firstSeat` null draws at random (R2.1), as now. A
rematch passes the seat opposite to the previous game's first seat (R2.2) and the same user order, so the seats stay
(R2.3).
### Rematch requests (`RematchOffers`, on the game loop)
`RematchOffers` is built next to `GameManager`, like `BotPlayer`, and hooks into it: `onGameEnded` (every ending
except a crash, with the end message) and `onPlayerSearching` (`new_game` and `new_bot_game`, before they are handled).
It listens to its own channels and to connection changes, and starts the next game with `createMancalaGame` and
`startGame`. When a game ends, it opens an entry unless a player left (R1.1). An entry keeps what the next game needs: the players in seat order, the bot settings' level, the
series with the ended game appended and the next first seat; plus who has asked, and two timers.
```
on_game_end ─► entry (open, 10 min) ── 10 min ──► gone (R1.7)
rematch from A ─► A asked; send on_rematch_request to B; 60 s timer
├─ rematch from B ─► start the next game; entry gone (R1.4)
├─ decline_rematch from B ─► on_rematch_ended(declined); entry gone (R1.6)
├─ decline_rematch from A ─► on_rematch_ended(withdrawn); entry stays open
├─ 60 s ─► on_rematch_ended(expired); entry stays open
├─ A or B offline ─► on_rematch_ended(offline); entry stays open
└─ A or B sends new_game / new_bot_game ─► on_rematch_ended(left); entry gone (R1.6)
```
- `on_rematch_ended` goes to both players: each client knows whether it asked and words the reason from that.
- A request to an opponent who is offline ends at once with `offline` (R1.5).
- `rematch` for a game without an entry (over 10 minutes, or the series ended) answers `on_rematch_ended(closed)`.
- Against the bot, `rematch` starts the next game at once (R4.1, R4.2); the bot takes a new strength as in
`onNewBotGame`.
- The players' user ids map to the entry (`entryByUser`), so `new_game` and going offline find it; a player is in at
most one entry, the one of their last game.
- Before starting the next game, the manager checks that neither player has a game in progress (they cannot, since
matchmaking removes the entry, but a restored or crashed state should not give a player two games).
### Channels (`Channels`, `channel_names.ts`)
| Channel | Direction | Message |
|---|---|---|
| `rematch` | client → server | `{ gameId }`: ask for a rematch of that ended game, or accept the opponent's request |
| `decline_rematch` | client → server | `{ gameId }`: say no, or withdraw one's own request |
| `on_rematch_request` | server → client | `{ gameId }`: the opponent asked; the client knows who that is |
| `on_rematch_ended` | server → client | `{ gameId, reason }`, reason one of `declined`, `withdrawn`, `expired`, `offline`, `left`, `closed` (10 minutes, or a new game) |
The next game reaches both players as `on_game_start`, like a game from the lobby.
## Core (`packages/core`)
- Channel names; types `Series`, `SeriesGame`, `RematchRequest`, `RematchEnded`; `Game.series`, `Game.firstSeat`,
`GameEndMessage.series` and `rematch`.
- `getSeriesScore(series, seat)`: wins of the seat and of the other seat, draws apart.
- Texts: `Rematch`, `RematchWaiting` ("Waiting for {{name}}…"), `RematchRequested` ("{{name}} wants a rematch"),
`RematchDeclined`, `RematchExpired`, `RematchOpponentGone`, `No`, `Series`, `SeriesScore`, `MovedFirst`,
`GamesNotShown` ("+{{count}}"), in `en.ts` and `tr.ts`.
## Web app
- **Following a rematch**: `GamePage` listens to `on_game_start` while a game of the user is over and navigates to
`/game/<new id>`. The route renders `GamePage` with `key={gameId}`, so the page starts afresh for the new game (its
load effect runs once per mount).
- **Game over card** (`GameOverCard`) gets a `rematch` state from the page:
- `available`: "Rematch" (primary), "New Game", "Home".
- `waiting`: "Waiting for Ahmet…" with "Cancel" (`decline_rematch`).
- `requested`: "Ahmet wants a rematch" with "Rematch" (primary) and "No". A request reopens a closed card (R1.3).
- `ended`: the reason as a short line, then "New Game" (and "Rematch" again unless the series is over, R1.6).
- Bot games: "Play again" sends `rematch` (R4.1); the lobby's `?bot=` stays for the home page and the lobby offer.
- The final score as two columns, the bottom player's first: the name (small, muted) above the store count
(large), the winner's count in their colour (R3.8).
- The series score line once the series has more than one game (R3.6).
- **Series strip** (`SeriesStrip`, R3): above the board in the game grid, shown from the second game of a series. The
score, then one box per game: the winner's colour (`--accent` for the bottom player, the opponent's colour for the
top one, neutral for a draw), "30–18" from the bottom player's side, a small dot for the player who moved first, a
small "time" or "left" tag. The current game's box is outlined and says "now". Finished boxes are links to
`/game/<id>` with `target="_blank"` (R3.4). At most 8 boxes; earlier games collapse into a "+N" box (R3.5). Below
600 px the strip scrolls sideways inside itself, never the page.
- Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons.
## Mobile app
Unchanged (R6). New fields in `on_game_start`, `on_game_update` and `on_game_end` are ignored by the mobile app; it
has no listeners for the new channels.
## Testing
- Backend unit tests (fakes, `ManualScheduler`): both ask → a game with the same seats, the other first seat and the
series appended; the third game alternates again; no rematch after a player left; decline, withdraw, 60 s expiry,
offline, `new_game` and `new_bot_game` each end the request with the right reason and to the right player; asking
again after withdraw and expiry; the 10 minute limit; the bot accepts at once at the same level; a player in a new
game cannot be pulled into a rematch.
- Serialisation: `series` and `firstSeat` round-trip through snapshots; old snapshots without them restore; the JSON of
`ClientGame` and `GameEndMessage` without a series is unchanged.
- Storage (`@QuarkusTest`): `series_id` saved and read; migration on a database with games from before.
- Core: `getSeriesScore`, text keys in both languages.
- Web: build and the temporary type check.
- End to end in Chrome, two browsers: a game ends, A asks, B sees the request and accepts, both land on the new game
with the other player moving first and the strip showing game 1; three games in a row alternate; B declines and A
is told; A asks and B does not answer within 60 s; a player who left gets no rematch; "Play again" against the bot
continues the series; a strip box opens the replay in a new tab; the strip at phone width.

View File

@ -0,0 +1,77 @@
# Rematch and series: requirements
Status: in progress, on branch `feature/rematch` (based on `develop`).
Scope: backend, `packages/core`, web app. The mobile app comes later (see R6).
## Goals
Two players who enjoyed a game can play again at once, without going back to the lobby, and see how their games
went: one box per game and the score of the series. Who moves first alternates, so neither player keeps the
advantage of the first move.
## Terms
- **Rematch**: a new game between the same two players, started from the game over card when both ask for it.
- **Series**: a game and the rematches that follow it, one after another. A series has no fixed length; it ends when
a rematch is not played.
- **Series score**: games won by each player in the series; draws count for neither.
- **Rematch request**: one player's "Rematch" waiting for the other's answer.
## Requirements
### R1. Asking for a rematch
- R1.1 When a game between two people ends, the game over card offers "Rematch" (Turkish: "Rövanş") to both
players, unless a player left the game: then the series is over and the card offers "New Game" as now.
- R1.2 The player who asks sees that they are waiting for the opponent and can withdraw the request.
- R1.3 The opponent sees the request on their game over card ("Ahmet wants a rematch") with "Rematch" and "No",
also when they had closed the card.
- R1.4 When both have asked, the new game starts for both at once, and their game pages show it.
- R1.5 A request ends without a game when: the opponent says no, the asking player withdraws, either player starts
looking for another game or starts a bot game, either player goes offline, or the opponent does not answer within
60 seconds. The asking player is told why ("Ahmet declined", "Ahmet is no longer here").
- R1.6 "No", or either player starting another game or a search, ends the series: the card offers only "New Game".
After a withdrawn or unanswered request, either player can ask again.
- R1.7 A rematch is possible for 10 minutes after the game ended; after that the card only offers "New Game".
### R2. Who moves first
- R2.1 The first game of a series starts with a player chosen at random, as now.
- R2.2 In every rematch, the player who did not move first in the previous game of the series moves first.
- R2.3 The players keep their sides of the board in a rematch.
### R3. Series score
- R3.1 From the second game of a series on, the game page shows the series above the board: the series score
("You 2 – 1 Ahmet") and one box per game, in order.
- R3.2 A finished game's box shows who won (in that player's colour, a draw neutral), the stones in both stores, a
mark for who moved first, and whether it ended on time or because someone left.
- R3.3 The box of the game being played is marked as the current game.
- R3.4 A finished game's box opens its replay in a new tab, so the live game is not left.
- R3.5 With more games than fit, the strip shows the latest games and how many earlier ones are not shown; the
score always counts all games.
- R3.6 The game over card shows the series score once the series has more than one game.
- R3.7 Spectators see the series strip too, with the players' names instead of "You".
- R3.8 The game over card shows the final score as two columns, each player's name above their stones, so names
and numbers do not run together ("Guest 6894 18 – 30 Bot · Hard" was hard to read).
### R4. Games against the bot
- R4.1 "Play again" after a bot game is a rematch: it starts at once, at the same level (the bot takes a new strength
as in any new game), and continues the series, with the series strip and alternating first moves.
- R4.2 The bot never declines a rematch.
### R5. Saving and restarts
- R5.1 Every saved game records which series it belongs to, so that the history can group series later.
- R5.2 A live game keeps its series across a backend restart (the strip shows the same games). Rematch requests
waiting at the time of the restart are dropped; the players can ask again.
### R6. Mobile app
- R6.1 The mobile app gets rematch and the series strip with its new design (`TODO.md`, Web redesign). Until then it
is unchanged: its players never see a rematch request, so a request from a web player to a mobile player ends
after 60 seconds (R1.5).
### R7. Languages
- R7.1 All texts exist in English and Turkish.
## Out of scope (TODO)
- Series of a fixed length ("best of 3") and declaring a series winner.
- Grouping series in the game history and on the home page.
- Taking spectators along to the next game of a series.
- Rematch and the series strip in the mobile app (R6).

View File

@ -0,0 +1,18 @@
# Rematch and series: tasks
Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed.
- [x] 1. **Spec**: requirements, design and tasks.
- [ ] 2. **Series in live games**: `Series`, `SeriesGame`, `BackendGame.series` and `firstSeat()`, the first seat in
`createMancalaGame`; `series` and `firstSeat` in `ClientGame` and `GameEndMessage`, `series` in snapshots (with
the restore of old snapshots); unit and serialisation tests.
- [ ] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on
`new_game`, `new_bot_game` and going offline, the bot accepting at once; unit tests.
- [ ] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests.
- [ ] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests.
- [ ] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch
states, "Play again" as a rematch, the score in two columns.
- [ ] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators.
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (the specs list, rematch notes),
`TODO.md` (the out of scope items; remove "A rematch button" from Web redesign), the web redesign design's note
on the rematch button.