From the second game of a series, a strip shows the series score and a box per game in the winner's colour, with the stores, who moved first and how it ended; a finished game opens its replay in a new tab. The game over card shows the series score too. Spectators see names.
144 lines
9.9 KiB
Markdown
144 lines
9.9 KiB
Markdown
# 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.
|