Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
142
docs/specs/rematch/design.md
Normal file
142
docs/specs/rematch/design.md
Normal 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.
|
||||||
77
docs/specs/rematch/requirements.md
Normal file
77
docs/specs/rematch/requirements.md
Normal 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).
|
||||||
18
docs/specs/rematch/tasks.md
Normal file
18
docs/specs/rematch/tasks.md
Normal 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.
|
||||||
Loading…
Reference in New Issue
Block a user