Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
141
docs/specs/game-clock/design.md
Normal file
141
docs/specs/game-clock/design.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Game clock: design
|
||||||
|
|
||||||
|
See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. Builds on `game-history`
|
||||||
|
(live game snapshots in Valkey, saved games in Postgres).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Reason |
|
||||||
|
|---|---|
|
||||||
|
| The server is the only authority on time; clients only display it | Players cannot gain time by changing their device clock or client. A move arriving too late is refused on the server (R2.3). |
|
||||||
|
| The clock is stored as "time left when the turn started" plus `turnStartedAt`, not as a ticking value | The state only changes on moves, so nothing is written per second; the Valkey snapshot that is already written on every move carries it. The time left at any moment is computed. |
|
||||||
|
| A scheduled service checks all live games every second, in addition to the check when a move arrives | It ends games where nobody does anything (R2.2) and is simple to reason about and to restore after a restart. Because late moves are refused, checking once a second never changes a result; it only decides how soon the end is announced. Per-game timers were considered: more precise, but every move, end and restore would have to keep them in sync. A few hundred live games are a trivial loop. |
|
||||||
|
| The end reason "timeout" is reused | It keeps its meaning: the player whose turn it was ran out of time. The database check constraint, results, texts and the recent games list need no change. Old saved timeouts came from the offline rule; the difference is not shown. |
|
||||||
|
| The offline rules from `game-history` (`TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`, `offlineSince`) are removed | The running clock ends any game where the player to move is away within 5 minutes (R6.1), so the old rules can no longer trigger. One mechanism instead of two. |
|
||||||
|
| 5 minutes, no increment, set by `GAME_CLOCK_MS` (default `300000`) | R1.1. The variable allows short clocks in tests and later tuning. The initial time travels with each game, so clients do not hard-code it and a running game keeps its time control if the setting changes. |
|
||||||
|
| Time while the backend is down is not charged (R5.1) | A deploy must not make anyone lose. On restore, the running clock restarts from the time left at the start of the turn; the player gets back what they spent on that turn before the restart. |
|
||||||
|
| Messages carry the server's time | Clients compute the offset between their clock and the server's and count down from it, so a wrong device clock does not matter (R3.3). |
|
||||||
|
| Animation and network delay are not compensated | A long move animates for a few seconds (300 ms per step) on the next player's time. This is accepted for now, like network delay; time added per move is the fix if it turns out to matter (out of scope). |
|
||||||
|
|
||||||
|
## Shared types (`packages/core`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// models/GameClock.ts
|
||||||
|
interface GameClock {
|
||||||
|
initialMs: number;
|
||||||
|
// Time left for each player. For the player whose turn it is: at turnStartedAt.
|
||||||
|
player1Ms: number;
|
||||||
|
player2Ms: number;
|
||||||
|
// Server time when the running clock started; undefined when no clock runs (the game has ended).
|
||||||
|
turnStartedAt?: number;
|
||||||
|
}
|
||||||
|
const CLOCK_LOW_TIME_MS = 30_000; // R3.2
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Game` (live game messages) gains `clock?: GameClock` and `serverTime?: number` (server time when the message was
|
||||||
|
built). Optional, so older servers and clients keep working.
|
||||||
|
- `GameEndMessage` gains `clock?: GameClock`, the stopped clocks (R3.5).
|
||||||
|
- `MoveRecord` gains `remainingMs?: number`, the mover's time left after the move (R4.1).
|
||||||
|
- `FinishedGameSummary` gains `clockInitialMs?: number`; undefined for games saved before this feature (R4.3).
|
||||||
|
|
||||||
|
Helpers in `packages/core/src/clock/`, used by the backend, the web app and the mobile app:
|
||||||
|
- `getClockTimes(clock, runningSeat, now)` → `{ player1Ms, player2Ms }`, never below zero.
|
||||||
|
- `ClockSync`: `update(game, receivedAt)` keeps the clock and the server offset (`serverTime - receivedAt`) from the
|
||||||
|
last message; `getTimes(now)` returns both players' time and the running seat. Framework-free so both apps can use it.
|
||||||
|
- `getClockAtMove(record, n)` → both players' time after move n of a saved game (n = 0: the initial time), from
|
||||||
|
`clockInitialMs` and the movers' `remainingMs`; undefined for games without clock data. On a timeout, the last
|
||||||
|
position shows the loser at zero.
|
||||||
|
- `formatClock(ms)` → `m:ss`, rounded up so `0:00` only shows when the time is gone.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Modules
|
||||||
|
```
|
||||||
|
src/game/GameClock.ts pure functions on GameClock
|
||||||
|
src/game/GameClockScheduler.ts the scheduled service (R2.2)
|
||||||
|
src/db/migrations/002_clock.ts clock_initial_ms column
|
||||||
|
```
|
||||||
|
|
||||||
|
`GameClock.ts`:
|
||||||
|
- `startClock(initialMs, now)`: both players at `initialMs`, `turnStartedAt = now`.
|
||||||
|
- `remainingMs(clock, seat, runningSeat, now)`: the stored time, minus `now - turnStartedAt` for the running seat.
|
||||||
|
- `chargeMove(clock, seat, now)`: deducts the turn's time from the mover and sets `turnStartedAt = now`, for a turn
|
||||||
|
change and an extra turn alike (R1.2, R1.3). Returns the mover's time left.
|
||||||
|
- `stopClock(clock, runningSeat, now)`: deducts the running seat's time (at least down to zero) and clears
|
||||||
|
`turnStartedAt`.
|
||||||
|
- `resumeClock(clock, now)`: sets `turnStartedAt = now` without deducting anything (R5.1).
|
||||||
|
|
||||||
|
### Game lifecycle (`GameManager`)
|
||||||
|
- `BackendGame` gains `clock: GameClock` and `moveRemainingMs: number[]`, aligned with `mancalaGame.history` like
|
||||||
|
`moveTimes`.
|
||||||
|
- **Start**: `startClock(config.gameClockMs, now)`.
|
||||||
|
- **Move**: before the engine is called, if the mover is the turn player and their time is gone, the move is not
|
||||||
|
played and the game ends with `timeout` (R2.3). After a move that was played (history grew), `chargeMove` and push
|
||||||
|
the result to `moveRemainingMs`.
|
||||||
|
- **End** (any reason): `stopClock`, then the final update and `GameEndMessage` carry the stopped clock.
|
||||||
|
- **`endGamesOutOfTime(now)`** (public): ends with `timeout` every live game whose running clock is at zero; the
|
||||||
|
winner is the opponent (R2.1). Each game is handled on its own, so an error in one is logged and does not stop the
|
||||||
|
others.
|
||||||
|
- **Messages**: `toClientGame` adds `clock` and `serverTime`. Reconnecting players already get `on_game_update`, so
|
||||||
|
R3.4 needs nothing more.
|
||||||
|
- **Removed**: `checkTimeouts`, the 10-second timer (`startTimers` / `stopTimers`), the `offlineSince` map and
|
||||||
|
`UserConnectionInfo.offlineSince`; `TURN_TIMEOUT_MS` and `INACTIVE_TIMEOUT_MS` in core.
|
||||||
|
|
||||||
|
### Scheduled service (`GameClockScheduler`)
|
||||||
|
```ts
|
||||||
|
class GameClockScheduler {
|
||||||
|
constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */, clock = Date.now)
|
||||||
|
start(): void // setInterval → gameManager.endGamesOutOfTime(clock())
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`src/index.ts` starts it after `restoreLiveGames()` and stops it on shutdown, replacing `gameManager.startTimers()`.
|
||||||
|
It only reads memory, so Postgres or Valkey being down does not affect it; ending a game saves it through
|
||||||
|
`GameRecorder` with retry as today.
|
||||||
|
|
||||||
|
### Restore (R5)
|
||||||
|
- The snapshot becomes `version: 2` with `clock` and `moveRemainingMs`. `isLiveGameSnapshot` accepts 1 and 2.
|
||||||
|
- `fromSnapshot`: version 2 → `resumeClock(snapshot.clock, now)`; version 1 → `startClock(config.gameClockMs, now)`
|
||||||
|
and empty `moveRemainingMs` entries (R5.2).
|
||||||
|
|
||||||
|
### Saving (R4.1)
|
||||||
|
- `toGameRecord` sets `clockInitialMs` and each move's `remainingMs` (from `moveRemainingMs`).
|
||||||
|
- Migration `002_clock`: `ALTER TABLE games ADD COLUMN clock_initial_ms integer;` (NULL for older games).
|
||||||
|
`remainingMs` lives in the existing `moves` jsonb. `PostgresGameHistoryRepository` writes and reads the column.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
`GAME_CLOCK_MS` (default `300000`) in `src/consts/config.ts`, passed into `GameManager`.
|
||||||
|
|
||||||
|
## Web app
|
||||||
|
|
||||||
|
- `hooks/useGameClock(game)`: a `ClockSync` updated on every game message (`on_game_start`, `on_game_update`,
|
||||||
|
`on_game_end`), re-rendering every 250 ms while a clock runs and not at all once the game has ended.
|
||||||
|
- `components/GameClockView.tsx`: one player's `m:ss`; highlighted with `playerTurnColor` while running; a warning
|
||||||
|
style below `CLOCK_LOW_TIME_MS`.
|
||||||
|
- `GamePage`: a clock beside each `UserStatus` in the board top toolbar, and in the mobile toolbar. The
|
||||||
|
"opponent is offline" countdown banner and the `OpponentOfflineCountdown` string are removed (R6.2).
|
||||||
|
- `ReplayView`: both clocks from `getClockAtMove(record, shownMove)`, hidden when `clockInitialMs` is missing (R4.2,
|
||||||
|
R4.3).
|
||||||
|
- Texts: `OpponentTimedOut` / `YouTimedOut` become "Opponent ran out of time" / "You ran out of time" in English; the
|
||||||
|
Turkish ones ("Rakibin süresi doldu", "Süren doldu") already fit.
|
||||||
|
|
||||||
|
## Mobile app
|
||||||
|
|
||||||
|
`GameScreen` shows the two clocks with `ClockSync` and `formatClock` from core, next to the players' names, with the
|
||||||
|
same running and low-time styles. Old app versions in the stores do not show the clock but are still bound by it;
|
||||||
|
the update should be released before or with the backend (see `TODO.md`).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- `packages/core`: `getClockTimes`, `ClockSync` (including a device clock that is off by minutes), `getClockAtMove`
|
||||||
|
(with and without clock data, timeout at the end), `formatClock`.
|
||||||
|
- Backend unit tests with the fake clock `GameManager` tests already use:
|
||||||
|
- a move deducts the turn's time; an extra turn keeps the clock running; the other clock does not change;
|
||||||
|
- a move after the time is gone is refused and ends the game with the opponent as winner;
|
||||||
|
- `endGamesOutOfTime` ends only games at zero, tells players and spectators, and saves the game;
|
||||||
|
- a completed game stops the clocks;
|
||||||
|
- restoring version 2 snapshots does not charge downtime; version 1 snapshots get a new clock;
|
||||||
|
- `GameClockScheduler` calls the sweep on its interval and stops (Jest fake timers).
|
||||||
|
- Postgres repository: `clock_initial_ms` and `remainingMs` survive saving and reading.
|
||||||
|
- End to end in Chrome with `GAME_CLOCK_MS=20000`: two guests play a few moves and the clocks agree on both pages;
|
||||||
|
one stops moving and loses on time with the right texts on both sides; a reload keeps the clocks; a backend
|
||||||
|
restart does not charge the time it was down; the replay shows the clocks.
|
||||||
71
docs/specs/game-clock/requirements.md
Normal file
71
docs/specs/game-clock/requirements.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Game clock: requirements
|
||||||
|
|
||||||
|
Status: proposed.
|
||||||
|
Scope: backend, web app and mobile app.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
Every game has a chess-style clock: each player has 5 minutes for all of their moves. A player whose time runs out
|
||||||
|
loses. The server keeps the time and ends such games on its own, whether or not anyone is connected.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Clock**: the time a player has left in a game.
|
||||||
|
- **Running clock**: the clock of the player whose turn it is. Only one clock runs at a time.
|
||||||
|
- **Turn**: from the moment a player is to move until the server receives their move.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### R1. Clock
|
||||||
|
- R1.1 Each player starts a game with 5 minutes. No time is added after a move.
|
||||||
|
- R1.2 Only the clock of the player whose turn it is runs. It starts when the game starts, for the player who moves
|
||||||
|
first. When a move passes the turn, the mover's clock stops and the opponent's starts.
|
||||||
|
- R1.3 When a move gives the mover an extra turn, the mover's clock keeps running.
|
||||||
|
- R1.4 The server measures the time: a turn lasts from when it started on the server to when the server receives the
|
||||||
|
move. Network delay and the time the client spends animating the opponent's move count as the player's time.
|
||||||
|
- R1.5 The clock runs whether or not the player is online.
|
||||||
|
|
||||||
|
### R2. Running out of time
|
||||||
|
- R2.1 When a player's clock reaches zero, the game ends: the opponent wins and the end reason is "timeout".
|
||||||
|
- R2.2 The server ends such games on its own within 1 second of the time running out, even if no client is
|
||||||
|
connected or sends anything.
|
||||||
|
- R2.3 A move that reaches the server after the mover's time has run out is not played; the game ends as in R2.1.
|
||||||
|
- R2.4 Players see "You won · Opponent ran out of time" or "You lost · You ran out of time"; spectators see who
|
||||||
|
won, as for other endings.
|
||||||
|
|
||||||
|
### R3. Showing the clocks
|
||||||
|
- R3.1 Players and spectators of a live game see both clocks next to the players' names as `m:ss`. The running
|
||||||
|
clock is highlighted.
|
||||||
|
- R3.2 When less than 30 seconds are left, the running clock is shown in a warning style.
|
||||||
|
- R3.3 The running clock counts down smoothly. It differs from the server's time by no more than about one second,
|
||||||
|
even if the device's clock is wrong.
|
||||||
|
- R3.4 After a reload or a reconnect, the clocks show the right times at once.
|
||||||
|
- R3.5 When the game ends, both clocks stop at the time they had; a player who ran out of time shows `0:00`.
|
||||||
|
- R3.6 The mobile app shows the clocks in the same way.
|
||||||
|
|
||||||
|
### R4. Saved games and replay
|
||||||
|
- R4.1 A saved game keeps the time each player started with and, for every move, the time the mover had left after
|
||||||
|
it.
|
||||||
|
- R4.2 The replay of a game shows both clocks as they were after the move being shown, and the time each player
|
||||||
|
started with before the first move.
|
||||||
|
- R4.3 Games saved before this feature have no clock data; their replay shows no clocks.
|
||||||
|
|
||||||
|
### R5. Restarts and deploys
|
||||||
|
- R5.1 Clocks survive a backend restart. Time while the backend is down does not count: after a restart, the
|
||||||
|
running clock goes on from where it was when the current turn started.
|
||||||
|
- R5.2 Games that are in progress when this feature is deployed get a new clock (5 minutes each) when the backend
|
||||||
|
restores them.
|
||||||
|
|
||||||
|
### R6. Rules the clock replaces
|
||||||
|
- R6.1 The clock replaces two rules from `game-history` (R3.3 and R3.4): losing after 5 minutes offline on your turn,
|
||||||
|
and a game without a winner after both players have been offline for 30 minutes. Because the running clock does not
|
||||||
|
stop for an offline player, it ends every such game within 5 minutes.
|
||||||
|
- R6.2 The "opponent is offline" countdown is replaced by the clocks. The online / offline status of the players
|
||||||
|
stays.
|
||||||
|
- R6.3 Saved games that ended by the old rules still show as before ("timeout", "inactive").
|
||||||
|
|
||||||
|
## Out of scope (TODO)
|
||||||
|
- Other time controls, time added per move, or letting players choose.
|
||||||
|
- Clocks in the home page's live game previews and in the ongoing-game banner.
|
||||||
|
- Making up for network delay or animation time.
|
||||||
|
- Pausing a game, or more time by agreement.
|
||||||
20
docs/specs/game-clock/tasks.md
Normal file
20
docs/specs/game-clock/tasks.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Game clock: tasks
|
||||||
|
|
||||||
|
Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed.
|
||||||
|
|
||||||
|
- [ ] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`,
|
||||||
|
`GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`,
|
||||||
|
`formatClock` with unit tests; English timeout texts.
|
||||||
|
- [ ] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on
|
||||||
|
`BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and
|
||||||
|
`serverTime` in client messages; unit tests.
|
||||||
|
- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove
|
||||||
|
`checkTimeouts`, the 10-second timer, `offlineSince`, `TURN_TIMEOUT_MS` and `INACTIVE_TIMEOUT_MS`; unit tests.
|
||||||
|
- [ ] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and
|
||||||
|
`clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests.
|
||||||
|
- [ ] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile
|
||||||
|
layouts; remove the offline countdown.
|
||||||
|
- [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.
|
||||||
|
- [ ] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle.
|
||||||
|
- [ ] 8. **End to end and docs**: Chrome tests from `design.md`; README, `AGENTS.md` and `TODO.md` (release the mobile
|
||||||
|
update before or with the backend; the out of scope items); mark `game-history` R3.3 and R3.4 as replaced.
|
||||||
Loading…
Reference in New Issue
Block a user