From c56d1f2c336bde80f86a9754b0f9b56c655e4964 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Sat, 17 Oct 2026 01:58:58 +0300 Subject: [PATCH] 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. --- docs/specs/rematch/design.md | 142 +++++++++++++++++++++++++++++ docs/specs/rematch/requirements.md | 77 ++++++++++++++++ docs/specs/rematch/tasks.md | 18 ++++ 3 files changed, 237 insertions(+) create mode 100644 docs/specs/rematch/design.md create mode 100644 docs/specs/rematch/requirements.md create mode 100644 docs/specs/rematch/tasks.md diff --git a/docs/specs/rematch/design.md b/docs/specs/rematch/design.md new file mode 100644 index 0000000..af79178 --- /dev/null +++ b/docs/specs/rematch/design.md @@ -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 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/`. 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/` 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. diff --git a/docs/specs/rematch/requirements.md b/docs/specs/rematch/requirements.md new file mode 100644 index 0000000..66dec09 --- /dev/null +++ b/docs/specs/rematch/requirements.md @@ -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). diff --git a/docs/specs/rematch/tasks.md b/docs/specs/rematch/tasks.md new file mode 100644 index 0000000..6c7a2f9 --- /dev/null +++ b/docs/specs/rematch/tasks.md @@ -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.