mancala/docs/specs/rematch/design.md

144 lines
9.9 KiB
Markdown
Raw Permalink Normal View History

# 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): the first row of the game grid, above the players and the board, 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
"time" or "left" after the game's number. 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.