Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
3 changed files with 245 additions and 0 deletions
Showing only changes of commit ec833b4ec5 - Show all commits

View 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.

View 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.

View 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`.