Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
137
docs/specs/quarkus-backend/design.md
Normal file
137
docs/specs/quarkus-backend/design.md
Normal file
@ -0,0 +1,137 @@
|
||||
# Quarkus backend: design
|
||||
|
||||
See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. The behaviour being ported is
|
||||
described in `keycloak-auth`, `game-history` and `game-clock`; this document covers how the Java backend is built.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Reason |
|
||||
|---|---|
|
||||
| Quarkus 3.40 (latest stable; 4.0 is in beta), Java 25, JVM mode | R1. Java 25 is the current LTS. Native images would need reflection config for the JSON models and buy little for one long-running process. |
|
||||
| A port of the rules to Java (`app.playmancala.engine`) instead of running `mancala.js` in the JVM (GraalJS) | The engine is about 500 lines with no dependencies; a port is simpler to run, debug and test than an embedded JS runtime. The clients keep using the TypeScript engine for display. |
|
||||
| Engine parity is tested against games recorded with the TypeScript engine | R2.2. A Node script plays seeded random games (including illegal moves) with `packages/mancala.js` and writes every move and the resulting game JSON to a fixture; a Java test replays them. Run the script again after changing the rules. |
|
||||
| All game state is owned by one thread, the **game loop** | The Node backend relies on running on one thread: `GameManager`, the stores, matchmaking, live previews and the WebSocket registry have no locks. A single-threaded scheduled executor keeps that model, so the game logic carries over line by line without races. WebSocket events, timers and API reads that touch live games run on it. A few hundred games are far from what one thread handles. |
|
||||
| Blocking I/O never runs on the game loop (except the restore at startup) | Postgres writes run on the recorder's own thread, Valkey writes on a store thread that keeps them in order (a game's `SET` must not overtake its `DEL`), and token checks on a worker. Results that change state are posted back to the loop. Snapshots are copied on the loop, so serialising them elsewhere is safe. |
|
||||
| Plain JDBC with the existing SQL and the existing migration runner (`schema_migrations`, advisory lock) | R5.1. Flyway or Liquibase would have to be baselined on databases the Node backend already migrated. Two migrations and a 40-line runner do not justify it. Hibernate adds nothing for two tables written once per game. |
|
||||
| `quarkus-redis-client` (blocking `RedisDataSource`) for Valkey | Valkey speaks the Redis protocol. Short connect and command timeouts make calls fail fast while Valkey is down, like `enableOfflineQueue: false` did. |
|
||||
| `quarkus-websockets-next` endpoint at `/`, a thin adapter over a framework-free `RtmtServer` | R3.1. The endpoint only forwards open, text and close events to the game loop; `RtmtServer` holds the protocol (auth handshake, timeouts, sockets per user, ping) and is unit-tested with fake sockets and a manual clock. |
|
||||
| jose4j for Keycloak tokens, not `quarkus-oidc` | Tokens arrive in a WebSocket message, not only in an HTTP header, so they are verified in code. jose4j (managed by the Quarkus BOM) fetches and caches the realm's JWKS like `jose` did. The checks are unchanged: RS256, issuer, `sub`, `exp`, `typ = Bearer`, `azp` in `KEYCLOAK_CLIENT_IDS`. |
|
||||
| Explicit JSON models (records) with `@JsonInclude(NON_NULL)` only on optional fields | R3.2, R5. TypeScript leaves out `undefined` but keeps `null`; e.g. `result: null` and `{ game: null }` must stay while `name` and `leftUserId` are left out. |
|
||||
| `DATABASE_URL` becomes `DATABASE_JDBC_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | R7.1. The JDBC driver does not take `postgres://user:pass@host/db`; parsing it ourselves is not worth it. Compose builds the new variables from the same `MANCALA_DB_PASSWORD`. |
|
||||
| The project is built in `apps/backend-quarkus` and replaces `apps/backend` in the last task | Every commit on the branch has a working backend, and the two can be compared side by side; the final move keeps the path that the Dockerfile, compose and CI refer to. |
|
||||
| `mvn quarkus:dev` debug port moved to 5006 | Quarkus dev mode listens for a debugger on 5005, the backend's port. |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
apps/backend/
|
||||
pom.xml
|
||||
Dockerfile multi-stage: Maven + Temurin 25 JDK, then Temurin 25 JRE
|
||||
scripts/record-engine-games.js writes the engine parity fixture from packages/mancala.js
|
||||
src/main/resources/
|
||||
application.properties
|
||||
db/migrations/001_games.sql, 002_clock.sql
|
||||
src/main/java/app/playmancala/
|
||||
engine/ Board, Pit, PitType, GameStep, MoveHistoryItem, MancalaGame, GameState, GameRule,
|
||||
CommonMancalaGame and the four rules (port of packages/mancala.js)
|
||||
model/ wire types: AuthUser, PlayerInfo, PlayerSummary, UserConnectionInfo, GameUsersConnectionInfo,
|
||||
GameClock, Seat, GameResult, EndReason, GameEndMessage, MoveRecord, GameRecord,
|
||||
FinishedGameSummary, LiveGameSummary, ClientGame, Channels; Json (the shared ObjectMapper)
|
||||
loop/ GameLoop (the game thread), Scheduler (what the logic needs from it), Cancellable
|
||||
auth/ AuthConfig, TokenVerifier, KeycloakTokenVerifier, InvalidTokenException
|
||||
rtmt/ Rtmt (interface used by the game logic), RtmtServer, RtmtSocket, RtmtEndpoint (Quarkus)
|
||||
game/ BackendGame, GameStore, MatchMaker, GameManager, GameClocks, GameClockScheduler,
|
||||
GameSerialization (LiveGameSnapshot), GameRecorder, LiveGames, PublicIds, CrashLog
|
||||
history/ GameHistoryRepository, PostgresGameHistoryRepository, Migrations, GameHistoryService
|
||||
live/ LiveGameStore, ValkeyLiveGameStore, Cache, ValkeyCache
|
||||
http/ GameResource, RootResource, BearerAuth, exception mapper
|
||||
Backend.java wiring and startup (a @Startup bean)
|
||||
```
|
||||
|
||||
The game logic (`engine`, `game`, `history` services, `rtmt.RtmtServer`) is plain Java built with constructors, as
|
||||
the TypeScript classes were, so unit tests build it with fakes. `Backend` creates it from CDI-managed infrastructure
|
||||
(datasource, Redis client, config) and exposes what the HTTP resources and the WebSocket endpoint need.
|
||||
|
||||
## Engine
|
||||
|
||||
A line-by-line port. Classes keep their TypeScript names and methods, so a change to one engine is easy to repeat in
|
||||
the other. The JSON of `MancalaGame` matches `JSON.stringify` of the TypeScript object, because clients rebuild it
|
||||
with `MancalaGame.createFromMancalaGame`:
|
||||
|
||||
```json
|
||||
{ "id", "board": { "pits": [{ "index", "stoneCount" }], "playerPitCount", "initialStoneCountInPits" },
|
||||
"player1Id", "player2Id", "turnPlayerId", "state", "gameRules": [{}, {}, {}, {}],
|
||||
"history": [{ "boardSnapshot", "gameSteps": [{ "index", "type", "data" }], "playerId", "moveIndex" }],
|
||||
"currentHistoryItem" }
|
||||
```
|
||||
|
||||
Errors keep their TypeScript messages (`IndexOutOfRange …`, `StoneNotFound …`, `Player cannot move reason : …`);
|
||||
they are `IllegalStateException`s.
|
||||
|
||||
## Threads
|
||||
|
||||
```
|
||||
WebSocket event loop ──open/text/close──▶ game loop (1 thread) ──▶ GameManager, LiveGames, MatchMaker, RtmtServer
|
||||
│ ▲
|
||||
token check (worker) ◀───────────┘ └── timers: clock check 1 s, ping 2 s, auth timeout, preview push
|
||||
HTTP worker ──call(read live state)──▶ game loop, waits for the result; reads saved games itself
|
||||
game loop ──snapshot──▶ store thread ──▶ Valkey (ordered)
|
||||
game loop ──record──▶ recorder thread ──▶ Postgres (retry every 30 s), then cache invalidation
|
||||
```
|
||||
|
||||
`Scheduler` is the part of the game loop the logic uses: `execute`, `schedule(delay)`, `scheduleAtFixedRate` and
|
||||
`now()`. Tests use `ManualScheduler`, which runs tasks at once and timers when the test advances its clock, in
|
||||
place of Jest's fake timers. Every task is wrapped so an exception is logged and never stops a repeating timer.
|
||||
|
||||
`GameRecorder` and the live game store take an `Executor`; tests pass a direct one, so saves finish before the test
|
||||
looks, in place of `flushPromises`.
|
||||
|
||||
## WebSocket
|
||||
|
||||
`RtmtEndpoint` (`@WebSocket(path = "/")`) handles `@OnOpen`, `@OnTextMessage` and `@OnClose` as `@NonBlocking` and
|
||||
only posts them to the game loop, keeping the order of a socket's events. It wraps the connection as an
|
||||
`RtmtSocket` (`id`, `query`, `send`, `close(code, reason)`, `isOpen`). Sends are asynchronous and never block the
|
||||
loop.
|
||||
|
||||
`RtmtServer` ports `rtmt_websocket.ts`: per socket an `alive` flag, the user id once authenticated and the auth
|
||||
timeout; per user the set of sockets and the `AuthUser`. Message JSON is parsed on the loop. A token is checked on a
|
||||
worker and the outcome handled on the loop, where a socket closed in the meantime is ignored.
|
||||
|
||||
## HTTP
|
||||
|
||||
Quarkus REST resources, blocking (run on worker threads). Reads of live games go through `GameLoop.call`, and the
|
||||
client game is serialised on the loop because it refers to the live `MancalaGame`. `BearerAuth` checks the
|
||||
`Authorization` header and answers 401. Any other exception becomes 503, as the Express error handler did. CORS and
|
||||
the access log are Quarkus HTTP settings.
|
||||
|
||||
## Startup
|
||||
|
||||
`Backend` (`@Startup`) builds everything, then queues on the game loop, before any WebSocket event can be queued:
|
||||
wait up to 5 s for Valkey, restore live games, start the clock check, the ping and the recorder's retries.
|
||||
Migrations run on their own thread and are retried every 30 s until Postgres is reachable. A restore that takes long
|
||||
(Postgres down: each check waits for the pool's 5 s timeout) delays socket events but loses none.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Quarkus property |
|
||||
|---|---|---|
|
||||
| `PORT` | `5005` (`6000` in the image) | `quarkus.http.port` |
|
||||
| `KEYCLOAK_ISSUER`, `KEYCLOAK_JWKS_URL`, `KEYCLOAK_CLIENT_IDS`, `LEGACY_USERKEY_AUTH` | as before | `mancala.auth.*` |
|
||||
| `GAME_CLOCK_MS` | `300000` | `mancala.game-clock-ms` |
|
||||
| `VALKEY_URL` | `redis://localhost:6379` | `quarkus.redis.hosts` |
|
||||
| `DATABASE_JDBC_URL` | `jdbc:postgresql://localhost:5433/mancala` | `quarkus.datasource.jdbc.url` |
|
||||
| `DATABASE_USERNAME`, `DATABASE_PASSWORD` | `mancala` | `quarkus.datasource.username`, `.password` |
|
||||
|
||||
Tests use Quarkus Dev Services with the compose images (`postgres:17.6`, `valkey/valkey:9.1.2`); the unit tests need
|
||||
no Docker.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit (no Docker): engine and parity fixture, game manager, live games, clock scheduler, token verifier (local key
|
||||
pair and JWKS), `RtmtServer` with fake sockets.
|
||||
- `@QuarkusTest` (Docker): Postgres repository and migrations, Valkey store and cache (including an unreachable
|
||||
Valkey), HTTP API, and the WebSocket endpoint with a real client and a test token verifier.
|
||||
- Manual, in Chrome with `playwright-core` against the local stack (R8.2): two players log in as guests and play a
|
||||
game to the end while a third user spectates and the home page shows the live preview; a player reloads mid-game;
|
||||
the backend is restarted mid-game and the game goes on with the clocks intact; the finished game appears in recent
|
||||
games and replays; a short `GAME_CLOCK_MS` ends a game on time.
|
||||
88
docs/specs/quarkus-backend/requirements.md
Normal file
88
docs/specs/quarkus-backend/requirements.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Quarkus backend: requirements
|
||||
|
||||
Status: implemented on branch `feature/quarkus-backend`.
|
||||
Scope: `apps/backend`, its Docker image, `apps/compose.yaml` and the docs.
|
||||
|
||||
## Goals
|
||||
|
||||
The backend is rewritten in Java with Quarkus on the latest Java LTS (Java 25). Players, the web app and the mobile
|
||||
app notice nothing: the WebSocket protocol, the HTTP API and the stored data stay as they are, and the new backend
|
||||
can replace the Node backend in a running deployment without ending live games.
|
||||
|
||||
## Terms
|
||||
|
||||
- **Node backend**: the Express + `ws` backend in `apps/backend` before this change.
|
||||
- **Java backend**: its replacement.
|
||||
- **Wire format**: the JSON sent over the WebSocket and the HTTP API, as the clients in `packages/core`,
|
||||
`apps/frontend` and `apps/mobile` read it.
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. Platform
|
||||
- R1.1 The backend is a Quarkus application (the latest stable 3.x release) compiled for and run on Java 25.
|
||||
- R1.2 It is built with Maven; dependencies have exact versions (the Quarkus platform BOM pins the extensions).
|
||||
- R1.3 It runs in JVM mode. A native image is out of scope.
|
||||
- R1.4 It no longer depends on `packages/core` or `packages/mancala.js`; the game rules it needs are part of the
|
||||
Java backend.
|
||||
|
||||
### R2. Game rules
|
||||
- R2.1 The Java backend plays games by exactly the rules of `packages/mancala.js`: the same moves are allowed, the
|
||||
same errors are raised, and every move produces the same board, the same history entry (board snapshot and game
|
||||
steps with their data) and the same next player.
|
||||
- R2.2 A test replays games recorded with the TypeScript engine and compares every result, including the JSON a
|
||||
client receives, so the two engines cannot drift apart unnoticed. The recording can be regenerated with a script.
|
||||
|
||||
### R3. WebSocket protocol
|
||||
- R3.1 Clients connect to the server's root path, as now (`ws://localhost:5005`, `wss://api.playmancala.app`).
|
||||
- R3.2 Every channel in `packages/core/src/rtmt/channel_names.ts` behaves as in the Node backend: the same messages
|
||||
are sent to the same users in the same order, with the same JSON. This covers authentication (`auth`, `on_auth`,
|
||||
`on_auth_error`, close code 4001, the 5 second timeout, token refresh), matchmaking, moves, leaving, spectating,
|
||||
connection changes, live game previews and the ping/pong keep-alive.
|
||||
- R3.3 Legacy clients that connect with `?userKey=<uuid>` work while `LEGACY_USERKEY_AUTH` is on.
|
||||
- R3.4 A user can be connected from several sockets; the user is offline when the last one closes.
|
||||
|
||||
### R4. HTTP API
|
||||
- R4.1 `GET /`, `GET /register/` (only with legacy auth), `GET /game/:id`, `GET /games/popular`,
|
||||
`GET /games/me/active` and `GET /games/me` answer with the same status codes and JSON as in the Node backend.
|
||||
- R4.2 CORS allows every origin, as now.
|
||||
- R4.3 Requests are written to `access.log` in the common log format, as now.
|
||||
|
||||
### R5. Data
|
||||
- R5.1 The Java backend uses the existing Postgres schema and the `schema_migrations` table: on a database the Node
|
||||
backend migrated, it applies nothing; on an empty one it creates the same tables.
|
||||
- R5.2 It restores live games that the Node backend saved in Valkey (snapshot versions 1 and 2), and writes snapshots
|
||||
the Node backend can restore, so a rollback also keeps live games.
|
||||
- R5.3 It saves finished games that the Node backend left pending in Valkey, and pending records it leaves can be
|
||||
saved by the Node backend.
|
||||
- R5.4 Saved games and cached lists have the same JSON as before; a game saved by either backend reads the same.
|
||||
|
||||
### R6. Behaviour when a dependency is down
|
||||
Unchanged from `game-history` and `game-clock`:
|
||||
- R6.1 Games are played while Postgres or Valkey is down. Finished games are kept and saved once Postgres is back;
|
||||
lists are served without the missing data.
|
||||
- R6.2 The backend starts while Postgres is down (migrations are retried) and while Valkey is down (live games are
|
||||
then not restored).
|
||||
- R6.3 Live games survive a restart; the time the backend is down is not charged to the clock.
|
||||
|
||||
### R7. Configuration and deployment
|
||||
- R7.1 The settings stay environment variables with the same names and defaults (`PORT`, `KEYCLOAK_*`,
|
||||
`LEGACY_USERKEY_AUTH`, `VALKEY_URL`, `GAME_CLOCK_MS`), except the database: `DATABASE_URL` is replaced by
|
||||
`DATABASE_JDBC_URL`, `DATABASE_USERNAME` and `DATABASE_PASSWORD`. `apps/.env` needs no change.
|
||||
- R7.2 The Docker image is still built from the repo root with `docker build -f apps/backend/Dockerfile .`, listens
|
||||
on port 6000 in the container and runs under `apps/compose.yaml` as before.
|
||||
- R7.3 `mvn quarkus:dev` runs the backend locally on port 5005 against the compose services, like `yarn dev` did.
|
||||
|
||||
### R8. Tests and docs
|
||||
- R8.1 The Node backend's tests are ported: game manager, live games, clock scheduler, token verifier, WebSocket
|
||||
server, HTTP API, Postgres repository and Valkey store and cache. Tests that need Postgres or Valkey run them in
|
||||
Docker, as now.
|
||||
- R8.2 The web app is played against the Java backend in Chrome: login, a full game between two users, a spectator,
|
||||
live previews, reconnecting, the game clock, restart with a live game, and the replay of the saved game.
|
||||
- R8.3 `README.md`, `apps/backend/README.md`, `AGENTS.md` and `TODO.md` describe the Java backend.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changes to the protocol, the API or the stored data, including fixing their known quirks (e.g. `on_game_crashed`
|
||||
sending `{}`).
|
||||
- Running more than one backend instance.
|
||||
- Publishing the Java engine as a library, or replacing the TypeScript engine in the clients.
|
||||
20
docs/specs/quarkus-backend/tasks.md
Normal file
20
docs/specs/quarkus-backend/tasks.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Quarkus backend: 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. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port
|
||||
with unit tests; `scripts/record-engine-games.js` and the parity test (R2).
|
||||
- [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`,
|
||||
`ManualScheduler`; `KeycloakTokenVerifier` with unit tests.
|
||||
- [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`,
|
||||
`GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported
|
||||
unit tests.
|
||||
- [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`,
|
||||
`ValkeyLiveGameStore`, `ValkeyCache`; integration tests with Dev Services.
|
||||
- [ ] 6. **WebSocket and HTTP**: `RtmtServer` with unit tests, `RtmtEndpoint`, the HTTP resources, `Backend`
|
||||
wiring and startup, configuration; `@QuarkusTest`s for the API and the WebSocket.
|
||||
- [ ] 7. **Replace the Node backend**: move the project to `apps/backend`, Dockerfile, `.dockerignore`, compose,
|
||||
remove the Node sources; build the image.
|
||||
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md` against the local stack; `README.md`,
|
||||
`apps/backend/README.md`, `AGENTS.md`, `TODO.md`.
|
||||
Loading…
Reference in New Issue
Block a user