Merge branch 'feature/quarkus-backend' into develop
This commit is contained in:
commit
f6506801d8
@ -1,6 +1,7 @@
|
||||
# Images for apps/backend and apps/frontend are built with the repo root as context.
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/target
|
||||
**/.parcel-cache
|
||||
.git
|
||||
apps/mobile
|
||||
|
||||
47
AGENTS.md
47
AGENTS.md
@ -5,13 +5,13 @@ saved time, add it here.
|
||||
|
||||
## Project
|
||||
|
||||
Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app and a Node backend.
|
||||
Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app and a Java (Quarkus) backend.
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `packages/mancala.js` | Game engine; rules are plug-ins in `src/common/game_rules` |
|
||||
| `packages/mancala.js` | Game engine used by the apps; rules are plug-ins in `src/common/game_rules`. The backend has a Java port |
|
||||
| `packages/core` | Code shared by the apps: models, WebSocket client (`RTMTWS`), channel names, history helpers, themes, `PitAnimator`, translations (en, tr) |
|
||||
| `apps/backend` | Express + `ws`: matchmaking, games, auth, saved games, HTTP API |
|
||||
| `apps/backend` | Java 25 + Quarkus: matchmaking, games, auth, saved games, HTTP API, the Java port of the engine |
|
||||
| `apps/frontend` | React web app, built with Parcel |
|
||||
| `apps/mobile` | React Native app |
|
||||
| `apps/keycloak` | Keycloak image with the realm, the login theme and a Java extension for guest login |
|
||||
@ -22,15 +22,15 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app
|
||||
| `TODO.md` | Open work, known bugs and deployment steps; read it before starting |
|
||||
|
||||
Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay),
|
||||
`game-clock` (5 minutes per player, ending games on time) and `web-redesign` (the web app's look: themes, board,
|
||||
pages).
|
||||
`game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board,
|
||||
pages) and `quarkus-backend` (the backend's move from Node to Java and Quarkus).
|
||||
|
||||
## Running locally
|
||||
|
||||
```sh
|
||||
yarn build:packages # repo root; builds mancala.js and core
|
||||
cd apps && docker compose up -d --build postgres valkey keycloak # needs apps/.env, see README.md
|
||||
cd apps/backend && yarn dev # http://localhost:5005
|
||||
cd apps/backend && ./mvnw quarkus:dev # http://localhost:5005, needs a JDK 25
|
||||
cd apps/frontend && yarn dev # http://localhost:1234 (must be 1234, Keycloak only allows that)
|
||||
```
|
||||
|
||||
@ -42,9 +42,9 @@ unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`.
|
||||
|
||||
| What | Command | Notes |
|
||||
|---|---|---|
|
||||
| Engine | `cd packages/mancala.js && npx jest` | |
|
||||
| Engine | `cd packages/mancala.js && npx jest` | After changing the rules, change the Java port too and record the parity games again (`apps/backend/README.md`) |
|
||||
| Core | `cd packages/core && npx jest` | |
|
||||
| Backend | `cd apps/backend && npx tsc --noEmit -p . && npx jest` | Needs Docker: Postgres and Valkey run in testcontainers |
|
||||
| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services |
|
||||
| Keycloak extension | `cd apps/keycloak/extension && mvn test` | Needs Docker; starts Keycloak with the real realm file |
|
||||
| Web app | `cd apps/frontend && yarn build` | There is no `tsconfig.json`, so nothing type-checks it; use a temporary one when needed (with `"types": ["styled-jsx", "react", "react-dom"]`). 1 type error (`react-dom/client`) is known |
|
||||
| Mobile | `cd apps/mobile && npx tsc --noEmit -p .` and `npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output /tmp/x.bundle` | 11 type errors are known and older than recent work |
|
||||
@ -66,25 +66,38 @@ There is no automated end-to-end suite. Changes to the game flow were checked by
|
||||
## Things that are easy to get wrong
|
||||
|
||||
**Packages and builds**
|
||||
- The apps use `packages/core` and `packages/mancala.js` through `link:` dependencies, i.e. their built `dist`.
|
||||
After changing a package, run `yarn build` in it (or `yarn dev` to rebuild on change), or the apps use stale code.
|
||||
Running dev servers do not notice a rebuilt package: restart the backend, and restart Parcel after deleting
|
||||
`apps/frontend/.parcel-cache`.
|
||||
- The web and mobile apps use `packages/core` and `packages/mancala.js` through `link:` dependencies, i.e. their built
|
||||
`dist`. After changing a package, run `yarn build` in it (or `yarn dev` to rebuild on change), or the apps use stale
|
||||
code.
|
||||
Running dev servers do not notice a rebuilt package: restart Parcel after deleting `apps/frontend/.parcel-cache`.
|
||||
The backend does not use the packages.
|
||||
- Backend and frontend Docker images are built from the repo root: `docker build -f apps/backend/Dockerfile .`.
|
||||
- The mobile app uses `RTMTWS` from core (its own `src/rtmt` copy is unused), so changing the core WebSocket client
|
||||
changes mobile too. Metro needs `watchFolders` and `nodeModulesPaths` (already in `metro.config.js`).
|
||||
|
||||
**Backend**
|
||||
- The game rules exist twice: `packages/mancala.js` for the clients and `app.playmancala.engine` in the backend.
|
||||
Change both; `EngineParityTest` replays games recorded with the TypeScript engine and fails if they differ.
|
||||
- All live state (games, matchmaking, sockets, previews) belongs to the game loop thread (`GameLoop`). Touch it only
|
||||
in tasks running there; never block the loop on Postgres, Valkey or HTTP. Other threads use `GameLoop.call`.
|
||||
- The JSON must stay what the Node backend sent and stored: TypeScript leaves out `undefined` but keeps `null`, so only
|
||||
optional fields get `@JsonInclude(NON_NULL)` (e.g. `result: null` stays). Valkey snapshots and keys, the tables and
|
||||
`schema_migrations` are shared with the Node backend's format; tests check data the Node backend wrote.
|
||||
- User ids: Keycloak users are `kc:<sub>`; legacy mobile users are their `userKey`, which is a secret. Anything
|
||||
saved or listed publicly goes through `publicUserId()`; clients get `toClientGame()`, never spectator ids.
|
||||
saved or listed publicly goes through `PublicIds.publicUserId`; clients get `toClientGame()`, never spectator ids.
|
||||
- Live games: memory is the source of truth, Valkey is a write-through copy restored on startup; the backend
|
||||
assumes a single instance. Finished games are written to Postgres once, with retry when it is down.
|
||||
- Postgres or Valkey being down must not stop games; reads fall back, writes are retried.
|
||||
- The game clock is stored as each player's time at the start of the turn plus `turnStartedAt`; compute the time
|
||||
left with `getClockTimes`, never store a ticking value. `GAME_CLOCK_MS=40000` makes games short for manual tests.
|
||||
- `RTMT.listenOnClientConnectionChange` adds a listener (several are allowed).
|
||||
- Jest 27 lacks Node's web globals that testcontainers needs; `tests/support/NodeEnvironment.js` copies them in.
|
||||
- A Valkey client pointed at a dead port keeps Jest alive for `disconnectTimeout`; tests pass a short one.
|
||||
left with `GameClocks.getClockTimes`, never store a ticking value. `GAME_CLOCK_MS=40000` makes games short for
|
||||
manual tests.
|
||||
- `Rtmt.listenOnClientConnectionChange` adds a listener (several are allowed).
|
||||
- Unit tests build the logic with fakes (`FakeRtmt`, in-memory stores, `ManualScheduler` instead of timers and
|
||||
direct executors); only storage, HTTP and WebSocket tests are `@QuarkusTest`s. `FakeTokenVerifier` replaces
|
||||
Keycloak in all `@QuarkusTest`s.
|
||||
- Quarkus dev mode's debugger is moved to port 5006 (`pom.xml`), because 5005 is the backend's port.
|
||||
- The Maven wrapper checks the `.zip` it downloads; without `unzip` it fetches a `.tar.gz` and fails the check (the
|
||||
Dockerfile installs `unzip`).
|
||||
|
||||
**Web app**
|
||||
- styled-jsx only scopes JSX written directly in a component's render; elements built in helper functions or
|
||||
|
||||
12
README.md
12
README.md
@ -4,7 +4,8 @@ Mancala is an open source application for playing mancala game online.
|
||||
|
||||
## Development
|
||||
|
||||
The apps use the local `packages/mancala.js` and `packages/core` through `link:` dependencies, so build those first:
|
||||
The web and mobile apps use the local `packages/mancala.js` and `packages/core` through `link:` dependencies, so build
|
||||
those first:
|
||||
|
||||
```sh
|
||||
yarn build:packages # from the repo root
|
||||
@ -26,8 +27,13 @@ EOF
|
||||
docker compose up -d --build postgres valkey keycloak
|
||||
```
|
||||
|
||||
Postgres is on `localhost:5433` and Valkey on `localhost:6379`; the backend's defaults point there. Then run
|
||||
`yarn dev` in `apps/backend` (port 5005) and in `apps/frontend` (port 1234).
|
||||
Postgres is on `localhost:5433` and Valkey on `localhost:6379`; the backend's defaults point there. Then run the
|
||||
backend (Java 25 and Quarkus, see `apps/backend/README.md`) and the web app:
|
||||
|
||||
```sh
|
||||
cd apps/backend && ./mvnw quarkus:dev # port 5005
|
||||
cd apps/frontend && yarn dev # port 1234
|
||||
```
|
||||
|
||||
Docker images for the backend and frontend are built from the repo root:
|
||||
|
||||
|
||||
14
TODO.md
14
TODO.md
@ -69,6 +69,10 @@ See `docs/specs/web-redesign`.
|
||||
- [ ] Deploy Keycloak (`apps/.env` with `KEYCLOAK_DB_PASSWORD`, `KEYCLOAK_ADMIN_PASSWORD`,
|
||||
`KEYCLOAK_HOSTNAME=https://auth.playmancala.app`) and reload nginx with the new `auth.playmancala.app` block
|
||||
- [ ] Deploy the backend before the web app; the new web app needs token auth on the server
|
||||
- [ ] The backend is now Java (Quarkus, see `docs/specs/quarkus-backend`); the compose file passes the database as
|
||||
`DATABASE_JDBC_URL`/`DATABASE_USERNAME`/`DATABASE_PASSWORD` built from the same `MANCALA_DB_PASSWORD`, so
|
||||
`apps/.env` needs no change. It reads the Node backend's database and live games, so it can replace it (and be
|
||||
rolled back) without ending games
|
||||
- [ ] Release the mobile update with the game clock before or with the backend: older app versions do not show the
|
||||
clock and do not handle `on_game_end`, so a game they lose on time does not end for them
|
||||
- [ ] Back up the Postgres volume (Keycloak and saved games), e.g. a daily `pg_dumpall`
|
||||
@ -83,7 +87,8 @@ See `docs/specs/web-redesign`.
|
||||
`~/.gradle/gradle.properties` or environment variables, and consider resetting the upload key
|
||||
- [ ] Add `apps/mobile/mancala-upload-key.jks` to `.gitignore` (only `android/app/mancala-upload-key.jks` is ignored)
|
||||
- [ ] Validate moves before running them instead of treating every invalid move as a crash: each one writes a
|
||||
file to `crashes/` and is broadcast to the players (`GameManager.onGameError`)
|
||||
file to `crashes/` and is broadcast to the players (`GameManager.onGameError`, kept as it was in the Node
|
||||
backend)
|
||||
|
||||
## Game rules
|
||||
|
||||
@ -95,7 +100,8 @@ See `docs/specs/web-redesign`.
|
||||
## Bugs
|
||||
|
||||
- [ ] `PitAnimator.dispose` removes a newly bound function, so the theme listener is never removed
|
||||
- [ ] `onGameError` broadcasts an `Error`, which serializes to `{}`
|
||||
- [ ] `onGameError` broadcasts `{}` instead of the error (the Java backend sends `{}` on purpose, as the Node backend
|
||||
did)
|
||||
|
||||
## Maintenance
|
||||
|
||||
@ -103,6 +109,8 @@ See `docs/specs/web-redesign`.
|
||||
- [ ] Mixed lockfiles: `packages/*` and `apps/mobile` have both `yarn.lock` and `package-lock.json`
|
||||
- [ ] Align TypeScript and Jest versions across packages
|
||||
- [ ] Tests for `MatchMaker`; `PitAnimator` is only tested for the first frame of a move
|
||||
- [ ] CI that runs the tests
|
||||
- [ ] CI that runs the tests (the backend's need a JDK 25 and Docker)
|
||||
- [ ] The rules exist twice, in `packages/mancala.js` and in the backend's Java port; a parity test keeps them alike,
|
||||
but every rule change has to be made in both
|
||||
- [ ] Pre-existing type errors: 4 in the frontend, 11 in mobile; the frontend has no `tsconfig.json`, so nothing
|
||||
type-checks it
|
||||
|
||||
43
apps/backend/.gitignore
vendored
43
apps/backend/.gitignore
vendored
@ -1,40 +1,9 @@
|
||||
lib-cov
|
||||
*.seed
|
||||
*.log
|
||||
*.csv
|
||||
*.dat
|
||||
*.out
|
||||
*.pid
|
||||
*.gz
|
||||
*.swp
|
||||
|
||||
pids
|
||||
logs
|
||||
results
|
||||
tmp
|
||||
|
||||
# Build
|
||||
public/css/main.css
|
||||
|
||||
# Coverage reports
|
||||
coverage
|
||||
|
||||
# API keys and secrets
|
||||
.env
|
||||
|
||||
# Dependency directory
|
||||
node_modules
|
||||
bower_components
|
||||
|
||||
# Editors
|
||||
target/
|
||||
.idea
|
||||
*.iml
|
||||
.vscode
|
||||
.factorypath
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Ignore built ts files
|
||||
dist/**/*
|
||||
|
||||
crashes
|
||||
# Written at runtime
|
||||
access*.log
|
||||
crashes/
|
||||
|
||||
4
apps/backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
4
apps/backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||
distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce
|
||||
25
apps/backend/.vscode/launch.json
vendored
25
apps/backend/.vscode/launch.json
vendored
@ -1,25 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"program": "${workspaceFolder}/src/index.ts",
|
||||
"preLaunchTask": "tsc: build - tsconfig.json",
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Jest Current File",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||
"args": ["${relativeFile}"],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"windows": {
|
||||
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,26 +1,29 @@
|
||||
# Build from the repo root: docker build -f apps/backend/Dockerfile .
|
||||
FROM node:lts-buster-slim
|
||||
FROM eclipse-temurin:25.0.4_7-jdk-noble AS build
|
||||
|
||||
# The Maven wrapper checks the .zip it downloads; without unzip it would fetch a .tar.gz that fails the check.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Dependencies first, so they are cached until the pom changes
|
||||
COPY apps/backend/.mvn .mvn
|
||||
COPY apps/backend/mvnw apps/backend/pom.xml ./
|
||||
RUN ./mvnw -B -q quarkus:go-offline
|
||||
|
||||
COPY apps/backend/src src
|
||||
RUN ./mvnw -B -q package -DskipTests
|
||||
|
||||
FROM eclipse-temurin:25.0.4_7-jre-noble
|
||||
|
||||
ENV PORT=6000
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Local packages the backend links to
|
||||
COPY package.json package.json
|
||||
COPY packages packages
|
||||
RUN yarn build:packages
|
||||
|
||||
WORKDIR /usr/src/app/apps/backend
|
||||
|
||||
COPY apps/backend/package.json package.json
|
||||
COPY apps/backend/yarn.lock yarn.lock
|
||||
|
||||
RUN yarn
|
||||
|
||||
COPY apps/backend .
|
||||
|
||||
RUN yarn build
|
||||
# The app writes access.log and crashes/ to its working directory.
|
||||
RUN useradd --system --create-home mancala && install -d -o mancala /home/mancala/app
|
||||
WORKDIR /home/mancala/app
|
||||
COPY --from=build --chown=mancala /build/target/quarkus-app/ ./
|
||||
USER mancala
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD [ "yarn", "start"]
|
||||
CMD ["java", "-jar", "quarkus-run.jar"]
|
||||
|
||||
@ -1,32 +1,50 @@
|
||||
# Mancala Backend
|
||||
|
||||
This is the backend code for the Mancala project, written in Express.js.
|
||||
The game server: matchmaking, live games over a WebSocket, saved games and a small HTTP API. Java 25 and Quarkus 3.40,
|
||||
built with Maven. The design and the reasons for it are in `docs/specs/quarkus-backend`.
|
||||
|
||||
## Getting Started
|
||||
## Getting started
|
||||
|
||||
### Install Packages
|
||||
You need a JDK 25 (e.g. Temurin); Maven comes with the wrapper. Postgres, Valkey and Keycloak run with Docker Compose,
|
||||
see the README in the repo root.
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```sh
|
||||
./mvnw quarkus:dev # http://localhost:5005; a debugger can attach on 5006
|
||||
./mvnw test # needs Docker: Postgres and Valkey run with Quarkus Dev Services
|
||||
./mvnw package # target/quarkus-app/quarkus-run.jar
|
||||
java -jar target/quarkus-app/quarkus-run.jar
|
||||
```
|
||||
|
||||
### Start in Development Mode
|
||||
The Docker image is built from the repo root: `docker build -f apps/backend/Dockerfile .`. It listens on port 6000.
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
## Code
|
||||
|
||||
| Package | What |
|
||||
|---|---|
|
||||
| `engine` | The game rules, a port of `packages/mancala.js` |
|
||||
| `model` | The JSON sent to clients and stored, and the shared `ObjectMapper` |
|
||||
| `loop` | `GameLoop`, the single thread that owns all live game state |
|
||||
| `auth` | Keycloak token verification |
|
||||
| `rtmt` | The WebSocket protocol (`RtmtServer`) and the Quarkus endpoint |
|
||||
| `game` | Games, matchmaking, the clock, live previews, snapshots and saving finished games |
|
||||
| `history` | Saved games in Postgres, migrations, the lists for the API |
|
||||
| `live` | Valkey: live game snapshots, games waiting to be saved, the cache |
|
||||
| `http` | The HTTP API |
|
||||
| `Backend` | Wiring and startup |
|
||||
|
||||
All live state (games, matchmaking, sockets) is only touched on the game loop, like on the Node backend's event loop.
|
||||
Blocking work never runs there: Postgres writes run on the recorder's thread, Valkey writes on their own ordered
|
||||
thread, token checks on virtual threads, HTTP requests on Quarkus' workers (which read live games through
|
||||
`GameLoop.call`).
|
||||
|
||||
The clients use the TypeScript engine, the server the Java one; they must play exactly alike.
|
||||
`EngineParityTest` replays games recorded with the TypeScript engine. After changing the rules in both, record them
|
||||
again:
|
||||
|
||||
```sh
|
||||
yarn --cwd ../../packages/mancala.js build && node scripts/record-engine-games.js
|
||||
```
|
||||
|
||||
### Start in Release Mode
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
### Development Dependencies
|
||||
|
||||
This package links the local packages `@mancala/core` and `mancala.js`. Build them first with `yarn build:packages` in the repo root.
|
||||
|
||||
## Authentication
|
||||
|
||||
Clients log in with Keycloak (see `apps/keycloak`), either as a guest or with an account, and send the access token
|
||||
@ -47,6 +65,7 @@ User ids of Keycloak users are the token subject prefixed with `kc:`.
|
||||
| `KEYCLOAK_JWKS_URL` | issuer + `/protocol/openid-connect/certs` | Where to fetch signing keys, if the backend reaches Keycloak at a different address |
|
||||
| `KEYCLOAK_CLIENT_IDS` | `mancala-web` | Comma-separated clients whose tokens are accepted |
|
||||
| `LEGACY_USERKEY_AUTH` | `true` | Also accept old clients that connect with `?userKey=<uuid>` and the `/register` endpoint. Turn off once no released client uses it, because a userKey is not a secret the server can check |
|
||||
| `PORT` | `5005` (`6000` in the image) | HTTP and WebSocket port |
|
||||
|
||||
## Saved games and live games
|
||||
|
||||
@ -55,11 +74,13 @@ backend starts, so a restart or deploy does not end them. The design is in `docs
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://mancala:mancala@localhost:5433/mancala` | Saved games; migrations run on startup |
|
||||
| `DATABASE_JDBC_URL` | `jdbc:postgresql://localhost:5433/mancala` | Saved games; migrations run on startup |
|
||||
| `DATABASE_USERNAME`, `DATABASE_PASSWORD` | `mancala`, `mancala` | |
|
||||
| `VALKEY_URL` | `redis://localhost:6379` | Live game state and short-lived caches |
|
||||
|
||||
The backend keeps running when either is down: games are still played, finished games are saved once Postgres is
|
||||
back, and lists are served without the missing data.
|
||||
back, and lists are served without the missing data. The tables, the `schema_migrations` table and the Valkey keys
|
||||
are the Node backend's, so the two can replace each other in either direction.
|
||||
|
||||
## Game clock
|
||||
|
||||
@ -79,4 +100,5 @@ backend is down is not charged. The design is in `docs/specs/game-clock`.
|
||||
| `GET /games/me/active` | Bearer token | `{ game }` or `{ game: null }` |
|
||||
| `GET /games/me?limit=3` | Bearer token | `{ games }`, newest first |
|
||||
|
||||
Tests use Docker (testcontainers) for Postgres and Valkey.
|
||||
Requests are logged to `access.log`, and a move that fails writes the game to `crashes/`, both in the working
|
||||
directory.
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
rm -rf node_modules
|
||||
yarn
|
||||
yarn build
|
||||
yarn start
|
||||
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
transform: {'^.+\\.ts?$': 'ts-jest'},
|
||||
testEnvironment: '<rootDir>/tests/support/NodeEnvironment.js',
|
||||
testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
};
|
||||
295
apps/backend/mvnw
vendored
Executable file
295
apps/backend/mvnw
vendored
Executable file
@ -0,0 +1,295 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
189
apps/backend/mvnw.cmd
vendored
Normal file
189
apps/backend/mvnw.cmd
vendored
Normal file
@ -0,0 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "@mancala/backend",
|
||||
"version": "0.4.7",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"coverage": "jest --coverage"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mancala/core": "link:../../packages/core",
|
||||
"@types/cors": "^2.8.10",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/ws": "^7.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"iovalkey": "0.4.0",
|
||||
"jose": "5.10.0",
|
||||
"mancala.js": "link:../../packages/mancala.js",
|
||||
"morgan": "^1.10.0",
|
||||
"pg": "8.23.1",
|
||||
"uuid": "^8.3.2",
|
||||
"ws": "^7.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.12",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/node": "^15.12.4",
|
||||
"@types/pg": "8.23.1",
|
||||
"jest": "^27.0.5",
|
||||
"nodemon": "^2.0.7",
|
||||
"testcontainers": "12.2.0",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
}
|
||||
113
apps/backend/pom.xml
Normal file
113
apps/backend/pom.xml
Normal file
@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>app.playmancala</groupId>
|
||||
<artifactId>mancala-backend</artifactId>
|
||||
<version>0.5.0</version>
|
||||
<packaging>quarkus</packaging>
|
||||
|
||||
<properties>
|
||||
<compiler-plugin.version>3.15.0</compiler-plugin.version>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||
<quarkus.platform.version>3.40.1</quarkus.platform.version>
|
||||
<surefire-plugin.version>3.5.6</surefire-plugin.version>
|
||||
<!-- Quarkus dev mode listens for a debugger on 5005 by default, which is the backend's own port. -->
|
||||
<debug>5006</debug>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${quarkus.platform.group-id}</groupId>
|
||||
<artifactId>${quarkus.platform.artifact-id}</artifactId>
|
||||
<version>${quarkus.platform.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-arc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-jackson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-websockets-next</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-agroal</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-jdbc-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-redis-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bitbucket.b_c</groupId>
|
||||
<artifactId>jose4j</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-junit</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.rest-assured</groupId>
|
||||
<artifactId>rest-assured</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.awaitility</groupId>
|
||||
<artifactId>awaitility</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>${quarkus.platform.group-id}</groupId>
|
||||
<artifactId>quarkus-maven-plugin</artifactId>
|
||||
<version>${quarkus.platform.version}</version>
|
||||
<extensions>true</extensions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${compiler-plugin.version}</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>${surefire-plugin.version}</version>
|
||||
<configuration>
|
||||
<argLine>@{argLine}</argLine>
|
||||
<systemPropertyVariables>
|
||||
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||
<maven.home>${maven.home}</maven.home>
|
||||
<mancala.crash-dir>${project.build.directory}/crashes</mancala.crash-dir>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
61
apps/backend/scripts/record-engine-games.js
Normal file
61
apps/backend/scripts/record-engine-games.js
Normal file
@ -0,0 +1,61 @@
|
||||
// Plays seeded random games with the TypeScript engine (packages/mancala.js) and writes every move and its outcome,
|
||||
// so EngineParityTest can check that the Java engine plays exactly the same. Run it again after changing the rules:
|
||||
//
|
||||
// yarn --cwd ../../packages/mancala.js build && node scripts/record-engine-games.js
|
||||
//
|
||||
// Moves include illegal ones (the wrong player, an empty pit, a store, out of range) and moves after the end.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { CommonMancalaGame } = require("../../../packages/mancala.js");
|
||||
|
||||
const GAME_COUNT = 15;
|
||||
const MAX_MOVES = 400;
|
||||
const OUTPUT = path.join(__dirname, "../src/test/resources/engine/recorded-games.json");
|
||||
|
||||
// mulberry32
|
||||
function random(seed) {
|
||||
return () => {
|
||||
seed |= 0;
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function recordGame(number) {
|
||||
const next = random(number + 1);
|
||||
const pick = (items) => items[Math.floor(next() * items.length)];
|
||||
const game = new CommonMancalaGame(`game-${number}`, `kc:player1-${number}`, `kc:player2-${number}`);
|
||||
game.turnPlayerId = next() < 0.5 ? game.player1Id : game.player2Id;
|
||||
const recorded = {
|
||||
id: game.id, player1Id: game.player1Id, player2Id: game.player2Id, turnPlayerId: game.turnPlayerId, moves: [],
|
||||
};
|
||||
let movesAfterEnd = 0;
|
||||
for (let i = 0; i < MAX_MOVES && movesAfterEnd < 2; i++) {
|
||||
const roll = next();
|
||||
const opponent = game.turnPlayerId === game.player1Id ? game.player2Id : game.player1Id;
|
||||
const playerId = roll < 0.93 ? game.turnPlayerId : opponent;
|
||||
const pitsWithStones = [0, 1, 2, 3, 4, 5].filter((pit) =>
|
||||
game.board.pits[game.getBoardIndexByPlayerId(playerId, pit)].stoneCount > 0);
|
||||
const pitIndex = roll < 0.85 && pitsWithStones.length > 0 ? pick(pitsWithStones)
|
||||
: roll < 0.97 ? Math.floor(next() * 6) : pick([-1, 6, 7, 13, 14]);
|
||||
const move = { playerId, pitIndex };
|
||||
try {
|
||||
game.moveByPlayerPit(playerId, pitIndex);
|
||||
} catch (err) {
|
||||
move.error = err.message;
|
||||
}
|
||||
Object.assign(move, { board: game.board.getStoneArray(), turnPlayerId: game.turnPlayerId, state: game.state });
|
||||
recorded.moves.push(move);
|
||||
if (game.state === "ended") movesAfterEnd++;
|
||||
}
|
||||
recorded.wonPlayerId = game.getWonPlayerId() ?? null;
|
||||
recorded.game = JSON.parse(JSON.stringify(game));
|
||||
return recorded;
|
||||
}
|
||||
|
||||
const games = Array.from({ length: GAME_COUNT }, (_, number) => recordGame(number));
|
||||
fs.writeFileSync(OUTPUT, JSON.stringify({ games }) + "\n");
|
||||
const moves = games.reduce((sum, game) => sum + game.moves.length, 0);
|
||||
console.log(`Recorded ${games.length} games, ${moves} moves, to ${path.relative(process.cwd(), OUTPUT)}`);
|
||||
@ -1,16 +0,0 @@
|
||||
export interface AuthConfig {
|
||||
keycloakIssuer: string;
|
||||
keycloakJwksUrl?: string;
|
||||
keycloakClientIds: string[];
|
||||
// Accept old clients that identify themselves with ?userKey= instead of a token.
|
||||
legacyUserKeyAuth: boolean;
|
||||
}
|
||||
|
||||
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
|
||||
return {
|
||||
keycloakIssuer: env.KEYCLOAK_ISSUER || "http://localhost:8080/realms/mancala",
|
||||
keycloakJwksUrl: env.KEYCLOAK_JWKS_URL || undefined,
|
||||
keycloakClientIds: (env.KEYCLOAK_CLIENT_IDS || "mancala-web").split(",").map((id) => id.trim()).filter(Boolean),
|
||||
legacyUserKeyAuth: (env.LEGACY_USERKEY_AUTH ?? "true") === "true",
|
||||
};
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export type { AuthUser } from "@mancala/core";
|
||||
@ -1,72 +0,0 @@
|
||||
import { createRemoteJWKSet, errors, JWTPayload, JWTVerifyGetKey, jwtVerify } from "jose";
|
||||
import { AuthUser } from "./AuthUser";
|
||||
import { InvalidTokenError, TokenVerifier } from "./TokenVerifier";
|
||||
|
||||
export const KEYCLOAK_USER_ID_PREFIX = "kc:";
|
||||
export const GUEST_ROLE = "guest";
|
||||
|
||||
export interface KeycloakTokenVerifierOptions {
|
||||
// Realm issuer, e.g. https://auth.playmancala.app/realms/mancala
|
||||
issuer: string;
|
||||
// Clients whose access tokens are accepted (checked against the azp claim).
|
||||
allowedClientIds: string[];
|
||||
// Where to fetch signing keys from. Defaults to the issuer's certs endpoint.
|
||||
jwksUrl?: string;
|
||||
// Overrides key lookup, for tests.
|
||||
keys?: JWTVerifyGetKey;
|
||||
}
|
||||
|
||||
type KeycloakAccessToken = JWTPayload & {
|
||||
typ?: string;
|
||||
azp?: string;
|
||||
preferred_username?: string;
|
||||
realm_access?: { roles?: string[] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies Keycloak access tokens offline against the realm's published signing keys.
|
||||
* Player ids are the token subject prefixed with "kc:", so they can never collide with
|
||||
* ids that legacy userKey clients choose for themselves.
|
||||
*/
|
||||
export class KeycloakTokenVerifier implements TokenVerifier {
|
||||
private readonly issuer: string;
|
||||
private readonly allowedClientIds: string[];
|
||||
private readonly keys: JWTVerifyGetKey;
|
||||
|
||||
constructor(options: KeycloakTokenVerifierOptions) {
|
||||
this.issuer = options.issuer;
|
||||
this.allowedClientIds = options.allowedClientIds;
|
||||
this.keys = options.keys ??
|
||||
createRemoteJWKSet(new URL(options.jwksUrl ?? `${options.issuer}/protocol/openid-connect/certs`));
|
||||
}
|
||||
|
||||
async verify(token: string): Promise<AuthUser> {
|
||||
let payload: KeycloakAccessToken;
|
||||
try {
|
||||
({ payload } = await jwtVerify(token, this.keys, {
|
||||
issuer: this.issuer,
|
||||
algorithms: ["RS256"],
|
||||
requiredClaims: ["sub", "exp"],
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err instanceof errors.JOSEError) {
|
||||
throw new InvalidTokenError(err.code);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens.
|
||||
if (payload.typ !== "Bearer") {
|
||||
throw new InvalidTokenError("not an access token");
|
||||
}
|
||||
if (!payload.azp || !this.allowedClientIds.includes(payload.azp)) {
|
||||
throw new InvalidTokenError("token issued to an unknown client");
|
||||
}
|
||||
|
||||
return {
|
||||
id: KEYCLOAK_USER_ID_PREFIX + payload.sub,
|
||||
name: payload.preferred_username,
|
||||
isGuest: payload.realm_access?.roles?.includes(GUEST_ROLE) ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import { AuthUser } from "./AuthUser";
|
||||
|
||||
export class InvalidTokenError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InvalidTokenError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenVerifier {
|
||||
// Resolves to the token's user, or rejects with InvalidTokenError.
|
||||
verify(token: string): Promise<AuthUser>;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export * from "./AuthUser";
|
||||
export * from "./AuthConfig";
|
||||
export * from "./TokenVerifier";
|
||||
export * from "./KeycloakTokenVerifier";
|
||||
40
apps/backend/src/cache/Cache.ts
vendored
40
apps/backend/src/cache/Cache.ts
vendored
@ -1,40 +0,0 @@
|
||||
import { Valkey } from "iovalkey";
|
||||
|
||||
const PREFIX = "mancala:cache:";
|
||||
|
||||
// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value.
|
||||
export interface Cache {
|
||||
getJson<T>(key: string): Promise<T | undefined>;
|
||||
setJson(key: string, value: unknown, ttlSeconds: number): Promise<void>;
|
||||
delete(...keys: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
export class ValkeyCache implements Cache {
|
||||
constructor(private readonly client: Valkey) { }
|
||||
|
||||
async getJson<T>(key: string): Promise<T | undefined> {
|
||||
try {
|
||||
const value = await this.client.get(PREFIX + key);
|
||||
return value === null ? undefined : JSON.parse(value) as T;
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async setJson(key: string, value: unknown, ttlSeconds: number): Promise<void> {
|
||||
try {
|
||||
await this.client.set(PREFIX + key, JSON.stringify(value), "EX", ttlSeconds);
|
||||
} catch (err) {
|
||||
// A missing cache entry only costs a database query.
|
||||
}
|
||||
}
|
||||
|
||||
async delete(...keys: string[]): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
try {
|
||||
await this.client.del(...keys.map((key) => PREFIX + key));
|
||||
} catch (err) {
|
||||
// Entries expire on their own.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
export const RTMT_WS_PING_INTERVAL = 2000;
|
||||
|
||||
// Time each player has for a whole game (docs/specs/game-clock).
|
||||
export const GAME_CLOCK_MS = positiveInteger(process.env.GAME_CLOCK_MS) ?? 5 * 60 * 1000;
|
||||
|
||||
function positiveInteger(value: string | undefined): number | undefined {
|
||||
const number = Number(value);
|
||||
return value && Number.isInteger(number) && number > 0 ? number : undefined;
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import { sql as games001 } from "./001_games";
|
||||
import { sql as clock002 } from "./002_clock";
|
||||
|
||||
export interface Migration {
|
||||
version: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
// Applied in this order; never change a migration that has been released, add a new one instead.
|
||||
export const migrations: Migration[] = [
|
||||
{ version: "001_games", sql: games001 },
|
||||
{ version: "002_clock", sql: clock002 },
|
||||
];
|
||||
@ -1,41 +0,0 @@
|
||||
import { Pool } from "pg";
|
||||
import { Migration, migrations as allMigrations } from "./migrations";
|
||||
|
||||
const MIGRATION_LOCK_ID = 727274;
|
||||
|
||||
export function createPool(databaseUrl: string): Pool {
|
||||
const pool = new Pool({ connectionString: databaseUrl, max: 5, connectionTimeoutMillis: 5000 });
|
||||
// Without a listener, an error on an idle connection (e.g. Postgres restarting) would crash the process.
|
||||
pool.on("error", (err) => console.error("(DB) Idle connection error", err.message));
|
||||
return pool;
|
||||
}
|
||||
|
||||
// Applies the migrations that have not run yet, each in its own transaction.
|
||||
export async function runMigrations(pool: Pool, migrations: Migration[] = allMigrations): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_ID]);
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
const { rows } = await client.query<{ version: string }>("SELECT version FROM schema_migrations");
|
||||
const applied = new Set(rows.map((row) => row.version));
|
||||
for (const migration of migrations) {
|
||||
if (applied.has(migration.version)) continue;
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(migration.sql);
|
||||
await client.query("INSERT INTO schema_migrations (version) VALUES ($1)", [migration.version]);
|
||||
await client.query("COMMIT");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
console.info(`(DB) Applied migration ${migration.version}`);
|
||||
}
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_ID]).catch(() => undefined);
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
import { GameClock, getClockTimes, Seat } from "@mancala/core";
|
||||
|
||||
// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn
|
||||
// plus when that turn started, so it only changes on moves; the time left at any moment is computed.
|
||||
|
||||
export function startClock(initialMs: number, now: number): GameClock {
|
||||
return { initialMs, player1Ms: initialMs, player2Ms: initialMs, turnStartedAt: now };
|
||||
}
|
||||
|
||||
export function getRemainingMs(clock: GameClock, seat: Seat, runningSeat: Seat | undefined, now: number): number {
|
||||
const times = getClockTimes(clock, runningSeat, now);
|
||||
return seat === "player1" ? times.player1Ms : times.player2Ms;
|
||||
}
|
||||
|
||||
// Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn.
|
||||
export function chargeMove(clock: GameClock, moverSeat: Seat, now: number): { clock: GameClock, remainingMs: number } {
|
||||
const remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now);
|
||||
const charged = { ...clock, turnStartedAt: now };
|
||||
if (moverSeat === "player1") charged.player1Ms = remainingMs;
|
||||
else charged.player2Ms = remainingMs;
|
||||
return { clock: charged, remainingMs };
|
||||
}
|
||||
|
||||
// Deducts the running seat's time, if any, and stops the clock.
|
||||
export function stopClock(clock: GameClock, runningSeat: Seat | undefined, now: number): GameClock {
|
||||
const times = getClockTimes(clock, runningSeat, now);
|
||||
return { initialMs: clock.initialMs, ...times };
|
||||
}
|
||||
|
||||
// Starts the running clock again from the time it had at the start of the turn, e.g. after a restart.
|
||||
export function resumeClock(clock: GameClock, now: number): GameClock {
|
||||
return { ...clock, turnStartedAt: now };
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import { GameManager } from "./GameManager";
|
||||
|
||||
export const CLOCK_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected
|
||||
// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs
|
||||
// only decides how soon the end is announced, never who wins.
|
||||
export class GameClockScheduler {
|
||||
private timer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly gameManager: Pick<GameManager, "endGamesOutOfTime">,
|
||||
private readonly intervalMs = CLOCK_CHECK_INTERVAL_MS,
|
||||
) { }
|
||||
|
||||
start() {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => this.gameManager.endGamesOutOfTime(), this.intervalMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
import fs from "fs";
|
||||
import { Game } from "@mancala/core";
|
||||
import { BackendGame } from "../models/Game";
|
||||
const crashFolder = "./crashes";
|
||||
export class GameCrashManager {
|
||||
constructor() {}
|
||||
|
||||
static createCrashFolderIfNotExist(): void {
|
||||
if (!fs.existsSync(crashFolder)) {
|
||||
fs.mkdirSync(crashFolder);
|
||||
}
|
||||
}
|
||||
|
||||
static getTime(): string {
|
||||
let date_ob = new Date();
|
||||
let date = ("0" + date_ob.getDate()).slice(-2);
|
||||
let month = ("0" + (date_ob.getMonth() + 1)).slice(-2);
|
||||
let year = date_ob.getFullYear();
|
||||
let hours = date_ob.getHours();
|
||||
let minutes = date_ob.getMinutes();
|
||||
let seconds = date_ob.getSeconds();
|
||||
let miliSeconds = date_ob.getMilliseconds();
|
||||
return `${year}-${month}-${date}-${hours}:${minutes}:${seconds}:${miliSeconds}`;
|
||||
}
|
||||
|
||||
public static logGameCrash(error: Error, game: BackendGame): string {
|
||||
this.createCrashFolderIfNotExist();
|
||||
const crash = {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
game,
|
||||
};
|
||||
const crashStr = JSON.stringify(crash);
|
||||
const crashFileName = `${game.id}::${this.getTime()}`;
|
||||
fs.writeFile(`${crashFolder}/${crashFileName}`, crashStr, () => {});
|
||||
return crashFileName;
|
||||
}
|
||||
}
|
||||
@ -1,402 +0,0 @@
|
||||
import { CommonMancalaGame } from "mancala.js";
|
||||
import { RTMT } from "../rtmt/rtmt";
|
||||
import { GameStore } from "./gamestore/GameStore";
|
||||
import { generateKey } from "../util/key_factory";
|
||||
import {
|
||||
channel_cancel_new_game,
|
||||
channel_game_move,
|
||||
channel_leave_game,
|
||||
channel_listen_game_events,
|
||||
channel_new_game,
|
||||
channel_on_game_crashed,
|
||||
channel_on_game_end,
|
||||
channel_on_game_start,
|
||||
channel_on_game_update,
|
||||
channel_on_game_user_leave,
|
||||
channel_on_new_game_rejected,
|
||||
channel_on_user_connection_change,
|
||||
channel_unlisten_game_events,
|
||||
Game,
|
||||
GameEndMessage,
|
||||
GameMove,
|
||||
GameUsersConnectionInfo,
|
||||
getSeat,
|
||||
Seat,
|
||||
UserConnectionInfo,
|
||||
} from "@mancala/core";
|
||||
import { GameCrashManager } from "./GameCrashManager";
|
||||
import { MatchMaker } from "../matchmaker/MatchMaker";
|
||||
import { BackendGame, PlayerInfo } from "../models/Game";
|
||||
import { LiveGameStore } from "../live/LiveGameStore";
|
||||
import { GameRecorder } from "./GameRecorder";
|
||||
import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord, toSnapshot } from "./GameSerialization";
|
||||
import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock";
|
||||
import { GAME_CLOCK_MS } from "../consts/config";
|
||||
|
||||
// What players and spectators receive; leaves out spectator ids and other server-side state.
|
||||
export type ClientGame = Game & {
|
||||
startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable<Game["clock"]>, serverTime: number,
|
||||
};
|
||||
|
||||
export class GameManager {
|
||||
gameStore: GameStore;
|
||||
rtmt: RTMT;
|
||||
matchMaker: MatchMaker;
|
||||
private readonly liveGameStore: LiveGameStore;
|
||||
private readonly recorder: GameRecorder;
|
||||
private readonly clock: () => number;
|
||||
// Time each player has for a new game.
|
||||
private readonly gameClockMs: number;
|
||||
private readonly liveGamesListeners: (() => void)[] = [];
|
||||
|
||||
constructor(params: {
|
||||
gameStore: GameStore,
|
||||
rtmt: RTMT,
|
||||
matchMaker: MatchMaker,
|
||||
liveGameStore: LiveGameStore,
|
||||
recorder: GameRecorder,
|
||||
clock?: () => number,
|
||||
gameClockMs?: number,
|
||||
}) {
|
||||
this.gameStore = params.gameStore;
|
||||
this.rtmt = params.rtmt;
|
||||
this.matchMaker = params.matchMaker;
|
||||
this.liveGameStore = params.liveGameStore;
|
||||
this.recorder = params.recorder;
|
||||
this.clock = params.clock ?? Date.now;
|
||||
this.gameClockMs = params.gameClockMs ?? GAME_CLOCK_MS;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.listenOnPlayersPaired();
|
||||
this.listenRtmtMessages();
|
||||
this.listenUserConnectionChange();
|
||||
}
|
||||
|
||||
// Called whenever a live game starts, changes or ends.
|
||||
public onLiveGamesChanged(listener: () => void) {
|
||||
this.liveGamesListeners.push(listener);
|
||||
}
|
||||
|
||||
private listenRtmtMessages() {
|
||||
this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => {
|
||||
const activeGame = this.gameStore.getGameByUser(userKey);
|
||||
if (activeGame) {
|
||||
this.rtmt.sendMessage(userKey, channel_on_new_game_rejected, { gameId: activeGame.id });
|
||||
return;
|
||||
}
|
||||
this.matchMaker.join(userKey);
|
||||
});
|
||||
|
||||
this.rtmt.listenMessage(channel_cancel_new_game, (userKey: string, message: Object) => {
|
||||
this.matchMaker.cancel(userKey);
|
||||
});
|
||||
|
||||
this.rtmt.listenMessage(channel_game_move, (userKey: string, message: Object) => {
|
||||
this.onGameMove(userKey, message as GameMove);
|
||||
});
|
||||
|
||||
this.rtmt.listenMessage(channel_leave_game, (userKey: string, message: Object) => {
|
||||
this.onPlayerLeave(userKey)
|
||||
});
|
||||
|
||||
this.rtmt.listenMessage(channel_listen_game_events, (userKey: string, message: Object) => {
|
||||
this.addSpectator(userKey, message as string);
|
||||
});
|
||||
|
||||
this.rtmt.listenMessage(channel_unlisten_game_events, (userKey: string, message: Object) => {
|
||||
this.removeSpectator(userKey, message as string);
|
||||
});
|
||||
}
|
||||
|
||||
private onGameMove(userKey: string, gameMove: GameMove) {
|
||||
const game = this.gameStore.getGameByUser(userKey);
|
||||
if (game) {
|
||||
try {
|
||||
const now = this.clock();
|
||||
// A move that arrives after the mover's time has run out is not played.
|
||||
if (userKey === game.mancalaGame.turnPlayerId && this.isOutOfTime(game, now)) {
|
||||
this.endGameOnTime(game);
|
||||
return;
|
||||
}
|
||||
const movesBefore = game.mancalaGame.history.length;
|
||||
const moverSeat = getSeat(game.mancalaGame, userKey);
|
||||
game.mancalaGame.moveByPlayerPit(userKey, gameMove.index);
|
||||
if (game.mancalaGame.history.length > movesBefore && moverSeat) {
|
||||
game.moveTimes.push(now);
|
||||
const charged = chargeMove(game.clock, moverSeat, now);
|
||||
game.clock = charged.clock;
|
||||
game.moveRemainingMs.push(charged.remainingMs);
|
||||
}
|
||||
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
||||
this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, this.toClientGame(game));
|
||||
if (game.mancalaGame.state == "ended") {
|
||||
this.endGame(game, { endReason: "completed", winnerId: game.mancalaGame.getWonPlayerId() });
|
||||
} else {
|
||||
this.saveLiveGame(game);
|
||||
this.notifyLiveGamesChanged();
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.onGameError(game, err)
|
||||
}
|
||||
} else {
|
||||
console.log("Game not found!");
|
||||
}
|
||||
}
|
||||
|
||||
private onGameError(game: BackendGame, error: any) {
|
||||
console.error(error);
|
||||
const crashFileName = GameCrashManager.logGameCrash(error, game);
|
||||
console.info(`Game crash saved to file : ${crashFileName}`);
|
||||
this.sendMessageToPlayersAndSpectators(game, channel_on_game_crashed, error);
|
||||
}
|
||||
|
||||
private onPlayerLeave(userKey: string) {
|
||||
const game = this.gameStore.getGameByUser(userKey);
|
||||
if (game) {
|
||||
this.endGame(game, { endReason: "left", winnerId: this.getOpponentId(game, userKey), leftUserId: userKey });
|
||||
}
|
||||
}
|
||||
|
||||
// Ends a live game for any reason: stops the clock, tells everyone, forgets the game and saves it.
|
||||
private endGame(game: BackendGame, ending: GameEnding) {
|
||||
if (this.gameStore.get(game.id) !== game) return;
|
||||
this.deleteGame(game);
|
||||
game.clock = stopClock(game.clock, this.getRunningSeat(game), this.clock());
|
||||
const endMessage: GameEndMessage = {
|
||||
gameId: game.id, result: getResult(game, ending), endReason: ending.endReason, clock: game.clock,
|
||||
};
|
||||
if (ending.winnerId !== undefined) endMessage.winnerId = ending.winnerId;
|
||||
if (ending.leftUserId !== undefined) {
|
||||
endMessage.leftUserId = ending.leftUserId;
|
||||
this.sendMessageToPlayersAndSpectators(game, channel_on_game_user_leave, ending.leftUserId);
|
||||
}
|
||||
this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage);
|
||||
game.spectatorIds = [];
|
||||
|
||||
const record = toGameRecord(game, ending, this.clock());
|
||||
this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err));
|
||||
this.recorder.save(record);
|
||||
this.notifyLiveGamesChanged();
|
||||
}
|
||||
|
||||
// The player whose turn it is ran out of time: the opponent wins.
|
||||
private endGameOnTime(game: BackendGame) {
|
||||
const { turnPlayerId } = game.mancalaGame;
|
||||
this.endGame(game, { endReason: "timeout", winnerId: this.getOpponentId(game, turnPlayerId) });
|
||||
}
|
||||
|
||||
private isOutOfTime(game: BackendGame, now: number): boolean {
|
||||
const runningSeat = this.getRunningSeat(game);
|
||||
return runningSeat !== undefined && getRemainingMs(game.clock, runningSeat, runningSeat, now) <= 0;
|
||||
}
|
||||
|
||||
// The seat whose clock runs; undefined once the game has ended.
|
||||
private getRunningSeat(game: BackendGame): Seat | undefined {
|
||||
const { mancalaGame } = game;
|
||||
if (mancalaGame.state === "ended" || game.clock.turnStartedAt === undefined) return undefined;
|
||||
return getSeat(mancalaGame, mancalaGame.turnPlayerId);
|
||||
}
|
||||
|
||||
// Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or
|
||||
// not the players are online, so this also ends games that both players have left.
|
||||
public endGamesOutOfTime(now = this.clock()) {
|
||||
for (const game of this.gameStore.getAll()) {
|
||||
try {
|
||||
if (this.isOutOfTime(game, now)) this.endGameOnTime(game);
|
||||
} catch (err) {
|
||||
console.error(`(GameManager) Could not end game ${game.id} on time:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Puts the live games saved in Valkey back into memory, e.g. after a restart or deploy.
|
||||
public async restoreLiveGames(): Promise<number> {
|
||||
let snapshots: unknown[];
|
||||
try {
|
||||
snapshots = await this.liveGameStore.loadGames();
|
||||
} catch (err) {
|
||||
console.error("(GameManager) Could not load live games:", (err as Error).message);
|
||||
return 0;
|
||||
}
|
||||
let restored = 0;
|
||||
for (const snapshot of snapshots) {
|
||||
if (!isLiveGameSnapshot(snapshot) || this.gameStore.get(snapshot.id)) continue;
|
||||
// A game that ended but whose snapshot could not be removed must not come back.
|
||||
if (await this.recorder.isRecorded(snapshot.id).catch(() => false)) {
|
||||
this.liveGameStore.removeGame(snapshot.id).catch((err) => this.logStoreError(err));
|
||||
continue;
|
||||
}
|
||||
const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs });
|
||||
this.gameStore.set(game.id, game);
|
||||
for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) {
|
||||
this.gameStore.setGameByUser(playerId, game);
|
||||
}
|
||||
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
||||
restored++;
|
||||
}
|
||||
if (restored > 0) {
|
||||
console.info(`(GameManager) Restored ${restored} live game(s)`);
|
||||
this.notifyLiveGamesChanged();
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
private addSpectator(userId: string, gameId: string) {
|
||||
const game = this.gameStore.get(gameId);
|
||||
if (game) {
|
||||
const isUserPlayer = this.checkUserIdisPlayer(game, userId);
|
||||
const isAlreadySpectator = game.spectatorIds.find((value) => value === userId);
|
||||
if (!isAlreadySpectator && !isUserPlayer) {
|
||||
game.spectatorIds.push(userId);
|
||||
if (!game.allSpectatorIds.includes(userId)) game.allSpectatorIds.push(userId);
|
||||
game.peakSpectators = Math.max(game.peakSpectators, game.spectatorIds.length);
|
||||
this.saveLiveGame(game);
|
||||
this.notifyLiveGamesChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeSpectator(userId: string, gameId: string) {
|
||||
const game = this.gameStore.get(gameId);
|
||||
if (game) {
|
||||
const userIdIndex = game.spectatorIds.findIndex((value) => value === userId);
|
||||
if (userIdIndex >= 0) {
|
||||
game.spectatorIds.splice(userIdIndex, 1);
|
||||
this.notifyLiveGamesChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private checkUserIdisPlayer(game: BackendGame, userId: string): boolean {
|
||||
return game.mancalaGame.player1Id === userId || game.mancalaGame.player2Id === userId;
|
||||
}
|
||||
|
||||
private getOpponentId(game: BackendGame, userId: string): string {
|
||||
const { player1Id, player2Id } = game.mancalaGame;
|
||||
return userId === player1Id ? player2Id : player1Id;
|
||||
}
|
||||
|
||||
private sendMessageToPlayersAndSpectators(game: BackendGame, channel: string, message: Object) {
|
||||
const sendMessage = (userId: string) => this.rtmt.sendMessage(userId, channel, message);
|
||||
sendMessage(game.mancalaGame.player1Id);
|
||||
sendMessage(game.mancalaGame.player2Id);
|
||||
game.spectatorIds.forEach((spectatorId) => sendMessage(spectatorId));
|
||||
}
|
||||
|
||||
public toClientGame(game: BackendGame): ClientGame {
|
||||
return {
|
||||
id: game.id,
|
||||
mancalaGame: game.mancalaGame,
|
||||
gameUsersConnectionInfo: game.gameUsersConnectionInfo,
|
||||
startedAt: game.startedAt,
|
||||
moveTimes: game.moveTimes,
|
||||
spectatorCount: game.spectatorIds.length,
|
||||
clock: game.clock,
|
||||
serverTime: this.clock(),
|
||||
};
|
||||
}
|
||||
|
||||
private listenOnPlayersPaired() {
|
||||
this.matchMaker.listenOnPlayersPaired((player1Id: string, player2Id: string) => {
|
||||
const game = this.createMancalaGame(player1Id, player2Id);
|
||||
this.startGame(game);
|
||||
});
|
||||
}
|
||||
|
||||
public createMancalaGame(userKey1: string, userKey2: string): BackendGame {
|
||||
const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2);
|
||||
const random = Math.random();
|
||||
mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2;
|
||||
const now = this.clock();
|
||||
|
||||
const players: Record<string, PlayerInfo> = {
|
||||
[userKey1]: this.getPlayerInfo(userKey1),
|
||||
[userKey2]: this.getPlayerInfo(userKey2),
|
||||
};
|
||||
const game: BackendGame = {
|
||||
id: mancalaGame.id,
|
||||
mancalaGame,
|
||||
gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromPlayers(players[userKey1], players[userKey2]),
|
||||
spectatorIds: [],
|
||||
allSpectatorIds: [],
|
||||
peakSpectators: 0,
|
||||
players,
|
||||
startedAt: now,
|
||||
moveTimes: [],
|
||||
clock: startClock(this.gameClockMs, now),
|
||||
moveRemainingMs: [],
|
||||
};
|
||||
|
||||
this.gameStore.set(game.id, game);
|
||||
this.gameStore.setGameByUser(userKey1, game);
|
||||
this.gameStore.setGameByUser(userKey2, game);
|
||||
return game;
|
||||
}
|
||||
|
||||
private getPlayerInfo(userId: string): PlayerInfo {
|
||||
const user = this.rtmt.getUser(userId);
|
||||
return { userId, name: user?.name, isGuest: user?.isGuest ?? true };
|
||||
}
|
||||
|
||||
public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo {
|
||||
const { player1Id, player2Id } = game.mancalaGame;
|
||||
const player = (id: string): PlayerInfo => game.players[id] ?? { userId: id, isGuest: true };
|
||||
return this.getGameUsersConnectionInfoFromPlayers(player(player1Id), player(player2Id));
|
||||
}
|
||||
|
||||
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
|
||||
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo =>
|
||||
({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
|
||||
return {
|
||||
user1ConnectionInfo: toConnectionInfo(player1),
|
||||
user2ConnectionInfo: toConnectionInfo(player2)
|
||||
};
|
||||
}
|
||||
|
||||
public startGame(game: BackendGame) {
|
||||
this.sendMessageToPlayersAndSpectators(game, channel_on_game_start, this.toClientGame(game));
|
||||
this.sendUserConnectionInfo(game);
|
||||
this.saveLiveGame(game);
|
||||
this.notifyLiveGamesChanged();
|
||||
}
|
||||
|
||||
public sendUserConnectionInfo(game: BackendGame) {
|
||||
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
||||
this.sendMessageToPlayersAndSpectators(game, channel_on_user_connection_change, game.gameUsersConnectionInfo);
|
||||
}
|
||||
|
||||
public deleteGame(game: BackendGame) {
|
||||
if (game) {
|
||||
this.gameStore.removeGameByPlayer(game.mancalaGame.player1Id);
|
||||
this.gameStore.removeGameByPlayer(game.mancalaGame.player2Id);
|
||||
this.gameStore.remove(game.id);
|
||||
}
|
||||
}
|
||||
|
||||
private saveLiveGame(game: BackendGame) {
|
||||
this.liveGameStore.saveGame(game.id, toSnapshot(game)).catch((err) => this.logStoreError(err));
|
||||
}
|
||||
|
||||
private logStoreError(err: Error) {
|
||||
console.error("(GameManager) Live game store:", err.message);
|
||||
}
|
||||
|
||||
private notifyLiveGamesChanged() {
|
||||
this.liveGamesListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
private listenUserConnectionChange() {
|
||||
this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => {
|
||||
const game = this.gameStore.getGameByUser(clientId);
|
||||
if (game) this.sendUserConnectionInfo(game);
|
||||
if (!isOnline) {
|
||||
const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId);
|
||||
speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId));
|
||||
} else {
|
||||
game && this.rtmt.sendMessage(clientId, channel_on_game_update, this.toClientGame(game));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
import { GameRecord } from "@mancala/core";
|
||||
import { GameHistoryRepository } from "../history/GameHistoryRepository";
|
||||
import { LiveGameStore } from "../live/LiveGameStore";
|
||||
|
||||
const RETRY_INTERVAL_MS = 30_000;
|
||||
|
||||
// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is
|
||||
// down too) and saved later, so a game is never lost because a dependency was briefly unavailable.
|
||||
export class GameRecorder {
|
||||
// Being saved right now.
|
||||
private readonly saving = new Map<string, GameRecord>();
|
||||
// Failed to save; retried later.
|
||||
private readonly pending = new Map<string, GameRecord>();
|
||||
private readonly savedListeners: ((record: GameRecord) => void)[] = [];
|
||||
private retryTimer?: NodeJS.Timeout;
|
||||
private retrying = false;
|
||||
|
||||
constructor(private readonly repository: GameHistoryRepository, private readonly liveGameStore: LiveGameStore) { }
|
||||
|
||||
onSaved(listener: (record: GameRecord) => void) {
|
||||
this.savedListeners.push(listener);
|
||||
}
|
||||
|
||||
async save(record: GameRecord): Promise<void> {
|
||||
this.saving.set(record.id, record);
|
||||
try {
|
||||
await this.repository.save(record);
|
||||
this.saving.delete(record.id);
|
||||
this.notifySaved(record);
|
||||
} catch (err) {
|
||||
console.error(`(GameRecorder) Could not save game ${record.id}, will retry:`, (err as Error).message);
|
||||
this.pending.set(record.id, record);
|
||||
this.saving.delete(record.id);
|
||||
await this.liveGameStore.savePendingRecord(record).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Finished games that are not in the database yet, so readers can include them.
|
||||
getUnsaved(): GameRecord[] {
|
||||
return [...this.saving.values(), ...this.pending.values()];
|
||||
}
|
||||
|
||||
// True if the game is saved or waiting to be saved. Throws if the database cannot be asked.
|
||||
async isRecorded(gameId: string): Promise<boolean> {
|
||||
return this.pending.has(gameId) || (await this.repository.get(gameId)) !== undefined;
|
||||
}
|
||||
|
||||
async retryPending(): Promise<void> {
|
||||
if (this.retrying) return;
|
||||
this.retrying = true;
|
||||
try {
|
||||
const stored = await this.liveGameStore.loadPendingRecords().catch(() => []);
|
||||
stored.forEach((record) => this.pending.set(record.id, record));
|
||||
for (const record of [...this.pending.values()]) {
|
||||
try {
|
||||
await this.repository.save(record);
|
||||
} catch (err) {
|
||||
return; // still unreachable; try again later
|
||||
}
|
||||
this.pending.delete(record.id);
|
||||
await this.liveGameStore.removePendingRecord(record.id).catch(() => undefined);
|
||||
console.info(`(GameRecorder) Saved game ${record.id} after retrying`);
|
||||
this.notifySaved(record);
|
||||
}
|
||||
} finally {
|
||||
this.retrying = false;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.retryPending();
|
||||
this.retryTimer = setInterval(() => this.retryPending(), RETRY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.retryTimer) clearInterval(this.retryTimer);
|
||||
}
|
||||
|
||||
private notifySaved(record: GameRecord) {
|
||||
this.savedListeners.forEach((listener) => listener(record));
|
||||
}
|
||||
}
|
||||
@ -1,139 +0,0 @@
|
||||
import { EndReason, GameClock, GameRecord, GameResult, MoveRecord } from "@mancala/core";
|
||||
import { CommonMancalaGame, GameState, GameStep } from "mancala.js";
|
||||
import { BackendGame, PlayerInfo } from "../models/Game";
|
||||
import { publicUserId } from "./publicIds";
|
||||
import { resumeClock, startClock } from "./GameClock";
|
||||
|
||||
type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] };
|
||||
|
||||
// What is kept in Valkey for a live game; enough to rebuild it after a restart. Version 1 has no clock.
|
||||
export interface LiveGameSnapshot {
|
||||
version: 1 | 2;
|
||||
id: string;
|
||||
player1Id: string;
|
||||
player2Id: string;
|
||||
turnPlayerId: string;
|
||||
state: GameState;
|
||||
board: number[];
|
||||
history: HistoryEntry[];
|
||||
players: Record<string, PlayerInfo>;
|
||||
startedAt: number;
|
||||
moveTimes: number[];
|
||||
peakSpectators: number;
|
||||
allSpectatorIds: string[];
|
||||
clock?: GameClock;
|
||||
moveRemainingMs?: (number | null)[];
|
||||
}
|
||||
|
||||
export interface GameEnding {
|
||||
endReason: EndReason;
|
||||
// Undefined when the game ends without a winner.
|
||||
winnerId?: string;
|
||||
leftUserId?: string;
|
||||
}
|
||||
|
||||
export function toSnapshot(game: BackendGame): LiveGameSnapshot {
|
||||
const { mancalaGame } = game;
|
||||
return {
|
||||
version: 2,
|
||||
id: game.id,
|
||||
player1Id: mancalaGame.player1Id,
|
||||
player2Id: mancalaGame.player2Id,
|
||||
turnPlayerId: mancalaGame.turnPlayerId,
|
||||
state: mancalaGame.state,
|
||||
board: mancalaGame.board.getStoneArray(),
|
||||
history: mancalaGame.history as unknown as HistoryEntry[],
|
||||
players: game.players,
|
||||
startedAt: game.startedAt,
|
||||
moveTimes: game.moveTimes,
|
||||
peakSpectators: game.peakSpectators,
|
||||
allSpectatorIds: game.allSpectatorIds,
|
||||
clock: game.clock,
|
||||
moveRemainingMs: game.moveRemainingMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot {
|
||||
const snapshot = value as LiveGameSnapshot;
|
||||
if (typeof snapshot !== "object" || snapshot === null) return false;
|
||||
const valid = typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
|
||||
&& Array.isArray(snapshot.history) && typeof snapshot.players === "object";
|
||||
if (snapshot.version === 1) return valid;
|
||||
return snapshot.version === 2 && valid && typeof snapshot.clock?.initialMs === "number"
|
||||
&& Array.isArray(snapshot.moveRemainingMs);
|
||||
}
|
||||
|
||||
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on. The running clock goes on
|
||||
// from the start of the turn, so the time the backend was down is not charged; games from before the game clock
|
||||
// get a new clock.
|
||||
export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame {
|
||||
const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id);
|
||||
snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
|
||||
mancalaGame.turnPlayerId = snapshot.turnPlayerId;
|
||||
mancalaGame.state = snapshot.state;
|
||||
mancalaGame.history.push(...snapshot.history);
|
||||
const player = (id: string): PlayerInfo => snapshot.players[id] ?? { userId: id, isGuest: true };
|
||||
return {
|
||||
id: snapshot.id,
|
||||
mancalaGame,
|
||||
gameUsersConnectionInfo: {
|
||||
user1ConnectionInfo: { ...player(snapshot.player1Id), isOnline: false },
|
||||
user2ConnectionInfo: { ...player(snapshot.player2Id), isOnline: false },
|
||||
},
|
||||
spectatorIds: [],
|
||||
allSpectatorIds: snapshot.allSpectatorIds,
|
||||
peakSpectators: snapshot.peakSpectators,
|
||||
players: snapshot.players,
|
||||
startedAt: snapshot.startedAt,
|
||||
moveTimes: snapshot.moveTimes,
|
||||
clock: snapshot.clock ? resumeClock(snapshot.clock, params.now) : startClock(params.gameClockMs, params.now),
|
||||
moveRemainingMs: snapshot.moveRemainingMs ?? snapshot.history.map(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
export function getResult(game: BackendGame, ending: GameEnding): GameResult | null {
|
||||
const { player1Id, player2Id } = game.mancalaGame;
|
||||
if (ending.winnerId === player1Id) return "player1";
|
||||
if (ending.winnerId === player2Id) return "player2";
|
||||
return ending.endReason === "completed" ? "draw" : null;
|
||||
}
|
||||
|
||||
// The record saved for a finished game; every id in it is a public id.
|
||||
export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: number): GameRecord {
|
||||
const { mancalaGame } = game;
|
||||
const history = mancalaGame.history as unknown as HistoryEntry[];
|
||||
const playerSummary = (id: string) => {
|
||||
const player = game.players[id];
|
||||
const summary = { userId: publicUserId(id), isGuest: player?.isGuest ?? true };
|
||||
return player?.name === undefined ? summary : { ...summary, name: player.name };
|
||||
};
|
||||
const moves: MoveRecord[] = history.map((entry, i) => {
|
||||
const move: MoveRecord = {
|
||||
playerId: publicUserId(entry.playerId),
|
||||
moveIndex: entry.moveIndex,
|
||||
boardSnapshot: entry.boardSnapshot,
|
||||
gameSteps: entry.gameSteps,
|
||||
at: game.moveTimes[i] ?? endedAt,
|
||||
};
|
||||
const remainingMs = game.moveRemainingMs[i];
|
||||
if (typeof remainingMs === "number") move.remainingMs = remainingMs;
|
||||
return move;
|
||||
});
|
||||
const record: GameRecord = {
|
||||
id: game.id,
|
||||
player1: playerSummary(mancalaGame.player1Id),
|
||||
player2: playerSummary(mancalaGame.player2Id),
|
||||
board: mancalaGame.board.getStoneArray(),
|
||||
result: getResult(game, ending),
|
||||
endReason: ending.endReason,
|
||||
startedAt: game.startedAt,
|
||||
endedAt,
|
||||
moveCount: moves.length,
|
||||
peakSpectators: game.peakSpectators,
|
||||
totalSpectators: game.allSpectatorIds.length,
|
||||
moves,
|
||||
clockInitialMs: game.clock.initialMs,
|
||||
};
|
||||
if (ending.leftUserId !== undefined) record.leftUserId = publicUserId(ending.leftUserId);
|
||||
return record;
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
import {
|
||||
channel_on_live_games,
|
||||
channel_subscribe_live_games,
|
||||
channel_unsubscribe_live_games,
|
||||
LiveGameSummary,
|
||||
} from "@mancala/core";
|
||||
import { RTMT } from "../rtmt/rtmt";
|
||||
import { BackendGame } from "../models/Game";
|
||||
import { GameStore } from "./gamestore/GameStore";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { publicUserId } from "./publicIds";
|
||||
|
||||
export const LIVE_GAMES_PUSH_COUNT = 5;
|
||||
const PUSH_INTERVAL_MS = 1000;
|
||||
|
||||
export function toLiveGameSummary(game: BackendGame): LiveGameSummary {
|
||||
const { mancalaGame } = game;
|
||||
const player = (id: string) => {
|
||||
const info = game.players[id];
|
||||
const summary = { userId: publicUserId(id), isGuest: info?.isGuest ?? true };
|
||||
return info?.name === undefined ? summary : { ...summary, name: info.name };
|
||||
};
|
||||
return {
|
||||
id: game.id,
|
||||
player1: player(mancalaGame.player1Id),
|
||||
player2: player(mancalaGame.player2Id),
|
||||
board: mancalaGame.board.getStoneArray(),
|
||||
turnPlayerId: publicUserId(mancalaGame.turnPlayerId),
|
||||
spectatorCount: game.spectatorIds.length,
|
||||
moveCount: mancalaGame.history.length,
|
||||
startedAt: game.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const lastActivity = (game: BackendGame) => game.moveTimes[game.moveTimes.length - 1] ?? game.startedAt;
|
||||
|
||||
// Live games for the home page: most spectated first, then most recently active. Subscribers get the top games
|
||||
// when they subscribe and again whenever they change, at most once per second. Subscribing does not make anyone a
|
||||
// spectator.
|
||||
export class LiveGames {
|
||||
private readonly subscribers = new Set<string>();
|
||||
private lastPushed = "";
|
||||
private lastPushAt = 0;
|
||||
private pushTimer?: NodeJS.Timeout;
|
||||
|
||||
constructor(private readonly params: { rtmt: RTMT, gameStore: GameStore, gameManager: GameManager }) {
|
||||
const { rtmt, gameManager } = params;
|
||||
rtmt.listenMessage(channel_subscribe_live_games, (userId: string) => this.subscribe(userId));
|
||||
rtmt.listenMessage(channel_unsubscribe_live_games, (userId: string) => this.subscribers.delete(userId));
|
||||
rtmt.listenOnClientConnectionChange((userId: string, isOnline: boolean) => {
|
||||
if (!isOnline) this.subscribers.delete(userId);
|
||||
});
|
||||
gameManager.onLiveGamesChanged(() => this.schedulePush());
|
||||
}
|
||||
|
||||
getTop(limit: number): LiveGameSummary[] {
|
||||
return this.params.gameStore.getAll()
|
||||
.sort((a, b) => b.spectatorIds.length - a.spectatorIds.length || lastActivity(b) - lastActivity(a))
|
||||
.slice(0, limit)
|
||||
.map(toLiveGameSummary);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.pushTimer) clearTimeout(this.pushTimer);
|
||||
}
|
||||
|
||||
private subscribe(userId: string) {
|
||||
this.subscribers.add(userId);
|
||||
this.params.rtmt.sendMessage(userId, channel_on_live_games, { games: this.getTop(LIVE_GAMES_PUSH_COUNT) });
|
||||
}
|
||||
|
||||
private schedulePush() {
|
||||
if (this.pushTimer) return;
|
||||
const wait = Math.max(0, this.lastPushAt + PUSH_INTERVAL_MS - Date.now());
|
||||
this.pushTimer = setTimeout(() => {
|
||||
this.pushTimer = undefined;
|
||||
this.push();
|
||||
}, wait);
|
||||
}
|
||||
|
||||
private push() {
|
||||
const games = this.getTop(LIVE_GAMES_PUSH_COUNT);
|
||||
const serialized = JSON.stringify(games);
|
||||
if (serialized === this.lastPushed) return;
|
||||
this.lastPushed = serialized;
|
||||
this.lastPushAt = Date.now();
|
||||
this.subscribers.forEach((userId) => this.params.rtmt.sendMessage(userId, channel_on_live_games, { games }));
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import { Game } from "@mancala/core";
|
||||
import { BackendGame } from "../../models/Game";
|
||||
|
||||
export interface GameStore {
|
||||
get(id: string): BackendGame | undefined;
|
||||
getAll(): BackendGame[];
|
||||
set(id: string, game: BackendGame): void;
|
||||
remove(id: string): void;
|
||||
|
||||
setGameIdByUser(userId: string, gameId: string): void;
|
||||
getGameIdByUser(userId: string): string | undefined;
|
||||
removeGameIdByPlayer(userId: string): void;
|
||||
|
||||
setGameByUser(userId: string, game: BackendGame): void;
|
||||
getGameByUser(userId: string): BackendGame | undefined;
|
||||
removeGameByPlayer(userId: string): void;
|
||||
|
||||
getSpeactatingGameIdsByUser(userId: string): string[]
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import { GameStore } from "./GameStore";
|
||||
import { BackendGame } from "../../models/Game";
|
||||
|
||||
export class GameStoreImpl implements GameStore {
|
||||
gameStore: Map<string, BackendGame> = new Map<string, BackendGame>()
|
||||
userGameMap: Map<string, string> = new Map<string, string>()
|
||||
|
||||
get(id: string): BackendGame | undefined {
|
||||
return this.gameStore.get(id);
|
||||
}
|
||||
getAll(): BackendGame[] {
|
||||
return [...this.gameStore.values()];
|
||||
}
|
||||
set(id: string, game: BackendGame): void {
|
||||
this.gameStore.set(id, game);
|
||||
}
|
||||
remove(id: string): void {
|
||||
this.gameStore.delete(id);
|
||||
}
|
||||
|
||||
setGameIdByUser(userId: string, gameId: string): void {
|
||||
this.userGameMap.set(userId, gameId)
|
||||
}
|
||||
getGameIdByUser(userId: string): string | undefined {
|
||||
return this.userGameMap.get(userId);
|
||||
}
|
||||
removeGameIdByPlayer(userId: string): void {
|
||||
this.userGameMap.delete(userId);
|
||||
}
|
||||
|
||||
setGameByUser(userId: string, game: BackendGame): void {
|
||||
this.setGameIdByUser(userId, game.id);
|
||||
}
|
||||
getGameByUser(userId: string): BackendGame | undefined {
|
||||
const gameId = this.getGameIdByUser(userId);
|
||||
return gameId && this.gameStore.get(gameId) || undefined;
|
||||
}
|
||||
removeGameByPlayer(userId: string): void {
|
||||
this.removeGameIdByPlayer(userId);
|
||||
}
|
||||
|
||||
getSpeactatingGameIdsByUser(userId: string): string[] {
|
||||
const speactatingGameIds = [];
|
||||
for (const game of this.gameStore.values()) {
|
||||
const isSpectator = game.spectatorIds.find((value) => value === userId);
|
||||
isSpectator && speactatingGameIds.push(game.id);
|
||||
}
|
||||
return speactatingGameIds;
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import { createHash } from "crypto";
|
||||
import { KEYCLOAK_USER_ID_PREFIX } from "../auth";
|
||||
|
||||
const LEGACY_PUBLIC_ID_PREFIX = "legacy:";
|
||||
|
||||
// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash
|
||||
// instead. Keycloak ids are not secret and stay as they are.
|
||||
export function publicUserId(userId: string): string {
|
||||
if (userId.startsWith(KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) return userId;
|
||||
return LEGACY_PUBLIC_ID_PREFIX + createHash("sha256").update(userId).digest("hex").slice(0, 20);
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
import { FinishedGameSummary, GameRecord, MoveRecord, PlayerSummary } from "@mancala/core";
|
||||
import { Pool, PoolClient } from "pg";
|
||||
|
||||
// Saved, finished games. Ids in records are public ids (see publicIds.ts).
|
||||
export interface GameHistoryRepository {
|
||||
// Saving the same game twice is a no-op, so failed saves can be retried.
|
||||
save(record: GameRecord): Promise<void>;
|
||||
get(id: string): Promise<GameRecord | undefined>;
|
||||
// The user's finished games, most recent first.
|
||||
getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]>;
|
||||
// Finished games with the most spectators, most recent first among equals.
|
||||
getPopular(limit: number): Promise<FinishedGameSummary[]>;
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const SUMMARY_COLUMNS = `g.id, g.result, g.end_reason, g.left_user_id, g.started_at, g.ended_at, g.move_count,
|
||||
g.final_board, g.peak_spectators, g.clock_initial_ms,
|
||||
p1.user_id AS p1_user_id, p1.name AS p1_name, p1.is_guest AS p1_is_guest,
|
||||
p2.user_id AS p2_user_id, p2.name AS p2_name, p2.is_guest AS p2_is_guest`;
|
||||
|
||||
const FROM_GAMES = `FROM games g
|
||||
JOIN game_players p1 ON p1.game_id = g.id AND p1.seat = 1
|
||||
JOIN game_players p2 ON p2.game_id = g.id AND p2.seat = 2`;
|
||||
|
||||
type SummaryRow = {
|
||||
id: string; result: FinishedGameSummary["result"]; end_reason: FinishedGameSummary["endReason"];
|
||||
left_user_id: string | null; started_at: Date; ended_at: Date; move_count: number; final_board: number[];
|
||||
peak_spectators: number; clock_initial_ms: number | null;
|
||||
p1_user_id: string; p1_name: string | null; p1_is_guest: boolean;
|
||||
p2_user_id: string; p2_name: string | null; p2_is_guest: boolean;
|
||||
};
|
||||
|
||||
const toPlayer = (userId: string, name: string | null, isGuest: boolean): PlayerSummary =>
|
||||
name === null ? { userId, isGuest } : { userId, name, isGuest };
|
||||
|
||||
function toSummary(row: SummaryRow): FinishedGameSummary {
|
||||
const summary: FinishedGameSummary = {
|
||||
id: row.id,
|
||||
player1: toPlayer(row.p1_user_id, row.p1_name, row.p1_is_guest),
|
||||
player2: toPlayer(row.p2_user_id, row.p2_name, row.p2_is_guest),
|
||||
board: row.final_board,
|
||||
result: row.result,
|
||||
endReason: row.end_reason,
|
||||
startedAt: row.started_at.getTime(),
|
||||
endedAt: row.ended_at.getTime(),
|
||||
moveCount: row.move_count,
|
||||
peakSpectators: row.peak_spectators,
|
||||
};
|
||||
if (row.left_user_id !== null) summary.leftUserId = row.left_user_id;
|
||||
if (row.clock_initial_ms !== null) summary.clockInitialMs = row.clock_initial_ms;
|
||||
return summary;
|
||||
}
|
||||
|
||||
export class PostgresGameHistoryRepository implements GameHistoryRepository {
|
||||
constructor(private readonly pool: Pool) { }
|
||||
|
||||
async save(record: GameRecord): Promise<void> {
|
||||
await this.inTransaction(async (client) => {
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
|
||||
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0),
|
||||
$9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[record.id, record.player1.userId, record.player2.userId, record.result, record.endReason,
|
||||
record.leftUserId ?? null, record.startedAt, record.endedAt, record.moveCount,
|
||||
JSON.stringify(record.board), JSON.stringify(record.moves), record.peakSpectators,
|
||||
record.totalSpectators, record.clockInitialMs ?? null]);
|
||||
if (inserted.rowCount === 0) return;
|
||||
for (const [seat, player] of [[1, record.player1], [2, record.player2]] as const) {
|
||||
await client.query(
|
||||
"INSERT INTO game_players (game_id, seat, user_id, name, is_guest) VALUES ($1, $2, $3, $4, $5)",
|
||||
[record.id, seat, player.userId, player.name ?? null, player.isGuest]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: string): Promise<GameRecord | undefined> {
|
||||
if (!UUID_PATTERN.test(id)) return undefined;
|
||||
const { rows } = await this.pool.query<SummaryRow & { moves: MoveRecord[], total_spectators: number }>(
|
||||
`SELECT ${SUMMARY_COLUMNS}, g.moves, g.total_spectators ${FROM_GAMES} WHERE g.id = $1`, [id]);
|
||||
if (rows.length === 0) return undefined;
|
||||
return { ...toSummary(rows[0]), moves: rows[0].moves, totalSpectators: rows[0].total_spectators };
|
||||
}
|
||||
|
||||
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
|
||||
const { rows } = await this.pool.query<SummaryRow>(
|
||||
`SELECT ${SUMMARY_COLUMNS} ${FROM_GAMES}
|
||||
WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = $1)
|
||||
ORDER BY g.ended_at DESC LIMIT $2`, [userId, limit]);
|
||||
return rows.map(toSummary);
|
||||
}
|
||||
|
||||
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
|
||||
const { rows } = await this.pool.query<SummaryRow>(
|
||||
`SELECT ${SUMMARY_COLUMNS} ${FROM_GAMES} ORDER BY g.peak_spectators DESC, g.ended_at DESC LIMIT $1`,
|
||||
[limit]);
|
||||
return rows.map(toSummary);
|
||||
}
|
||||
|
||||
private async inTransaction(work: (client: PoolClient) => Promise<void>): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await work(client);
|
||||
await client.query("COMMIT");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import { FinishedGameSummary, GameRecord } from "@mancala/core";
|
||||
import { Cache } from "../cache/Cache";
|
||||
import { GameHistoryRepository } from "./GameHistoryRepository";
|
||||
|
||||
export const MAX_LIST_LIMIT = 20;
|
||||
const CACHE_TTL_SECONDS = 10;
|
||||
const POPULAR_KEY = "popular-finished";
|
||||
const recentKey = (userId: string) => `recent:${userId}`;
|
||||
|
||||
// Games that ended but are not in the database yet (being saved, or waiting for a retry).
|
||||
export type UnsavedGames = () => GameRecord[];
|
||||
|
||||
const toSummary = ({ moves, totalSpectators, ...summary }: GameRecord): FinishedGameSummary => summary;
|
||||
|
||||
const byNewest = (a: FinishedGameSummary, b: FinishedGameSummary) => b.endedAt - a.endedAt;
|
||||
const byPopularity = (a: FinishedGameSummary, b: FinishedGameSummary) =>
|
||||
b.peakSpectators - a.peakSpectators || byNewest(a, b);
|
||||
|
||||
// Reads finished games for the API, including ones not saved yet. Lists are cached briefly and come back without the
|
||||
// saved games while the database is unreachable, so the home page still loads.
|
||||
export class GameHistoryService {
|
||||
constructor(
|
||||
private readonly repository: GameHistoryRepository,
|
||||
private readonly cache: Cache,
|
||||
private readonly unsaved: UnsavedGames = () => [],
|
||||
) { }
|
||||
|
||||
async get(id: string): Promise<GameRecord | undefined> {
|
||||
return this.unsaved().find((record) => record.id === id) ?? this.repository.get(id);
|
||||
}
|
||||
|
||||
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
|
||||
const saved = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT));
|
||||
const unsaved = this.unsaved().filter((r) => r.player1.userId === userId || r.player2.userId === userId);
|
||||
return merge(saved, unsaved, byNewest).slice(0, limit);
|
||||
}
|
||||
|
||||
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
|
||||
const saved = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT));
|
||||
return merge(saved, this.unsaved(), byPopularity).slice(0, limit);
|
||||
}
|
||||
|
||||
// Call when a game has been saved, so lists show it right away.
|
||||
onGameSaved(record: GameRecord): Promise<void> {
|
||||
return this.cache.delete(POPULAR_KEY, recentKey(record.player1.userId), recentKey(record.player2.userId));
|
||||
}
|
||||
|
||||
private async cached(key: string, load: () => Promise<FinishedGameSummary[]>): Promise<FinishedGameSummary[]> {
|
||||
const hit = await this.cache.getJson<FinishedGameSummary[]>(key);
|
||||
if (hit) return hit;
|
||||
try {
|
||||
const games = await load();
|
||||
await this.cache.setJson(key, games, CACHE_TTL_SECONDS);
|
||||
return games;
|
||||
} catch (err) {
|
||||
console.error("(History) Could not load games:", (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function merge(saved: FinishedGameSummary[], unsaved: GameRecord[], order: typeof byNewest): FinishedGameSummary[] {
|
||||
const savedIds = new Set(saved.map((game) => game.id));
|
||||
return [...saved, ...unsaved.filter((record) => !savedIds.has(record.id)).map(toSummary)].sort(order);
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
|
||||
import { RTMTWS } from "./rtmt/rtmt_websocket";
|
||||
import { GameManager } from "./game/GameManager";
|
||||
import { GameStoreImpl } from "./game/gamestore/GameStoreImpl";
|
||||
import { MatchMakerImpl } from "./matchmaker/MatchMakerImpl";
|
||||
import { ExpressApp } from "./server/ExpressApp";
|
||||
import { WebServer } from "./server/WebServer";
|
||||
import { KeycloakTokenVerifier, readAuthConfig } from "./auth";
|
||||
import { createPool, runMigrations } from "./db/postgres";
|
||||
import { PostgresGameHistoryRepository } from "./history/GameHistoryRepository";
|
||||
import { createValkeyClient } from "./live/valkey";
|
||||
import { ValkeyLiveGameStore } from "./live/LiveGameStore";
|
||||
import { GameRecorder } from "./game/GameRecorder";
|
||||
import { LiveGames } from "./game/LiveGames";
|
||||
import { GameHistoryService } from "./history/GameHistoryService";
|
||||
import { ValkeyCache } from "./cache/Cache";
|
||||
import { GameClockScheduler } from "./game/GameClockScheduler";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala";
|
||||
const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379";
|
||||
const MIGRATION_RETRY_MS = 30_000;
|
||||
const VALKEY_STARTUP_WAIT_MS = 5_000;
|
||||
|
||||
async function main() {
|
||||
const authConfig = readAuthConfig();
|
||||
const tokenVerifier = new KeycloakTokenVerifier({
|
||||
issuer: authConfig.keycloakIssuer,
|
||||
jwksUrl: authConfig.keycloakJwksUrl,
|
||||
allowedClientIds: authConfig.keycloakClientIds,
|
||||
});
|
||||
|
||||
const pool = createPool(DATABASE_URL);
|
||||
migrateWhenReachable(pool);
|
||||
const valkey = createValkeyClient(VALKEY_URL);
|
||||
await waitForReady(valkey, VALKEY_STARTUP_WAIT_MS);
|
||||
|
||||
const liveGameStore = new ValkeyLiveGameStore(valkey);
|
||||
const repository = new PostgresGameHistoryRepository(pool);
|
||||
const recorder = new GameRecorder(repository, liveGameStore);
|
||||
const history = new GameHistoryService(repository, new ValkeyCache(valkey), () => recorder.getUnsaved());
|
||||
recorder.onSaved((record) => history.onGameSaved(record));
|
||||
|
||||
const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });
|
||||
const gameStore = new GameStoreImpl();
|
||||
const matchMaker = new MatchMakerImpl({ rtmt });
|
||||
const gameManager = new GameManager({ rtmt, gameStore, matchMaker, liveGameStore, recorder });
|
||||
const liveGames = new LiveGames({ rtmt, gameStore, gameManager });
|
||||
|
||||
// Before accepting connections, so reconnecting players find their games.
|
||||
await gameManager.restoreLiveGames();
|
||||
recorder.start();
|
||||
new GameClockScheduler(gameManager).start();
|
||||
|
||||
const expressApp = new ExpressApp({
|
||||
gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth,
|
||||
});
|
||||
const server = new WebServer({ expressApp: expressApp.app });
|
||||
rtmt.initWebSocket(server.server);
|
||||
}
|
||||
|
||||
// The game runs without the database; saved games wait in the recorder until migrations have run.
|
||||
function migrateWhenReachable(pool: ReturnType<typeof createPool>) {
|
||||
runMigrations(pool).catch((err) => {
|
||||
console.error(`(DB) Migrations failed, retrying in ${MIGRATION_RETRY_MS / 1000}s:`, err.message);
|
||||
setTimeout(() => migrateWhenReachable(pool), MIGRATION_RETRY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForReady(client: ReturnType<typeof createValkeyClient>, timeoutMs: number): Promise<void> {
|
||||
if (client.status === "ready") return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
console.error("(Valkey) Not reachable yet; starting without restoring live games");
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
client.once("ready", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,61 +0,0 @@
|
||||
import { GameRecord } from "@mancala/core";
|
||||
import { Valkey } from "iovalkey";
|
||||
import { scanKeys } from "./valkey";
|
||||
|
||||
const LIVE_PREFIX = "mancala:live:";
|
||||
const PENDING_SAVE_PREFIX = "mancala:pending-save:";
|
||||
|
||||
// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished
|
||||
// games that could not be saved to the database yet. Methods reject when Valkey is unreachable.
|
||||
export interface LiveGameStore {
|
||||
saveGame(gameId: string, snapshot: unknown): Promise<void>;
|
||||
removeGame(gameId: string): Promise<void>;
|
||||
loadGames(): Promise<unknown[]>;
|
||||
savePendingRecord(record: GameRecord): Promise<void>;
|
||||
removePendingRecord(gameId: string): Promise<void>;
|
||||
loadPendingRecords(): Promise<GameRecord[]>;
|
||||
}
|
||||
|
||||
export class ValkeyLiveGameStore implements LiveGameStore {
|
||||
constructor(private readonly client: Valkey) { }
|
||||
|
||||
async saveGame(gameId: string, snapshot: unknown): Promise<void> {
|
||||
await this.client.set(LIVE_PREFIX + gameId, JSON.stringify(snapshot));
|
||||
}
|
||||
|
||||
async removeGame(gameId: string): Promise<void> {
|
||||
await this.client.del(LIVE_PREFIX + gameId);
|
||||
}
|
||||
|
||||
loadGames(): Promise<unknown[]> {
|
||||
return this.loadAll(LIVE_PREFIX);
|
||||
}
|
||||
|
||||
async savePendingRecord(record: GameRecord): Promise<void> {
|
||||
await this.client.set(PENDING_SAVE_PREFIX + record.id, JSON.stringify(record));
|
||||
}
|
||||
|
||||
async removePendingRecord(gameId: string): Promise<void> {
|
||||
await this.client.del(PENDING_SAVE_PREFIX + gameId);
|
||||
}
|
||||
|
||||
loadPendingRecords(): Promise<GameRecord[]> {
|
||||
return this.loadAll(PENDING_SAVE_PREFIX) as Promise<GameRecord[]>;
|
||||
}
|
||||
|
||||
private async loadAll(prefix: string): Promise<unknown[]> {
|
||||
const keys = await scanKeys(this.client, prefix + "*");
|
||||
if (keys.length === 0) return [];
|
||||
const values = await this.client.mget(keys);
|
||||
const parsed: unknown[] = [];
|
||||
values.forEach((value, i) => {
|
||||
if (value === null) return;
|
||||
try {
|
||||
parsed.push(JSON.parse(value));
|
||||
} catch {
|
||||
console.error(`(Valkey) Ignoring unreadable value in ${keys[i]}`);
|
||||
}
|
||||
});
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
import { RedisOptions, Valkey } from "iovalkey";
|
||||
|
||||
export function createValkeyClient(url: string, options: RedisOptions = {}): Valkey {
|
||||
const client = new Valkey(url, {
|
||||
// Fail commands at once while Valkey is down instead of queueing them; callers fall back.
|
||||
enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 1,
|
||||
...options,
|
||||
});
|
||||
// Without a listener, connection errors would be unhandled 'error' events and crash the process.
|
||||
let lastError = "";
|
||||
client.on("error", (err: Error) => {
|
||||
if (err.message !== lastError) console.error("(Valkey) " + err.message);
|
||||
lastError = err.message;
|
||||
});
|
||||
client.on("ready", () => {
|
||||
lastError = "";
|
||||
console.info("(Valkey) Connected");
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
// Keys matching the pattern, without blocking Valkey the way KEYS would.
|
||||
export async function scanKeys(client: Valkey, pattern: string): Promise<string[]> {
|
||||
const keys: string[] = [];
|
||||
let cursor = "0";
|
||||
do {
|
||||
const [next, batch] = await client.scan(cursor, "MATCH", pattern, "COUNT", 100);
|
||||
keys.push(...batch);
|
||||
cursor = next;
|
||||
} while (cursor !== "0");
|
||||
return keys;
|
||||
}
|
||||
186
apps/backend/src/main/java/app/playmancala/Backend.java
Normal file
186
apps/backend/src/main/java/app/playmancala/Backend.java
Normal file
@ -0,0 +1,186 @@
|
||||
package app.playmancala;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import app.playmancala.auth.TokenVerifier;
|
||||
import app.playmancala.game.GameClockScheduler;
|
||||
import app.playmancala.game.GameManager;
|
||||
import app.playmancala.game.GameRecorder;
|
||||
import app.playmancala.game.GameStore;
|
||||
import app.playmancala.game.LiveGames;
|
||||
import app.playmancala.game.MatchMaker;
|
||||
import app.playmancala.history.GameHistoryService;
|
||||
import app.playmancala.history.Migrations;
|
||||
import app.playmancala.history.PostgresGameHistoryRepository;
|
||||
import app.playmancala.live.ValkeyCache;
|
||||
import app.playmancala.live.ValkeyLiveGameStore;
|
||||
import app.playmancala.loop.GameLoop;
|
||||
import app.playmancala.rtmt.RtmtServer;
|
||||
import io.quarkus.redis.datasource.RedisDataSource;
|
||||
import io.quarkus.runtime.Startup;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
// Builds the backend from the infrastructure Quarkus provides and starts it (docs/specs/quarkus-backend, Startup).
|
||||
@Startup
|
||||
@ApplicationScoped
|
||||
public class Backend {
|
||||
private static final Logger LOG = Logger.getLogger(Backend.class);
|
||||
private static final long MIGRATION_RETRY_MS = 30_000;
|
||||
private static final long VALKEY_STARTUP_WAIT_MS = 5_000;
|
||||
|
||||
@Inject
|
||||
DataSource dataSource;
|
||||
|
||||
@Inject
|
||||
RedisDataSource redis;
|
||||
|
||||
@Inject
|
||||
TokenVerifier tokenVerifier;
|
||||
|
||||
@ConfigProperty(name = "mancala.auth.legacy-userkey-auth")
|
||||
String legacyUserKeyAuth;
|
||||
|
||||
@ConfigProperty(name = "mancala.game-clock-ms")
|
||||
long gameClockMs;
|
||||
|
||||
private GameLoop loop;
|
||||
// Valkey writes, in order.
|
||||
private ExecutorService storeWriter;
|
||||
// Migrations and saving finished games.
|
||||
private ScheduledExecutorService recorderIo;
|
||||
// Token checks.
|
||||
private ExecutorService verifier;
|
||||
|
||||
private RtmtServer rtmt;
|
||||
private GameStore gameStore;
|
||||
private GameManager gameManager;
|
||||
private LiveGames liveGames;
|
||||
private GameHistoryService history;
|
||||
private GameClockScheduler clockScheduler;
|
||||
|
||||
@PostConstruct
|
||||
void start() {
|
||||
loop = new GameLoop();
|
||||
storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory());
|
||||
recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory());
|
||||
verifier = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis);
|
||||
PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource);
|
||||
GameRecorder recorder = new GameRecorder(repository, liveGameStore, recorderIo);
|
||||
history = new GameHistoryService(repository, new ValkeyCache(redis), recorder::getUnsaved);
|
||||
recorder.onSaved(history::onGameSaved);
|
||||
|
||||
// Exactly "true", like the Node backend.
|
||||
rtmt = new RtmtServer(tokenVerifier, "true".equals(legacyUserKeyAuth), loop, verifier,
|
||||
RtmtServer.DEFAULT_AUTH_TIMEOUT_MS);
|
||||
gameStore = new GameStore();
|
||||
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter,
|
||||
loop::now, gameClockMs);
|
||||
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
||||
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
||||
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
||||
|
||||
// The game runs without the database; saved games wait in the recorder until migrations have run.
|
||||
recorderIo.execute(this::migrate);
|
||||
recorderIo.scheduleWithFixedDelay(() -> guarded(recorder::retryPending), 0, GameRecorder.RETRY_INTERVAL_MS,
|
||||
TimeUnit.MILLISECONDS);
|
||||
// The first task on the loop, so socket events queue behind it and reconnecting players find their games.
|
||||
loop.execute(() -> {
|
||||
waitForValkey();
|
||||
gameManager.restoreLiveGames();
|
||||
clockScheduler.start();
|
||||
rtmt.start();
|
||||
});
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void stop() {
|
||||
loop.close();
|
||||
storeWriter.shutdown();
|
||||
recorderIo.shutdownNow();
|
||||
verifier.shutdownNow();
|
||||
}
|
||||
|
||||
private void migrate() {
|
||||
try {
|
||||
Migrations.run(dataSource);
|
||||
} catch (SQLException | RuntimeException e) {
|
||||
LOG.errorf("(DB) Migrations failed, retrying in %ds: %s", MIGRATION_RETRY_MS / 1000, e.getMessage());
|
||||
recorderIo.schedule(this::migrate, MIGRATION_RETRY_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitForValkey() {
|
||||
long deadline = System.currentTimeMillis() + VALKEY_STARTUP_WAIT_MS;
|
||||
while (true) {
|
||||
try {
|
||||
redis.execute("PING");
|
||||
return;
|
||||
} catch (RuntimeException e) {
|
||||
if (System.currentTimeMillis() >= deadline) {
|
||||
LOG.error("(Valkey) Not reachable yet; starting without restoring live games");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
Thread.sleep(250);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void guarded(Runnable task) {
|
||||
try {
|
||||
task.run();
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("(Backend) Task failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public GameLoop loop() {
|
||||
return loop;
|
||||
}
|
||||
|
||||
public RtmtServer rtmt() {
|
||||
return rtmt;
|
||||
}
|
||||
|
||||
public GameStore gameStore() {
|
||||
return gameStore;
|
||||
}
|
||||
|
||||
public GameManager gameManager() {
|
||||
return gameManager;
|
||||
}
|
||||
|
||||
public LiveGames liveGames() {
|
||||
return liveGames;
|
||||
}
|
||||
|
||||
public GameHistoryService history() {
|
||||
return history;
|
||||
}
|
||||
|
||||
public TokenVerifier tokenVerifier() {
|
||||
return tokenVerifier;
|
||||
}
|
||||
|
||||
public boolean legacyUserKeyAuth() {
|
||||
return "true".equals(legacyUserKeyAuth);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package app.playmancala.auth;
|
||||
|
||||
public class InvalidTokenException extends Exception {
|
||||
public InvalidTokenException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package app.playmancala.auth;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.jose4j.http.Get;
|
||||
import org.jose4j.jwa.AlgorithmConstraints;
|
||||
import org.jose4j.jwk.HttpsJwks;
|
||||
import org.jose4j.jws.AlgorithmIdentifiers;
|
||||
import org.jose4j.jwt.JwtClaims;
|
||||
import org.jose4j.jwt.consumer.InvalidJwtException;
|
||||
import org.jose4j.jwt.consumer.JwtConsumer;
|
||||
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
|
||||
import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver;
|
||||
import org.jose4j.keys.resolvers.VerificationKeyResolver;
|
||||
|
||||
import app.playmancala.model.AuthUser;
|
||||
|
||||
// Verifies Keycloak access tokens offline against the realm's published signing keys. Player ids are the token
|
||||
// subject prefixed with "kc:", so they can never collide with ids that legacy userKey clients choose for themselves.
|
||||
public class KeycloakTokenVerifier implements TokenVerifier {
|
||||
public static final String KEYCLOAK_USER_ID_PREFIX = "kc:";
|
||||
public static final String GUEST_ROLE = "guest";
|
||||
|
||||
private static final int JWKS_TIMEOUT_MS = 5_000;
|
||||
// Keys are cached this long, and kept this long while Keycloak cannot be reached.
|
||||
private static final long JWKS_CACHE_SECONDS = 600;
|
||||
// A token with an unknown key id refetches the keys at most this often.
|
||||
private static final long JWKS_REFRESH_COOLDOWN_MS = 30_000;
|
||||
|
||||
private final List<String> allowedClientIds;
|
||||
private final JwtConsumer consumer;
|
||||
|
||||
// `jwksUrl` defaults to the issuer's certs endpoint.
|
||||
public static KeycloakTokenVerifier create(String issuer, List<String> allowedClientIds, String jwksUrl) {
|
||||
HttpsJwks jwks = new HttpsJwks(jwksUrl != null ? jwksUrl : issuer + "/protocol/openid-connect/certs");
|
||||
Get get = new Get();
|
||||
get.setConnectTimeout(JWKS_TIMEOUT_MS);
|
||||
get.setReadTimeout(JWKS_TIMEOUT_MS);
|
||||
jwks.setSimpleHttpGet(get);
|
||||
jwks.setDefaultCacheDuration(JWKS_CACHE_SECONDS);
|
||||
jwks.setRetainCacheOnErrorDuration(JWKS_CACHE_SECONDS * 1000);
|
||||
jwks.setRefreshReprieveThreshold(JWKS_REFRESH_COOLDOWN_MS);
|
||||
return new KeycloakTokenVerifier(issuer, allowedClientIds, new HttpsJwksVerificationKeyResolver(jwks));
|
||||
}
|
||||
|
||||
public KeycloakTokenVerifier(String issuer, List<String> allowedClientIds, VerificationKeyResolver keys) {
|
||||
this.allowedClientIds = List.copyOf(allowedClientIds);
|
||||
this.consumer = new JwtConsumerBuilder()
|
||||
.setExpectedIssuer(issuer)
|
||||
.setRequireSubject()
|
||||
.setRequireExpirationTime()
|
||||
// Keycloak access tokens are checked by azp below, not by audience.
|
||||
.setSkipDefaultAudienceValidation()
|
||||
.setJwsAlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.RSA_USING_SHA256)
|
||||
.setVerificationKeyResolver(keys)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthUser verify(String token) throws InvalidTokenException {
|
||||
JwtClaims claims;
|
||||
try {
|
||||
claims = consumer.processToClaims(token);
|
||||
} catch (InvalidJwtException e) {
|
||||
throw new InvalidTokenException(e.getMessage());
|
||||
}
|
||||
|
||||
// Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens.
|
||||
if (!"Bearer".equals(claims.getClaimValue("typ"))) {
|
||||
throw new InvalidTokenException("not an access token");
|
||||
}
|
||||
Object azp = claims.getClaimValue("azp");
|
||||
if (!(azp instanceof String clientId) || !allowedClientIds.contains(clientId)) {
|
||||
throw new InvalidTokenException("token issued to an unknown client");
|
||||
}
|
||||
|
||||
Object username = claims.getClaimValue("preferred_username");
|
||||
return new AuthUser(KEYCLOAK_USER_ID_PREFIX + claims.getClaimValue("sub"),
|
||||
username instanceof String name ? name : null, hasGuestRole(claims.getClaimValue("realm_access")));
|
||||
}
|
||||
|
||||
private static boolean hasGuestRole(Object realmAccess) {
|
||||
return realmAccess instanceof Map<?, ?> access && access.get("roles") instanceof List<?> roles
|
||||
&& roles.contains(GUEST_ROLE);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package app.playmancala.auth;
|
||||
|
||||
import app.playmancala.model.AuthUser;
|
||||
|
||||
public interface TokenVerifier {
|
||||
// The token's user. May block (fetching signing keys); call it off the game loop.
|
||||
AuthUser verify(String token) throws InvalidTokenException;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package app.playmancala.auth;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import jakarta.enterprise.inject.Produces;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
public class TokenVerifierProducer {
|
||||
@Produces
|
||||
@Singleton
|
||||
TokenVerifier tokenVerifier(
|
||||
@ConfigProperty(name = "mancala.auth.issuer") String issuer,
|
||||
@ConfigProperty(name = "mancala.auth.jwks-url") Optional<String> jwksUrl,
|
||||
@ConfigProperty(name = "mancala.auth.client-ids") String clientIds) {
|
||||
// Comma-separated; spaces and empty entries are ignored, as in the Node backend.
|
||||
List<String> allowedClientIds = Arrays.stream(clientIds.split(",")).map(String::trim).filter(id -> !id.isEmpty()).toList();
|
||||
return KeycloakTokenVerifier.create(issuer, allowedClientIds, jwksUrl.orElse(null));
|
||||
}
|
||||
}
|
||||
15
apps/backend/src/main/java/app/playmancala/engine/Bank.java
Normal file
15
apps/backend/src/main/java/app/playmancala/engine/Bank.java
Normal file
@ -0,0 +1,15 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
public class Bank extends Pit {
|
||||
public Bank(int index, int stoneCount) {
|
||||
super(index, stoneCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public boolean isBank() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
221
apps/backend/src/main/java/app/playmancala/engine/Board.java
Normal file
221
apps/backend/src/main/java/app/playmancala/engine/Board.java
Normal file
@ -0,0 +1,221 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// Port of packages/mancala.js/src/core/Board.ts. Pits are indexed counterclockwise: player 1's pits, player 1's bank,
|
||||
// player 2's pits, player 2's bank.
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
|
||||
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
|
||||
@JsonPropertyOrder({ "pits", "playerPitCount", "initialStoneCountInPits" })
|
||||
public class Board {
|
||||
private static final int BANK_COUNT = 2;
|
||||
|
||||
private final List<Pit> pits;
|
||||
private final int playerPitCount;
|
||||
private final int initialStoneCountInPits;
|
||||
|
||||
@JsonIgnore
|
||||
IntConsumer onGameMoveStart = index -> { };
|
||||
@JsonIgnore
|
||||
IntConsumer onGameMove = index -> { };
|
||||
@JsonIgnore
|
||||
IntConsumer onGameMoveEnd = index -> { };
|
||||
|
||||
public Board(int playerPitCount, int initialStoneCountInPits) {
|
||||
this(playerPitCount, initialStoneCountInPits, null);
|
||||
}
|
||||
|
||||
public Board(int playerPitCount, int initialStoneCountInPits, List<Pit> pits) {
|
||||
this.playerPitCount = playerPitCount;
|
||||
this.initialStoneCountInPits = initialStoneCountInPits;
|
||||
this.pits = pits != null ? pits : createPits(initialStoneCountInPits);
|
||||
}
|
||||
|
||||
private List<Pit> createPits(int initialStoneCountInPits) {
|
||||
List<Pit> pitList = new ArrayList<>(playerPitCount * 2 + BANK_COUNT);
|
||||
for (int index = 0; index < totalPitCount(); index++) {
|
||||
PitType pitType = getPitTypeByIndex(index);
|
||||
pitList.add(pitType.isBank() ? new Bank(index, 0) : new Pit(index, initialStoneCountInPits));
|
||||
}
|
||||
return pitList;
|
||||
}
|
||||
|
||||
public List<Pit> getPits() {
|
||||
return pits;
|
||||
}
|
||||
|
||||
public int getPlayerPitCount() {
|
||||
return playerPitCount;
|
||||
}
|
||||
|
||||
public int getInitialStoneCountInPits() {
|
||||
return initialStoneCountInPits;
|
||||
}
|
||||
|
||||
public int player1PitStartIndex() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int player1PitStopIndex() {
|
||||
return player1PitStartIndex() + playerPitCount - 1;
|
||||
}
|
||||
|
||||
public int player1BankIndex() {
|
||||
return player1PitStopIndex() + 1;
|
||||
}
|
||||
|
||||
public int player2PitStartIndex() {
|
||||
return player1BankIndex() + 1;
|
||||
}
|
||||
|
||||
public int player2PitStopIndex() {
|
||||
return player2PitStartIndex() + playerPitCount - 1;
|
||||
}
|
||||
|
||||
public int player2BankIndex() {
|
||||
return player2PitStopIndex() + 1;
|
||||
}
|
||||
|
||||
public int totalPitCount() {
|
||||
return player2BankIndex() + 1;
|
||||
}
|
||||
|
||||
public boolean checkIndex(int index) {
|
||||
return index >= player1PitStartIndex() && index <= player2BankIndex();
|
||||
}
|
||||
|
||||
void checkIndexAndMaybeThrowError(int index) {
|
||||
if (!checkIndex(index)) {
|
||||
throw new IllegalStateException("IndexOutOfRange => index : " + index + " [start, stop] = ["
|
||||
+ player1PitStartIndex() + ", " + player2BankIndex() + "]");
|
||||
}
|
||||
}
|
||||
|
||||
public PitType getPitTypeByIndex(int index) {
|
||||
checkIndexAndMaybeThrowError(index);
|
||||
if (index >= player1PitStartIndex() && index <= player1PitStopIndex()) {
|
||||
return PitType.PLAYER1_PIT;
|
||||
} else if (index == player1BankIndex()) {
|
||||
return PitType.PLAYER1_BANK;
|
||||
} else if (index <= player2PitStopIndex()) {
|
||||
return PitType.PLAYER2_PIT;
|
||||
} else {
|
||||
return PitType.PLAYER2_BANK;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean checkPitTypeIsNormalPitByIndex(int index) {
|
||||
return getPitTypeByIndex(index).isNormalPit();
|
||||
}
|
||||
|
||||
public boolean checkPitTypeIsBankByIndex(int index) {
|
||||
return getPitTypeByIndex(index).isBank();
|
||||
}
|
||||
|
||||
public void move(int index) {
|
||||
checkIndexAndMaybeThrowError(index);
|
||||
PitType pitType = getPitTypeByIndex(index);
|
||||
if (pitType.isBank()) {
|
||||
throw new IllegalStateException("InCorrectPit => index : " + index + " pitType = " + pitTypeName(pitType));
|
||||
}
|
||||
Pit pit = pits.get(index);
|
||||
if (pit.getStoneCount() <= 0) {
|
||||
throw new IllegalStateException("StoneNotFound => index : " + index + " stoneCount = " + pit.getStoneCount());
|
||||
}
|
||||
List<Integer> path = getSowingPath(index);
|
||||
pit.setStoneCount(0);
|
||||
onGameMoveStart.accept(path.getFirst());
|
||||
path.forEach(this::onGameMoveStep);
|
||||
onGameMoveEnd.accept(path.getLast());
|
||||
}
|
||||
|
||||
public List<Integer> getSowingPath(int index) {
|
||||
return getSowingPath(index, pits.get(index).getStoneCount());
|
||||
}
|
||||
|
||||
// Indexes the stones of the pit are dropped into, in order. A single stone goes to the next pit; otherwise the
|
||||
// first stone goes back into the played pit. The player's own bank gets a stone, the opponent's bank is skipped.
|
||||
// The last index is where the last stone lands. Empty for a bank or an empty pit.
|
||||
public List<Integer> getSowingPath(int index, int stoneCount) {
|
||||
checkIndexAndMaybeThrowError(index);
|
||||
if (stoneCount <= 0 || checkPitTypeIsBankByIndex(index)) return List.of();
|
||||
int opponentBankIndex = getPitTypeByIndex(index) == PitType.PLAYER1_PIT ? player2BankIndex() : player1BankIndex();
|
||||
List<Integer> path = new ArrayList<>(stoneCount);
|
||||
int current = stoneCount == 1 ? getNextPitIndexCircularly(index) : index;
|
||||
while (path.size() < stoneCount) {
|
||||
if (current != opponentBankIndex) path.add(current);
|
||||
current = getNextPitIndexCircularly(current);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
public void onGameMoveStep(int index) {
|
||||
pits.get(getPitIndexCircularly(index)).increaseStone();
|
||||
onGameMove.accept(index);
|
||||
}
|
||||
|
||||
public int getNextPitIndexCircularly(int currentPitIndex) {
|
||||
checkIndexAndMaybeThrowError(currentPitIndex);
|
||||
return getPitIndexCircularly(currentPitIndex + 1);
|
||||
}
|
||||
|
||||
public Pit getPitCircularly(int currentPitIndex) {
|
||||
return pits.get(getPitIndexCircularly(currentPitIndex));
|
||||
}
|
||||
|
||||
public int getPitIndexCircularly(int currentPitIndex) {
|
||||
return currentPitIndex % totalPitCount();
|
||||
}
|
||||
|
||||
public int[] getStoneArray() {
|
||||
return pits.stream().mapToInt(Pit::getStoneCount).toArray();
|
||||
}
|
||||
|
||||
public List<Pit> player1Pits() {
|
||||
return pits.subList(player1PitStartIndex(), player1BankIndex());
|
||||
}
|
||||
|
||||
public List<Pit> player2Pits() {
|
||||
return pits.subList(player2PitStartIndex(), player2BankIndex());
|
||||
}
|
||||
|
||||
public Pit player1Bank() {
|
||||
return pits.get(player1BankIndex());
|
||||
}
|
||||
|
||||
public Pit player2Bank() {
|
||||
return pits.get(player2BankIndex());
|
||||
}
|
||||
|
||||
public int getOppositePitIndex(int index) {
|
||||
if (index > player1BankIndex()) {
|
||||
return player2PitStopIndex() - index;
|
||||
} else {
|
||||
return player1BankIndex() + playerPitCount - index;
|
||||
}
|
||||
}
|
||||
|
||||
public void clearPlayer1Pits() {
|
||||
player1Pits().forEach(pit -> pit.setStoneCount(0));
|
||||
}
|
||||
|
||||
public void clearPlayer2Pits() {
|
||||
player2Pits().forEach(pit -> pit.setStoneCount(0));
|
||||
}
|
||||
|
||||
// The TypeScript name of a pit type, for error messages.
|
||||
static String pitTypeName(PitType pitType) {
|
||||
return switch (pitType) {
|
||||
case PLAYER1_PIT -> "player1Pit";
|
||||
case PLAYER1_BANK -> "player1Bank";
|
||||
case PLAYER2_PIT -> "player2Pit";
|
||||
case PLAYER2_BANK -> "player2Bank";
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
public class CommonBoard extends Board {
|
||||
private static final int PIT_COUNT_BY_USER = 6;
|
||||
private static final int INITIAL_STONE_COUNT_IN_PIT = 4;
|
||||
|
||||
public CommonBoard() {
|
||||
super(PIT_COUNT_BY_USER, INITIAL_STONE_COUNT_IN_PIT);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import app.playmancala.engine.rules.GRClearBoardAtEnd;
|
||||
import app.playmancala.engine.rules.GRDoubleStoneInPit;
|
||||
import app.playmancala.engine.rules.GRLastStoneInBank;
|
||||
import app.playmancala.engine.rules.GRLastStoneInEmptyPit;
|
||||
|
||||
// The Turkish Mangala rules. The order of the rules matters: each sees the board as the previous one left it.
|
||||
public class CommonMancalaGame extends MancalaGame {
|
||||
public CommonMancalaGame(String id, String player1Id, String player2Id) {
|
||||
super(id, new CommonBoard(), player1Id, player2Id, player1Id,
|
||||
List.of(new GRLastStoneInEmptyPit(), new GRDoubleStoneInPit(), new GRLastStoneInBank(),
|
||||
new GRClearBoardAtEnd()),
|
||||
List.of());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
public interface GameRule {
|
||||
default void onGameMoveStart(MancalaGame game, int index) {
|
||||
}
|
||||
|
||||
default void onGameMove(MancalaGame game, int index) {
|
||||
}
|
||||
|
||||
default void onGameMoveEnd(MancalaGame game, int index) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public enum GameState {
|
||||
@JsonProperty("initial") INITIAL,
|
||||
@JsonProperty("playing") PLAYING,
|
||||
@JsonProperty("ended") ENDED
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// One thing that happened during a move, for clients to animate. `data` depends on the type and is null for most.
|
||||
@JsonPropertyOrder({ "index", "type", "data" })
|
||||
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||
public record GameStep(int index, String type, Object data) {
|
||||
public GameStep(int index, String type) {
|
||||
this(index, type, null);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,213 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// Port of packages/mancala.js/src/core/MancalaGame.ts. Its JSON is what JSON.stringify makes of the TypeScript object,
|
||||
// because clients rebuild the game from it with MancalaGame.createFromMancalaGame.
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
|
||||
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
|
||||
@JsonPropertyOrder({ "id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history",
|
||||
"currentHistoryItem" })
|
||||
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||
public class MancalaGame {
|
||||
public static final String GAME_STEP_GAME_MOVE = "GAME_STEP_GAME_MOVE";
|
||||
|
||||
private final String id;
|
||||
private final Board board;
|
||||
private final String player1Id;
|
||||
private final String player2Id;
|
||||
private String turnPlayerId;
|
||||
private GameState state;
|
||||
@JsonIgnore
|
||||
private final List<GameRule> rules;
|
||||
private final List<MoveHistoryItem> history;
|
||||
private MoveHistoryItem currentHistoryItem;
|
||||
|
||||
public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId,
|
||||
List<GameRule> gameRules, List<MoveHistoryItem> history) {
|
||||
this(id, board, player1Id, player2Id, turnPlayerId, gameRules, history, GameState.INITIAL);
|
||||
}
|
||||
|
||||
public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId,
|
||||
List<GameRule> gameRules, List<MoveHistoryItem> history, GameState state) {
|
||||
this.id = id;
|
||||
this.board = board;
|
||||
this.player1Id = player1Id;
|
||||
this.player2Id = player2Id;
|
||||
this.turnPlayerId = turnPlayerId;
|
||||
this.state = state;
|
||||
this.rules = List.copyOf(gameRules);
|
||||
this.history = new ArrayList<>(history);
|
||||
listenBoardMoveEvents();
|
||||
}
|
||||
|
||||
private void listenBoardMoveEvents() {
|
||||
board.onGameMoveStart = index -> rules.forEach(gameRule -> gameRule.onGameMoveStart(this, index));
|
||||
board.onGameMove = index -> {
|
||||
addGameStep(new GameStep(index, GAME_STEP_GAME_MOVE));
|
||||
rules.forEach(gameRule -> gameRule.onGameMove(this, index));
|
||||
};
|
||||
board.onGameMoveEnd = index -> rules.forEach(gameRule -> gameRule.onGameMoveEnd(this, index));
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Board getBoard() {
|
||||
return board;
|
||||
}
|
||||
|
||||
public String getPlayer1Id() {
|
||||
return player1Id;
|
||||
}
|
||||
|
||||
public String getPlayer2Id() {
|
||||
return player2Id;
|
||||
}
|
||||
|
||||
public String getTurnPlayerId() {
|
||||
return turnPlayerId;
|
||||
}
|
||||
|
||||
public void setTurnPlayerId(String turnPlayerId) {
|
||||
this.turnPlayerId = turnPlayerId;
|
||||
}
|
||||
|
||||
public GameState getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public void setState(GameState state) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public List<MoveHistoryItem> getHistory() {
|
||||
return history;
|
||||
}
|
||||
|
||||
public MoveHistoryItem getCurrentHistoryItem() {
|
||||
return currentHistoryItem;
|
||||
}
|
||||
|
||||
// Rules have no state; the TypeScript objects serialise as {}.
|
||||
@JsonProperty("gameRules")
|
||||
private List<Map<String, Object>> gameRulesJson() {
|
||||
return rules.stream().map(rule -> Map.<String, Object>of()).toList();
|
||||
}
|
||||
|
||||
public void changePlayerTurn() {
|
||||
turnPlayerId = turnPlayerId.equals(player1Id) ? player2Id : player1Id;
|
||||
}
|
||||
|
||||
public boolean isTurnPlayer1() {
|
||||
return player1Id.equals(turnPlayerId);
|
||||
}
|
||||
|
||||
public boolean isTurnPlayer2() {
|
||||
return player2Id.equals(turnPlayerId);
|
||||
}
|
||||
|
||||
public String getPlayerIdByPitType(PitType pitType) {
|
||||
return switch (pitType) {
|
||||
case PLAYER1_PIT, PLAYER1_BANK -> player1Id;
|
||||
case PLAYER2_PIT, PLAYER2_BANK -> player2Id;
|
||||
};
|
||||
}
|
||||
|
||||
public String getPlayerIdByIndex(int index) {
|
||||
return getPlayerIdByPitType(board.getPitTypeByIndex(index));
|
||||
}
|
||||
|
||||
public boolean checkIsPlayerTurnByIndex(int index) {
|
||||
return checkIsPlayerTurn(getPlayerIdByIndex(index));
|
||||
}
|
||||
|
||||
public int getBoardIndexByPlayerId(String playerId, int pitIndex) {
|
||||
if (player1Id.equals(playerId)) {
|
||||
return board.player1PitStartIndex() + pitIndex;
|
||||
} else if (player2Id.equals(playerId)) {
|
||||
return board.player2PitStartIndex() + pitIndex;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean checkIsPlayerTurn(String playerId) {
|
||||
return playerId.equals(turnPlayerId);
|
||||
}
|
||||
|
||||
public boolean checkPitIndexForPlayerId(String playerId, int pitIndex) {
|
||||
return playerId.equals(getPlayerIdByIndex(getBoardIndexByPlayerId(playerId, pitIndex)));
|
||||
}
|
||||
|
||||
public boolean checkIsPitIndexBank(String playerId, int pitIndex) {
|
||||
return board.getPitTypeByIndex(getBoardIndexByPlayerId(playerId, pitIndex)).isBank();
|
||||
}
|
||||
|
||||
public boolean canPlayerMove(String playerId, int pitIndex) {
|
||||
boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex);
|
||||
boolean isPitIndexBank = checkIsPitIndexBank(playerId, pitIndex);
|
||||
boolean isPlayerTurn = checkIsPlayerTurn(playerId);
|
||||
return isPitIndexCorrect && !isPitIndexBank && isPlayerTurn;
|
||||
}
|
||||
|
||||
public void moveByPlayerPit(String playerId, int pitIndex) {
|
||||
if (state == GameState.ENDED) return;
|
||||
if (state == GameState.INITIAL) {
|
||||
state = GameState.PLAYING;
|
||||
}
|
||||
if (canPlayerMove(playerId, pitIndex)) {
|
||||
int moveIndex = getBoardIndexByPlayerId(playerId, pitIndex);
|
||||
currentHistoryItem = new MoveHistoryItem(playerId, moveIndex, new int[0], List.of());
|
||||
board.move(moveIndex);
|
||||
currentHistoryItem.setBoardSnapshot(board.getStoneArray());
|
||||
history.add(currentHistoryItem);
|
||||
if (checkGameIsEnded()) {
|
||||
state = GameState.ENDED;
|
||||
}
|
||||
} else {
|
||||
boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex);
|
||||
boolean isPlayerTurn = checkIsPlayerTurn(playerId);
|
||||
throw new IllegalStateException("Player cannot move reason : isPitIndexCorrect = " + isPitIndexCorrect
|
||||
+ " isPlayerTurn = " + isPlayerTurn);
|
||||
}
|
||||
}
|
||||
|
||||
public int getPlayer1StoneCountInPits() {
|
||||
return getPlayerStoneCountInPits(board.player1PitStartIndex(), board.player1BankIndex());
|
||||
}
|
||||
|
||||
public int getPlayer2StoneCountInPits() {
|
||||
return getPlayerStoneCountInPits(board.player2PitStartIndex(), board.player2BankIndex());
|
||||
}
|
||||
|
||||
private int getPlayerStoneCountInPits(int playerPitStartIndex, int playerBankIndex) {
|
||||
return board.getPits().subList(playerPitStartIndex, playerBankIndex).stream()
|
||||
.mapToInt(Pit::getStoneCount).sum();
|
||||
}
|
||||
|
||||
public boolean checkGameIsEnded() {
|
||||
return getPlayer1StoneCountInPits() == 0 || getPlayer2StoneCountInPits() == 0;
|
||||
}
|
||||
|
||||
// The winner once the game has ended; null for a draw or a game still being played.
|
||||
public String getWonPlayerId() {
|
||||
int player1StoneCount = board.player1Bank().getStoneCount();
|
||||
int player2StoneCount = board.player2Bank().getStoneCount();
|
||||
if (!checkGameIsEnded() || player1StoneCount == player2StoneCount) return null;
|
||||
return player1StoneCount > player2StoneCount ? player1Id : player2Id;
|
||||
}
|
||||
|
||||
public void addGameStep(GameStep gameStep) {
|
||||
if (currentHistoryItem != null) currentHistoryItem.getGameSteps().add(gameStep);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// A played move. The property order is that of the TypeScript MoveHistoryItem, whose base class sets the board first.
|
||||
@JsonPropertyOrder({ "boardSnapshot", "gameSteps", "playerId", "moveIndex" })
|
||||
public class MoveHistoryItem {
|
||||
private int[] boardSnapshot;
|
||||
private final List<GameStep> gameSteps;
|
||||
private final String playerId;
|
||||
private final int moveIndex;
|
||||
|
||||
@JsonCreator
|
||||
public MoveHistoryItem(
|
||||
@JsonProperty("playerId") String playerId,
|
||||
@JsonProperty("moveIndex") int moveIndex,
|
||||
@JsonProperty("boardSnapshot") int[] boardSnapshot,
|
||||
@JsonProperty("gameSteps") List<GameStep> gameSteps) {
|
||||
this.playerId = playerId;
|
||||
this.moveIndex = moveIndex;
|
||||
this.boardSnapshot = boardSnapshot == null ? new int[0] : boardSnapshot;
|
||||
this.gameSteps = gameSteps == null ? new ArrayList<>() : new ArrayList<>(gameSteps);
|
||||
}
|
||||
|
||||
public int[] getBoardSnapshot() {
|
||||
return boardSnapshot;
|
||||
}
|
||||
|
||||
void setBoardSnapshot(int[] boardSnapshot) {
|
||||
this.boardSnapshot = boardSnapshot;
|
||||
}
|
||||
|
||||
public List<GameStep> getGameSteps() {
|
||||
return gameSteps;
|
||||
}
|
||||
|
||||
public String getPlayerId() {
|
||||
return playerId;
|
||||
}
|
||||
|
||||
public int getMoveIndex() {
|
||||
return moveIndex;
|
||||
}
|
||||
}
|
||||
40
apps/backend/src/main/java/app/playmancala/engine/Pit.java
Normal file
40
apps/backend/src/main/java/app/playmancala/engine/Pit.java
Normal file
@ -0,0 +1,40 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
@JsonPropertyOrder({ "index", "stoneCount" })
|
||||
public class Pit {
|
||||
private final int index;
|
||||
private int stoneCount;
|
||||
|
||||
public Pit(int index, int stoneCount) {
|
||||
this.index = index;
|
||||
this.stoneCount = stoneCount;
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public int getStoneCount() {
|
||||
return stoneCount;
|
||||
}
|
||||
|
||||
public void setStoneCount(int stoneCount) {
|
||||
this.stoneCount = stoneCount;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isBank() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void increaseStone() {
|
||||
stoneCount++;
|
||||
}
|
||||
|
||||
public void addStones(int count) {
|
||||
stoneCount += count;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
public enum PitType {
|
||||
PLAYER1_PIT,
|
||||
PLAYER1_BANK,
|
||||
PLAYER2_PIT,
|
||||
PLAYER2_BANK;
|
||||
|
||||
public boolean isBank() {
|
||||
return this == PLAYER1_BANK || this == PLAYER2_BANK;
|
||||
}
|
||||
|
||||
public boolean isNormalPit() {
|
||||
return this == PLAYER1_PIT || this == PLAYER2_PIT;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package app.playmancala.engine.rules;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import app.playmancala.engine.Board;
|
||||
import app.playmancala.engine.GameRule;
|
||||
import app.playmancala.engine.GameStep;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
|
||||
// When a move empties one side, the player whose side is empty gets the stones left on the other side.
|
||||
public class GRClearBoardAtEnd implements GameRule {
|
||||
public static final String GAME_STEP_BOARD_CLEARED = "GAME_STEP_BOARD_CLEARED";
|
||||
|
||||
public record ClearBoardAtEndData(List<Integer> pitIndexesThatHasStone) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameMoveEnd(MancalaGame game, int index) {
|
||||
Board board = game.getBoard();
|
||||
if (game.getPlayer1StoneCountInPits() == 0) {
|
||||
ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board));
|
||||
board.player1Bank().addStones(game.getPlayer2StoneCountInPits());
|
||||
board.clearPlayer2Pits();
|
||||
game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data));
|
||||
} else if (game.getPlayer2StoneCountInPits() == 0) {
|
||||
ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board));
|
||||
board.player2Bank().addStones(game.getPlayer1StoneCountInPits());
|
||||
board.clearPlayer1Pits();
|
||||
game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data));
|
||||
}
|
||||
}
|
||||
|
||||
private List<Integer> getPitIndexesThatHasStone(Board board) {
|
||||
List<Integer> indexList = new ArrayList<>();
|
||||
int[] stones = board.getStoneArray();
|
||||
for (int index = 0; index < stones.length; index++) {
|
||||
if (stones[index] > 0 && board.checkPitTypeIsNormalPitByIndex(index)) {
|
||||
indexList.add(index);
|
||||
}
|
||||
}
|
||||
return indexList;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package app.playmancala.engine.rules;
|
||||
|
||||
import app.playmancala.engine.Board;
|
||||
import app.playmancala.engine.GameRule;
|
||||
import app.playmancala.engine.GameStep;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.engine.Pit;
|
||||
import app.playmancala.engine.PitType;
|
||||
|
||||
// When the last stone makes the number of stones in an opponent's pit even, the mover takes them.
|
||||
public class GRDoubleStoneInPit implements GameRule {
|
||||
public static final String GAME_STEP_DOUBLE_STONE_IN_PIT = "GAME_STEP_DOUBLE_STONE_IN_PIT";
|
||||
|
||||
public record DoubleStoneInPitData(int pitIndex, int bankIndex) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameMoveEnd(MancalaGame game, int index) {
|
||||
Board board = game.getBoard();
|
||||
index = board.getPitIndexCircularly(index);
|
||||
Pit pit = board.getPits().get(index);
|
||||
PitType pitType = board.getPitTypeByIndex(index);
|
||||
if (pit.getStoneCount() % 2 != 0) return;
|
||||
if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer2()) {
|
||||
capture(game, index, board.player2Bank(), board.player2BankIndex());
|
||||
} else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer1()) {
|
||||
capture(game, index, board.player1Bank(), board.player1BankIndex());
|
||||
}
|
||||
}
|
||||
|
||||
private void capture(MancalaGame game, int pitIndex, Pit bank, int bankIndex) {
|
||||
Pit pit = game.getBoard().getPitCircularly(pitIndex);
|
||||
bank.addStones(pit.getStoneCount());
|
||||
pit.setStoneCount(0);
|
||||
game.addGameStep(new GameStep(pitIndex, GAME_STEP_DOUBLE_STONE_IN_PIT, new DoubleStoneInPitData(pitIndex, bankIndex)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package app.playmancala.engine.rules;
|
||||
|
||||
import app.playmancala.engine.GameRule;
|
||||
import app.playmancala.engine.GameStep;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.engine.PitType;
|
||||
|
||||
// When the last stone lands in the mover's bank, the mover plays again; otherwise the turn passes.
|
||||
public class GRLastStoneInBank implements GameRule {
|
||||
public static final String GAME_STEP_LAST_STONE_IN_BANK = "GAME_STEP_LAST_STONE_IN_BANK";
|
||||
|
||||
@Override
|
||||
public void onGameMoveEnd(MancalaGame game, int index) {
|
||||
index = game.getBoard().getPitIndexCircularly(index);
|
||||
PitType pitType = game.getBoard().getPitTypeByIndex(index);
|
||||
if ((pitType == PitType.PLAYER1_BANK && game.isTurnPlayer1())
|
||||
|| (pitType == PitType.PLAYER2_BANK && game.isTurnPlayer2())) {
|
||||
game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_BANK));
|
||||
} else {
|
||||
game.changePlayerTurn();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package app.playmancala.engine.rules;
|
||||
|
||||
import app.playmancala.engine.Board;
|
||||
import app.playmancala.engine.GameRule;
|
||||
import app.playmancala.engine.GameStep;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.engine.Pit;
|
||||
import app.playmancala.engine.PitType;
|
||||
|
||||
// When the last stone lands in an empty pit of the mover's own, the mover takes it and the stones opposite.
|
||||
public class GRLastStoneInEmptyPit implements GameRule {
|
||||
public static final String GAME_STEP_LAST_STONE_IN_EMPTY_PIT = "GAME_STEP_LAST_STONE_IN_EMPTY_PIT";
|
||||
|
||||
public record LastStoneInEmptyPitData(int oppositeIndex) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameMoveEnd(MancalaGame game, int index) {
|
||||
Board board = game.getBoard();
|
||||
index = board.getPitIndexCircularly(index);
|
||||
Pit pit = board.getPits().get(index);
|
||||
PitType pitType = board.getPitTypeByIndex(index);
|
||||
if (pit.getStoneCount() != 1) return;
|
||||
if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer1()) {
|
||||
capture(game, index, pit, board.player1Bank());
|
||||
} else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer2()) {
|
||||
capture(game, index, pit, board.player2Bank());
|
||||
}
|
||||
}
|
||||
|
||||
private void capture(MancalaGame game, int index, Pit pit, Pit bank) {
|
||||
int oppositeIndex = game.getBoard().getOppositePitIndex(index);
|
||||
Pit oppositePit = game.getBoard().getPits().get(oppositeIndex);
|
||||
if (oppositePit.getStoneCount() <= 0) return;
|
||||
bank.addStones(1 + oppositePit.getStoneCount());
|
||||
oppositePit.setStoneCount(0);
|
||||
pit.setStoneCount(0);
|
||||
game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_EMPTY_PIT, new LastStoneInEmptyPitData(oppositeIndex)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.model.GameClock;
|
||||
import app.playmancala.model.GameUsersConnectionInfo;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
|
||||
// A live game and the server-side state around it. Only touched on the game loop.
|
||||
public class BackendGame {
|
||||
public final String id;
|
||||
public final MancalaGame mancalaGame;
|
||||
public GameUsersConnectionInfo gameUsersConnectionInfo;
|
||||
// Users watching right now.
|
||||
public final List<String> spectatorIds = new ArrayList<>();
|
||||
// Everyone who watched at some point, and the most at once.
|
||||
public final List<String> allSpectatorIds;
|
||||
public int peakSpectators;
|
||||
// Keyed by user id, captured when the game is created.
|
||||
public final Map<String, PlayerSummary> players;
|
||||
public final long startedAt;
|
||||
// Time of each move, aligned with mancalaGame.history.
|
||||
public final List<Long> moveTimes;
|
||||
public GameClock clock;
|
||||
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
|
||||
public final List<Long> moveRemainingMs;
|
||||
|
||||
public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo,
|
||||
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
|
||||
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs) {
|
||||
this.id = mancalaGame.getId();
|
||||
this.mancalaGame = mancalaGame;
|
||||
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
|
||||
this.allSpectatorIds = new ArrayList<>(allSpectatorIds);
|
||||
this.peakSpectators = peakSpectators;
|
||||
this.players = Map.copyOf(players);
|
||||
this.startedAt = startedAt;
|
||||
this.moveTimes = new ArrayList<>(moveTimes);
|
||||
this.clock = clock;
|
||||
this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
|
||||
}
|
||||
|
||||
public PlayerSummary player(String userId) {
|
||||
PlayerSummary player = players.get(userId);
|
||||
return player != null ? player : PlayerSummary.unknown(userId);
|
||||
}
|
||||
|
||||
public boolean isPlayer(String userId) {
|
||||
return mancalaGame.getPlayer1Id().equals(userId) || mancalaGame.getPlayer2Id().equals(userId);
|
||||
}
|
||||
|
||||
public String opponentOf(String userId) {
|
||||
return userId.equals(mancalaGame.getPlayer1Id()) ? mancalaGame.getPlayer2Id() : mancalaGame.getPlayer1Id();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import app.playmancala.model.Json;
|
||||
|
||||
// Writes a file to ./crashes (or the folder in the mancala.crash-dir system property) for each game that failed while a move was played, with the error and the game.
|
||||
public final class CrashLog {
|
||||
private static final Logger LOG = Logger.getLogger(CrashLog.class);
|
||||
private static final Path CRASH_FOLDER = Path.of(System.getProperty("mancala.crash-dir", "crashes"));
|
||||
|
||||
private CrashLog() {
|
||||
}
|
||||
|
||||
// The JSON to write; call it on the game loop, while the game cannot change.
|
||||
public static String describe(Throwable error, BackendGame game) {
|
||||
StringWriter stack = new StringWriter();
|
||||
error.printStackTrace(new PrintWriter(stack));
|
||||
Map<String, Object> crash = new LinkedHashMap<>();
|
||||
crash.put("message", error.getMessage());
|
||||
crash.put("stack", stack.toString());
|
||||
crash.put("game", game);
|
||||
return Json.write(crash);
|
||||
}
|
||||
|
||||
// Returns the file name, e.g. "<game id>::2026-10-16-12:5:3:42" as the Node backend named them.
|
||||
public static String fileName(String gameId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return String.format("%s::%d-%02d-%02d-%d:%d:%d:%d", gameId, now.getYear(), now.getMonthValue(),
|
||||
now.getDayOfMonth(), now.getHour(), now.getMinute(), now.getSecond(), now.getNano() / 1_000_000);
|
||||
}
|
||||
|
||||
public static void write(String fileName, String crash) {
|
||||
try {
|
||||
Files.createDirectories(CRASH_FOLDER);
|
||||
Files.writeString(CRASH_FOLDER.resolve(fileName), crash);
|
||||
} catch (IOException e) {
|
||||
LOG.errorf("(CrashLog) Could not write %s: %s", fileName, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import app.playmancala.loop.Cancellable;
|
||||
import app.playmancala.loop.Scheduler;
|
||||
|
||||
// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected
|
||||
// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs
|
||||
// only decides how soon the end is announced, never who wins.
|
||||
public class GameClockScheduler {
|
||||
public static final long CLOCK_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
private final Runnable endGamesOutOfTime;
|
||||
private final Scheduler scheduler;
|
||||
private final long intervalMs;
|
||||
private Cancellable timer;
|
||||
|
||||
public GameClockScheduler(Runnable endGamesOutOfTime, Scheduler scheduler, long intervalMs) {
|
||||
this.endGamesOutOfTime = endGamesOutOfTime;
|
||||
this.scheduler = scheduler;
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (timer != null) return;
|
||||
timer = scheduler.scheduleAtFixedRate(endGamesOutOfTime, intervalMs);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (timer != null) timer.cancel();
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import app.playmancala.model.GameClock;
|
||||
import app.playmancala.model.Seat;
|
||||
|
||||
// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn
|
||||
// plus when that turn started, so it only changes on moves; the time left at any moment is computed.
|
||||
public final class GameClocks {
|
||||
private GameClocks() {
|
||||
}
|
||||
|
||||
public record ClockTimes(long player1Ms, long player2Ms) {
|
||||
}
|
||||
|
||||
public record ChargedMove(GameClock clock, long remainingMs) {
|
||||
}
|
||||
|
||||
// The seat of the player, or null if they do not play in the game.
|
||||
public static Seat getSeat(String player1Id, String player2Id, String playerId) {
|
||||
if (playerId.equals(player1Id)) return Seat.PLAYER1;
|
||||
if (playerId.equals(player2Id)) return Seat.PLAYER2;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static GameClock start(long initialMs, long now) {
|
||||
return new GameClock(initialMs, initialMs, initialMs, now);
|
||||
}
|
||||
|
||||
// Both players' time at `now`; never below zero. Port of getClockTimes in packages/core.
|
||||
public static ClockTimes getClockTimes(GameClock clock, Seat runningSeat, long now) {
|
||||
long elapsed = runningSeat != null && clock.turnStartedAt() != null ? Math.max(0, now - clock.turnStartedAt()) : 0;
|
||||
return new ClockTimes(
|
||||
Math.max(0, clock.player1Ms() - (runningSeat == Seat.PLAYER1 ? elapsed : 0)),
|
||||
Math.max(0, clock.player2Ms() - (runningSeat == Seat.PLAYER2 ? elapsed : 0)));
|
||||
}
|
||||
|
||||
public static long getRemainingMs(GameClock clock, Seat seat, Seat runningSeat, long now) {
|
||||
ClockTimes times = getClockTimes(clock, runningSeat, now);
|
||||
return seat == Seat.PLAYER1 ? times.player1Ms() : times.player2Ms();
|
||||
}
|
||||
|
||||
// Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn.
|
||||
public static ChargedMove chargeMove(GameClock clock, Seat moverSeat, long now) {
|
||||
long remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now);
|
||||
GameClock charged = moverSeat == Seat.PLAYER1
|
||||
? new GameClock(clock.initialMs(), remainingMs, clock.player2Ms(), now)
|
||||
: new GameClock(clock.initialMs(), clock.player1Ms(), remainingMs, now);
|
||||
return new ChargedMove(charged, remainingMs);
|
||||
}
|
||||
|
||||
// Deducts the running seat's time, if any, and stops the clock.
|
||||
public static GameClock stop(GameClock clock, Seat runningSeat, long now) {
|
||||
ClockTimes times = getClockTimes(clock, runningSeat, now);
|
||||
return new GameClock(clock.initialMs(), times.player1Ms(), times.player2Ms(), null);
|
||||
}
|
||||
|
||||
// Starts the running clock again from the time it had at the start of the turn, e.g. after a restart.
|
||||
public static GameClock resume(GameClock clock, long now) {
|
||||
return new GameClock(clock.initialMs(), clock.player1Ms(), clock.player2Ms(), now);
|
||||
}
|
||||
}
|
||||
351
apps/backend/src/main/java/app/playmancala/game/GameManager.java
Normal file
351
apps/backend/src/main/java/app/playmancala/game/GameManager.java
Normal file
@ -0,0 +1,351 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.engine.CommonMancalaGame;
|
||||
import app.playmancala.engine.GameState;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.game.GameSerialization.GameEnding;
|
||||
import app.playmancala.game.GameSerialization.LiveGameSnapshot;
|
||||
import app.playmancala.live.LiveGameStore;
|
||||
import app.playmancala.model.AuthUser;
|
||||
import app.playmancala.model.Channels;
|
||||
import app.playmancala.model.ClientGame;
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.GameEndMessage;
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.GameUsersConnectionInfo;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
import app.playmancala.model.Seat;
|
||||
import app.playmancala.model.UserConnectionInfo;
|
||||
import app.playmancala.rtmt.Rtmt;
|
||||
|
||||
// Runs live games: matchmaking, moves, the clock, spectators and every way a game ends. Only touched on the game loop.
|
||||
// Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop.
|
||||
public class GameManager {
|
||||
private static final Logger LOG = Logger.getLogger(GameManager.class);
|
||||
|
||||
private final GameStore gameStore;
|
||||
private final Rtmt rtmt;
|
||||
private final MatchMaker matchMaker;
|
||||
private final LiveGameStore liveGameStore;
|
||||
private final GameRecorder recorder;
|
||||
private final Executor storeExecutor;
|
||||
private final LongSupplier clock;
|
||||
// Time each player has for a new game.
|
||||
private final long gameClockMs;
|
||||
private final List<Runnable> liveGamesListeners = new ArrayList<>();
|
||||
|
||||
public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore,
|
||||
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
|
||||
this.gameStore = gameStore;
|
||||
this.rtmt = rtmt;
|
||||
this.matchMaker = matchMaker;
|
||||
this.liveGameStore = liveGameStore;
|
||||
this.recorder = recorder;
|
||||
this.storeExecutor = storeExecutor;
|
||||
this.clock = clock;
|
||||
this.gameClockMs = gameClockMs;
|
||||
matchMaker.listenOnPlayersPaired((player1Id, player2Id) -> startGame(createMancalaGame(player1Id, player2Id)));
|
||||
listenRtmtMessages();
|
||||
listenUserConnectionChange();
|
||||
}
|
||||
|
||||
// Called whenever a live game starts, changes or ends.
|
||||
public void onLiveGamesChanged(Runnable listener) {
|
||||
liveGamesListeners.add(listener);
|
||||
}
|
||||
|
||||
private void listenRtmtMessages() {
|
||||
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||
if (activeGame != null) {
|
||||
rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id));
|
||||
return;
|
||||
}
|
||||
matchMaker.join(userId);
|
||||
});
|
||||
rtmt.listenMessage(Channels.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId));
|
||||
rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove);
|
||||
rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId));
|
||||
rtmt.listenMessage(Channels.LISTEN_GAME_EVENTS, (userId, message) -> {
|
||||
if (message.isTextual()) addSpectator(userId, message.asText());
|
||||
});
|
||||
rtmt.listenMessage(Channels.UNLISTEN_GAME_EVENTS, (userId, message) -> {
|
||||
if (message.isTextual()) removeSpectator(userId, message.asText());
|
||||
});
|
||||
}
|
||||
|
||||
private void onGameMove(String userId, JsonNode gameMove) {
|
||||
BackendGame game = gameStore.getGameByUser(userId);
|
||||
if (game == null) {
|
||||
LOG.debug("Game not found!");
|
||||
return;
|
||||
}
|
||||
MancalaGame mancalaGame = game.mancalaGame;
|
||||
try {
|
||||
long now = clock.getAsLong();
|
||||
// A move that arrives after the mover's time has run out is not played.
|
||||
if (userId.equals(mancalaGame.getTurnPlayerId()) && isOutOfTime(game, now)) {
|
||||
endGameOnTime(game);
|
||||
return;
|
||||
}
|
||||
JsonNode index = gameMove.path("index");
|
||||
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
|
||||
throw new IllegalStateException("Invalid move: " + gameMove);
|
||||
}
|
||||
int movesBefore = mancalaGame.getHistory().size();
|
||||
Seat moverSeat = seatOf(mancalaGame, userId);
|
||||
mancalaGame.moveByPlayerPit(userId, index.asInt());
|
||||
if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) {
|
||||
game.moveTimes.add(now);
|
||||
GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now);
|
||||
game.clock = charged.clock();
|
||||
game.moveRemainingMs.add(charged.remainingMs());
|
||||
}
|
||||
game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game);
|
||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_UPDATE, toClientGame(game));
|
||||
if (mancalaGame.getState() == GameState.ENDED) {
|
||||
endGame(game, new GameEnding(EndReason.COMPLETED, mancalaGame.getWonPlayerId(), null));
|
||||
} else {
|
||||
saveLiveGame(game);
|
||||
notifyLiveGamesChanged();
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
onGameError(game, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void onGameError(BackendGame game, RuntimeException error) {
|
||||
LOG.error("(GameManager) Game " + game.id + " failed", error);
|
||||
String crash = CrashLog.describe(error, game);
|
||||
String fileName = CrashLog.fileName(game.id);
|
||||
storeExecutor.execute(() -> CrashLog.write(fileName, crash));
|
||||
LOG.infof("Game crash saved to file : %s", fileName);
|
||||
// The Node backend sent the Error object, which serialises to {}; clients only use the channel.
|
||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_CRASHED, Map.of());
|
||||
}
|
||||
|
||||
private void onPlayerLeave(String userId) {
|
||||
BackendGame game = gameStore.getGameByUser(userId);
|
||||
if (game != null) {
|
||||
endGame(game, new GameEnding(EndReason.LEFT, game.opponentOf(userId), userId));
|
||||
}
|
||||
}
|
||||
|
||||
// Ends a live game for any reason: stops the clock, tells everyone, forgets the game and saves it.
|
||||
private void endGame(BackendGame game, GameEnding ending) {
|
||||
if (gameStore.get(game.id) != game) return;
|
||||
deleteGame(game);
|
||||
game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong());
|
||||
GameEndMessage endMessage = new GameEndMessage(game.id, GameSerialization.getResult(game, ending),
|
||||
ending.endReason(), game.clock, ending.winnerId(), ending.leftUserId());
|
||||
if (ending.leftUserId() != null) {
|
||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId());
|
||||
}
|
||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_END, endMessage);
|
||||
game.spectatorIds.clear();
|
||||
|
||||
GameRecord record = GameSerialization.toGameRecord(game, ending, clock.getAsLong());
|
||||
inStore(() -> liveGameStore.removeGame(game.id));
|
||||
recorder.save(record);
|
||||
notifyLiveGamesChanged();
|
||||
}
|
||||
|
||||
// The player whose turn it is ran out of time: the opponent wins.
|
||||
private void endGameOnTime(BackendGame game) {
|
||||
endGame(game, new GameEnding(EndReason.TIMEOUT, game.opponentOf(game.mancalaGame.getTurnPlayerId()), null));
|
||||
}
|
||||
|
||||
private boolean isOutOfTime(BackendGame game, long now) {
|
||||
Seat runningSeat = getRunningSeat(game);
|
||||
return runningSeat != null && GameClocks.getRemainingMs(game.clock, runningSeat, runningSeat, now) <= 0;
|
||||
}
|
||||
|
||||
// The seat whose clock runs; null once the game has ended.
|
||||
private Seat getRunningSeat(BackendGame game) {
|
||||
MancalaGame mancalaGame = game.mancalaGame;
|
||||
if (mancalaGame.getState() == GameState.ENDED || game.clock.turnStartedAt() == null) return null;
|
||||
return seatOf(mancalaGame, mancalaGame.getTurnPlayerId());
|
||||
}
|
||||
|
||||
private static Seat seatOf(MancalaGame mancalaGame, String playerId) {
|
||||
return GameClocks.getSeat(mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), playerId);
|
||||
}
|
||||
|
||||
// Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or
|
||||
// not the players are online, so this also ends games that both players have left.
|
||||
public void endGamesOutOfTime() {
|
||||
long now = clock.getAsLong();
|
||||
for (BackendGame game : gameStore.getAll()) {
|
||||
try {
|
||||
if (isOutOfTime(game, now)) endGameOnTime(game);
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("(GameManager) Could not end game " + game.id + " on time", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Puts the live games saved in Valkey back into memory, e.g. after a restart or deploy. Blocks on Valkey and the
|
||||
// database; runs once at startup.
|
||||
public int restoreLiveGames() {
|
||||
List<JsonNode> stored;
|
||||
try {
|
||||
stored = liveGameStore.loadGames();
|
||||
} catch (RuntimeException e) {
|
||||
LOG.errorf("(GameManager) Could not load live games: %s", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
int restored = 0;
|
||||
for (JsonNode json : stored) {
|
||||
LiveGameSnapshot snapshot = GameSerialization.parseSnapshot(json);
|
||||
if (snapshot == null || gameStore.get(snapshot.id()) != null) continue;
|
||||
// A game that ended but whose snapshot could not be removed must not come back.
|
||||
if (isRecorded(snapshot.id())) {
|
||||
inStore(() -> liveGameStore.removeGame(snapshot.id()));
|
||||
continue;
|
||||
}
|
||||
BackendGame game = GameSerialization.fromSnapshot(snapshot, clock.getAsLong(), gameClockMs);
|
||||
gameStore.set(game.id, game);
|
||||
gameStore.setGameByUser(game.mancalaGame.getPlayer1Id(), game);
|
||||
gameStore.setGameByUser(game.mancalaGame.getPlayer2Id(), game);
|
||||
game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game);
|
||||
restored++;
|
||||
}
|
||||
if (restored > 0) {
|
||||
LOG.infof("(GameManager) Restored %d live game(s)", restored);
|
||||
notifyLiveGamesChanged();
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
private boolean isRecorded(String gameId) {
|
||||
try {
|
||||
return recorder.isRecorded(gameId);
|
||||
} catch (RuntimeException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void addSpectator(String userId, String gameId) {
|
||||
BackendGame game = gameStore.get(gameId);
|
||||
if (game == null || game.isPlayer(userId) || game.spectatorIds.contains(userId)) return;
|
||||
game.spectatorIds.add(userId);
|
||||
if (!game.allSpectatorIds.contains(userId)) game.allSpectatorIds.add(userId);
|
||||
game.peakSpectators = Math.max(game.peakSpectators, game.spectatorIds.size());
|
||||
saveLiveGame(game);
|
||||
notifyLiveGamesChanged();
|
||||
}
|
||||
|
||||
private void removeSpectator(String userId, String gameId) {
|
||||
BackendGame game = gameStore.get(gameId);
|
||||
if (game != null && game.spectatorIds.remove(userId)) {
|
||||
notifyLiveGamesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) {
|
||||
rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message);
|
||||
rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message);
|
||||
List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, channel, message));
|
||||
}
|
||||
|
||||
public ClientGame toClientGame(BackendGame game) {
|
||||
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
|
||||
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong());
|
||||
}
|
||||
|
||||
public BackendGame createMancalaGame(String userId1, String userId2) {
|
||||
MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2);
|
||||
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2);
|
||||
long now = clock.getAsLong();
|
||||
PlayerSummary player1 = getPlayerInfo(userId1);
|
||||
PlayerSummary player2 = getPlayerInfo(userId2);
|
||||
Map<String, PlayerSummary> players = new LinkedHashMap<>();
|
||||
players.put(userId1, player1);
|
||||
players.put(userId2, player2);
|
||||
BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0,
|
||||
players, now, List.of(), GameClocks.start(gameClockMs, now), List.of());
|
||||
gameStore.set(game.id, game);
|
||||
gameStore.setGameByUser(userId1, game);
|
||||
gameStore.setGameByUser(userId2, game);
|
||||
return game;
|
||||
}
|
||||
|
||||
private PlayerSummary getPlayerInfo(String userId) {
|
||||
AuthUser user = rtmt.getUser(userId);
|
||||
return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest());
|
||||
}
|
||||
|
||||
public GameUsersConnectionInfo getGameUsersConnectionInfo(BackendGame game) {
|
||||
return getGameUsersConnectionInfo(game.player(game.mancalaGame.getPlayer1Id()),
|
||||
game.player(game.mancalaGame.getPlayer2Id()));
|
||||
}
|
||||
|
||||
private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) {
|
||||
return new GameUsersConnectionInfo(
|
||||
UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())),
|
||||
UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId())));
|
||||
}
|
||||
|
||||
public void startGame(BackendGame game) {
|
||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_START, toClientGame(game));
|
||||
sendUserConnectionInfo(game);
|
||||
saveLiveGame(game);
|
||||
notifyLiveGamesChanged();
|
||||
}
|
||||
|
||||
public void sendUserConnectionInfo(BackendGame game) {
|
||||
game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game);
|
||||
sendMessageToPlayersAndSpectators(game, Channels.ON_USER_CONNECTION_CHANGE, game.gameUsersConnectionInfo);
|
||||
}
|
||||
|
||||
public void deleteGame(BackendGame game) {
|
||||
gameStore.removeGameByPlayer(game.mancalaGame.getPlayer1Id());
|
||||
gameStore.removeGameByPlayer(game.mancalaGame.getPlayer2Id());
|
||||
gameStore.remove(game.id);
|
||||
}
|
||||
|
||||
private void saveLiveGame(BackendGame game) {
|
||||
LiveGameSnapshot snapshot = GameSerialization.toSnapshot(game);
|
||||
inStore(() -> liveGameStore.saveGame(game.id, snapshot));
|
||||
}
|
||||
|
||||
// Runs a Valkey write off the loop; a failure is only logged, the game goes on from memory.
|
||||
private void inStore(Runnable write) {
|
||||
storeExecutor.execute(() -> {
|
||||
try {
|
||||
write.run();
|
||||
} catch (RuntimeException e) {
|
||||
LOG.errorf("(GameManager) Live game store: %s", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void notifyLiveGamesChanged() {
|
||||
liveGamesListeners.forEach(Runnable::run);
|
||||
}
|
||||
|
||||
private void listenUserConnectionChange() {
|
||||
rtmt.listenOnClientConnectionChange((clientId, isOnline) -> {
|
||||
BackendGame game = gameStore.getGameByUser(clientId);
|
||||
if (game != null) sendUserConnectionInfo(game);
|
||||
if (!isOnline) {
|
||||
gameStore.getSpectatingGameIdsByUser(clientId).forEach(gameId -> removeSpectator(clientId, gameId));
|
||||
} else if (game != null) {
|
||||
rtmt.sendMessage(clientId, Channels.ON_GAME_UPDATE, toClientGame(game));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import app.playmancala.history.GameHistoryRepository;
|
||||
import app.playmancala.live.LiveGameStore;
|
||||
import app.playmancala.model.GameRecord;
|
||||
|
||||
// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is
|
||||
// down too) and saved later, so a game is never lost because a dependency was briefly unavailable. Saves run on the
|
||||
// given executor; the lists of unsaved games are read from any thread.
|
||||
public class GameRecorder {
|
||||
public static final long RETRY_INTERVAL_MS = 30_000;
|
||||
private static final Logger LOG = Logger.getLogger(GameRecorder.class);
|
||||
|
||||
private final GameHistoryRepository repository;
|
||||
private final LiveGameStore liveGameStore;
|
||||
private final Executor io;
|
||||
// Being saved right now.
|
||||
private final Map<String, GameRecord> saving = new ConcurrentHashMap<>();
|
||||
// Failed to save; retried later.
|
||||
private final Map<String, GameRecord> pending = new ConcurrentHashMap<>();
|
||||
private final List<Consumer<GameRecord>> savedListeners = new CopyOnWriteArrayList<>();
|
||||
private final AtomicBoolean retrying = new AtomicBoolean();
|
||||
|
||||
public GameRecorder(GameHistoryRepository repository, LiveGameStore liveGameStore, Executor io) {
|
||||
this.repository = repository;
|
||||
this.liveGameStore = liveGameStore;
|
||||
this.io = io;
|
||||
}
|
||||
|
||||
public void onSaved(Consumer<GameRecord> listener) {
|
||||
savedListeners.add(listener);
|
||||
}
|
||||
|
||||
public void save(GameRecord record) {
|
||||
saving.put(record.id(), record);
|
||||
io.execute(() -> {
|
||||
try {
|
||||
repository.save(record);
|
||||
saving.remove(record.id());
|
||||
notifySaved(record);
|
||||
} catch (RuntimeException e) {
|
||||
LOG.errorf("(GameRecorder) Could not save game %s, will retry: %s", record.id(), e.getMessage());
|
||||
pending.put(record.id(), record);
|
||||
saving.remove(record.id());
|
||||
try {
|
||||
liveGameStore.savePendingRecord(record);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Kept in memory.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Finished games that are not in the database yet, so readers can include them.
|
||||
public List<GameRecord> getUnsaved() {
|
||||
List<GameRecord> unsaved = new ArrayList<>(saving.values());
|
||||
unsaved.addAll(pending.values());
|
||||
return unsaved;
|
||||
}
|
||||
|
||||
// True if the game is saved or waiting to be saved. Throws if the database cannot be asked.
|
||||
public boolean isRecorded(String gameId) {
|
||||
return pending.containsKey(gameId) || repository.get(gameId) != null;
|
||||
}
|
||||
|
||||
// Saves the records that failed before, including those a previous run left in Valkey. Blocks.
|
||||
public void retryPending() {
|
||||
if (!retrying.compareAndSet(false, true)) return;
|
||||
try {
|
||||
try {
|
||||
liveGameStore.loadPendingRecords().forEach(record -> pending.put(record.id(), record));
|
||||
} catch (RuntimeException ignored) {
|
||||
// Valkey is down; retry what is in memory.
|
||||
}
|
||||
for (GameRecord record : List.copyOf(pending.values())) {
|
||||
try {
|
||||
repository.save(record);
|
||||
} catch (RuntimeException e) {
|
||||
return; // still unreachable; try again later
|
||||
}
|
||||
pending.remove(record.id());
|
||||
try {
|
||||
liveGameStore.removePendingRecord(record.id());
|
||||
} catch (RuntimeException ignored) {
|
||||
// Saving it again later is a no-op.
|
||||
}
|
||||
LOG.infof("(GameRecorder) Saved game %s after retrying", record.id());
|
||||
notifySaved(record);
|
||||
}
|
||||
} finally {
|
||||
retrying.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifySaved(GameRecord record) {
|
||||
savedListeners.forEach(listener -> listener.accept(record));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.engine.CommonMancalaGame;
|
||||
import app.playmancala.engine.GameState;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.engine.MoveHistoryItem;
|
||||
import app.playmancala.engine.Pit;
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.GameClock;
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.GameResult;
|
||||
import app.playmancala.model.GameUsersConnectionInfo;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.MoveRecord;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
import app.playmancala.model.UserConnectionInfo;
|
||||
|
||||
// Live game snapshots (kept in Valkey) and saved records. Snapshots have the Node backend's format, so either backend
|
||||
// can restore what the other saved.
|
||||
public final class GameSerialization {
|
||||
private static final Logger LOG = Logger.getLogger(GameSerialization.class);
|
||||
private static final int BOARD_SIZE = 14;
|
||||
|
||||
private GameSerialization() {
|
||||
}
|
||||
|
||||
// What is kept in Valkey for a live game; enough to rebuild it after a restart. Version 1 has no clock. Built on
|
||||
// the game loop from copies, so it can be serialised on another thread.
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record LiveGameSnapshot(
|
||||
int version,
|
||||
String id,
|
||||
String player1Id,
|
||||
String player2Id,
|
||||
String turnPlayerId,
|
||||
GameState state,
|
||||
List<Integer> board,
|
||||
List<MoveHistoryItem> history,
|
||||
Map<String, PlayerSummary> players,
|
||||
long startedAt,
|
||||
List<Long> moveTimes,
|
||||
int peakSpectators,
|
||||
List<String> allSpectatorIds,
|
||||
GameClock clock,
|
||||
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs) {
|
||||
}
|
||||
|
||||
public record GameEnding(EndReason endReason, String winnerId, String leftUserId) {
|
||||
// Without a winner.
|
||||
public static GameEnding of(EndReason endReason) {
|
||||
return new GameEnding(endReason, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static LiveGameSnapshot toSnapshot(BackendGame game) {
|
||||
MancalaGame mancalaGame = game.mancalaGame;
|
||||
return new LiveGameSnapshot(2, game.id, mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(),
|
||||
mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
|
||||
List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock,
|
||||
Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)));
|
||||
}
|
||||
|
||||
// The snapshot in the JSON, or null if it is not one this backend can restore.
|
||||
public static LiveGameSnapshot parseSnapshot(JsonNode json) {
|
||||
if (!isLiveGameSnapshot(json)) return null;
|
||||
try {
|
||||
return Json.MAPPER.treeToValue(json, LiveGameSnapshot.class);
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("(GameSerialization) Ignoring unreadable snapshot %s: %s", json.path("id").asText(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLiveGameSnapshot(JsonNode json) {
|
||||
if (json == null || !json.isObject()) return false;
|
||||
boolean valid = json.path("id").isTextual() && json.path("board").isArray() && json.path("board").size() == BOARD_SIZE
|
||||
&& json.path("history").isArray() && json.path("players").isObject();
|
||||
int version = json.path("version").asInt(0);
|
||||
if (version == 1) return valid;
|
||||
return version == 2 && valid && json.path("clock").path("initialMs").isNumber()
|
||||
&& json.path("moveRemainingMs").isArray();
|
||||
}
|
||||
|
||||
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on. The running clock goes on
|
||||
// from the start of the turn, so the time the backend was down is not charged; games from before the game clock
|
||||
// get a new clock.
|
||||
public static BackendGame fromSnapshot(LiveGameSnapshot snapshot, long now, long gameClockMs) {
|
||||
MancalaGame mancalaGame = new CommonMancalaGame(snapshot.id(), snapshot.player1Id(), snapshot.player2Id());
|
||||
List<Pit> pits = mancalaGame.getBoard().getPits();
|
||||
for (int index = 0; index < pits.size(); index++) pits.get(index).setStoneCount(snapshot.board().get(index));
|
||||
mancalaGame.setTurnPlayerId(snapshot.turnPlayerId());
|
||||
mancalaGame.setState(snapshot.state());
|
||||
mancalaGame.getHistory().addAll(snapshot.history());
|
||||
Map<String, PlayerSummary> players = snapshot.players();
|
||||
PlayerSummary player1 = players.getOrDefault(snapshot.player1Id(), PlayerSummary.unknown(snapshot.player1Id()));
|
||||
PlayerSummary player2 = players.getOrDefault(snapshot.player2Id(), PlayerSummary.unknown(snapshot.player2Id()));
|
||||
GameClock clock = snapshot.clock() != null ? GameClocks.resume(snapshot.clock(), now) : GameClocks.start(gameClockMs, now);
|
||||
List<Long> moveRemainingMs = snapshot.moveRemainingMs() != null ? snapshot.moveRemainingMs()
|
||||
: Collections.nCopies(snapshot.history().size(), null);
|
||||
return new BackendGame(mancalaGame,
|
||||
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
|
||||
orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(),
|
||||
orEmpty(snapshot.moveTimes()), clock, moveRemainingMs);
|
||||
}
|
||||
|
||||
// Null when the game ended without a winner and was not completed, e.g. both players left.
|
||||
public static GameResult getResult(BackendGame game, GameEnding ending) {
|
||||
if (game.mancalaGame.getPlayer1Id().equals(ending.winnerId())) return GameResult.PLAYER1;
|
||||
if (game.mancalaGame.getPlayer2Id().equals(ending.winnerId())) return GameResult.PLAYER2;
|
||||
return ending.endReason() == EndReason.COMPLETED ? GameResult.DRAW : null;
|
||||
}
|
||||
|
||||
// The record saved for a finished game; every id in it is a public id.
|
||||
public static GameRecord toGameRecord(BackendGame game, GameEnding ending, long endedAt) {
|
||||
MancalaGame mancalaGame = game.mancalaGame;
|
||||
List<MoveHistoryItem> history = mancalaGame.getHistory();
|
||||
List<MoveRecord> moves = new ArrayList<>(history.size());
|
||||
for (int i = 0; i < history.size(); i++) {
|
||||
MoveHistoryItem entry = history.get(i);
|
||||
moves.add(new MoveRecord(PublicIds.publicUserId(entry.getPlayerId()), entry.getMoveIndex(),
|
||||
boxed(entry.getBoardSnapshot()), List.copyOf(entry.getGameSteps()),
|
||||
i < game.moveTimes.size() ? game.moveTimes.get(i) : endedAt,
|
||||
i < game.moveRemainingMs.size() ? game.moveRemainingMs.get(i) : null));
|
||||
}
|
||||
return new GameRecord(game.id, publicSummary(game, mancalaGame.getPlayer1Id()),
|
||||
publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||
getResult(game, ending), ending.endReason(),
|
||||
ending.leftUserId() == null ? null : PublicIds.publicUserId(ending.leftUserId()), game.startedAt, endedAt,
|
||||
moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size());
|
||||
}
|
||||
|
||||
// The player with a public id, for anything saved or listed.
|
||||
static PlayerSummary publicSummary(BackendGame game, String userId) {
|
||||
PlayerSummary player = game.player(userId);
|
||||
return new PlayerSummary(PublicIds.publicUserId(userId), player.name(), player.isGuest());
|
||||
}
|
||||
|
||||
private static <T> List<T> orEmpty(List<T> list) {
|
||||
return list != null ? list : List.of();
|
||||
}
|
||||
|
||||
static List<Integer> boxed(int[] values) {
|
||||
return Arrays.stream(values).boxed().toList();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
// Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop.
|
||||
public class GameStore {
|
||||
// In the order games started.
|
||||
private final Map<String, BackendGame> games = new LinkedHashMap<>();
|
||||
private final Map<String, String> gameIdsByUser = new HashMap<>();
|
||||
|
||||
public BackendGame get(String id) {
|
||||
return games.get(id);
|
||||
}
|
||||
|
||||
public List<BackendGame> getAll() {
|
||||
return new ArrayList<>(games.values());
|
||||
}
|
||||
|
||||
public void set(String id, BackendGame game) {
|
||||
games.put(id, game);
|
||||
}
|
||||
|
||||
public void remove(String id) {
|
||||
games.remove(id);
|
||||
}
|
||||
|
||||
public void setGameByUser(String userId, BackendGame game) {
|
||||
gameIdsByUser.put(userId, game.id);
|
||||
}
|
||||
|
||||
// The game the user plays in, or null.
|
||||
public BackendGame getGameByUser(String userId) {
|
||||
String gameId = gameIdsByUser.get(userId);
|
||||
return gameId == null ? null : games.get(gameId);
|
||||
}
|
||||
|
||||
public void removeGameByPlayer(String userId) {
|
||||
gameIdsByUser.remove(userId);
|
||||
}
|
||||
|
||||
public List<String> getSpectatingGameIdsByUser(String userId) {
|
||||
return games.values().stream().filter(game -> game.spectatorIds.contains(userId)).map(game -> game.id).toList();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import app.playmancala.loop.Cancellable;
|
||||
import app.playmancala.loop.Scheduler;
|
||||
import app.playmancala.model.Channels;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.LiveGameSummary;
|
||||
import app.playmancala.rtmt.Rtmt;
|
||||
|
||||
// Live games for the home page: most spectated first, then most recently active. Subscribers get the top games
|
||||
// when they subscribe and again whenever they change, at most once per second. Subscribing does not make anyone a
|
||||
// spectator. Only touched on the game loop.
|
||||
public class LiveGames {
|
||||
public static final int LIVE_GAMES_PUSH_COUNT = 5;
|
||||
private static final long PUSH_INTERVAL_MS = 1_000;
|
||||
|
||||
private final Rtmt rtmt;
|
||||
private final GameStore gameStore;
|
||||
private final Scheduler scheduler;
|
||||
private final Set<String> subscribers = new LinkedHashSet<>();
|
||||
private String lastPushed = "";
|
||||
private long lastPushAt;
|
||||
private Cancellable pushTimer;
|
||||
|
||||
public LiveGames(Rtmt rtmt, GameStore gameStore, GameManager gameManager, Scheduler scheduler) {
|
||||
this.rtmt = rtmt;
|
||||
this.gameStore = gameStore;
|
||||
this.scheduler = scheduler;
|
||||
rtmt.listenMessage(Channels.SUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribe(userId));
|
||||
rtmt.listenMessage(Channels.UNSUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribers.remove(userId));
|
||||
rtmt.listenOnClientConnectionChange((userId, isOnline) -> {
|
||||
if (!isOnline) subscribers.remove(userId);
|
||||
});
|
||||
gameManager.onLiveGamesChanged(this::schedulePush);
|
||||
}
|
||||
|
||||
public static LiveGameSummary toLiveGameSummary(BackendGame game) {
|
||||
var mancalaGame = game.mancalaGame;
|
||||
return new LiveGameSummary(game.id,
|
||||
GameSerialization.publicSummary(game, mancalaGame.getPlayer1Id()),
|
||||
GameSerialization.publicSummary(game, mancalaGame.getPlayer2Id()),
|
||||
GameSerialization.boxed(mancalaGame.getBoard().getStoneArray()),
|
||||
PublicIds.publicUserId(mancalaGame.getTurnPlayerId()), game.spectatorIds.size(),
|
||||
mancalaGame.getHistory().size(), game.startedAt);
|
||||
}
|
||||
|
||||
private static long lastActivity(BackendGame game) {
|
||||
return game.moveTimes.isEmpty() ? game.startedAt : game.moveTimes.getLast();
|
||||
}
|
||||
|
||||
public List<LiveGameSummary> getTop(int limit) {
|
||||
return gameStore.getAll().stream()
|
||||
.sorted(Comparator.<BackendGame>comparingInt(game -> game.spectatorIds.size())
|
||||
.thenComparingLong(LiveGames::lastActivity).reversed())
|
||||
.limit(limit)
|
||||
.map(LiveGames::toLiveGameSummary)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
if (pushTimer != null) pushTimer.cancel();
|
||||
}
|
||||
|
||||
private void subscribe(String userId) {
|
||||
subscribers.add(userId);
|
||||
rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, Map.of("games", getTop(LIVE_GAMES_PUSH_COUNT)));
|
||||
}
|
||||
|
||||
private void schedulePush() {
|
||||
if (pushTimer != null) return;
|
||||
long wait = Math.max(0, lastPushAt + PUSH_INTERVAL_MS - scheduler.now());
|
||||
pushTimer = scheduler.schedule(() -> {
|
||||
pushTimer = null;
|
||||
push();
|
||||
}, wait);
|
||||
}
|
||||
|
||||
private void push() {
|
||||
List<LiveGameSummary> games = getTop(LIVE_GAMES_PUSH_COUNT);
|
||||
String serialized = Json.write(games);
|
||||
if (serialized.equals(lastPushed)) return;
|
||||
lastPushed = serialized;
|
||||
lastPushAt = scheduler.now();
|
||||
Map<String, Object> message = Map.of("games", games);
|
||||
List.copyOf(subscribers).forEach(userId -> rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, message));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
import app.playmancala.rtmt.Rtmt;
|
||||
|
||||
// Pairs the next two players who ask for a game. Only touched on the game loop.
|
||||
public class MatchMaker {
|
||||
private final Rtmt rtmt;
|
||||
private String waitingPlayerId;
|
||||
private BiConsumer<String, String> onPlayersPaired = (player1Id, player2Id) -> { };
|
||||
|
||||
public MatchMaker(Rtmt rtmt) {
|
||||
this.rtmt = rtmt;
|
||||
}
|
||||
|
||||
public void join(String newcomerPlayerId) {
|
||||
if (newcomerPlayerId.equals(waitingPlayerId)) return;
|
||||
if (waitingPlayerId != null && rtmt.isClientOnline(waitingPlayerId)) {
|
||||
String waiting = waitingPlayerId;
|
||||
waitingPlayerId = null;
|
||||
onPlayersPaired.accept(waiting, newcomerPlayerId);
|
||||
} else {
|
||||
waitingPlayerId = newcomerPlayerId;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean cancel(String playerId) {
|
||||
if (playerId.equals(waitingPlayerId)) {
|
||||
waitingPlayerId = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void listenOnPlayersPaired(BiConsumer<String, String> onPlayersPaired) {
|
||||
this.onPlayersPaired = onPlayersPaired;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
import app.playmancala.auth.KeycloakTokenVerifier;
|
||||
|
||||
// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash
|
||||
// instead. Keycloak ids are not secret and stay as they are.
|
||||
public final class PublicIds {
|
||||
private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:";
|
||||
|
||||
private PublicIds() {
|
||||
}
|
||||
|
||||
public static String publicUserId(String userId) {
|
||||
if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) {
|
||||
return userId;
|
||||
}
|
||||
try {
|
||||
byte[] hash = MessageDigest.getInstance("SHA-256").digest(userId.getBytes(StandardCharsets.UTF_8));
|
||||
return LEGACY_PUBLIC_ID_PREFIX + HexFormat.of().formatHex(hash).substring(0, 20);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package app.playmancala.history;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import app.playmancala.model.FinishedGameSummary;
|
||||
import app.playmancala.model.GameRecord;
|
||||
|
||||
// Saved, finished games. Ids in records are public ids (see PublicIds). Methods block and throw when the database
|
||||
// is unreachable.
|
||||
public interface GameHistoryRepository {
|
||||
// Saving the same game twice is a no-op, so failed saves can be retried.
|
||||
void save(GameRecord record);
|
||||
|
||||
// Null if there is no such game.
|
||||
GameRecord get(String id);
|
||||
|
||||
// The user's finished games, most recent first.
|
||||
List<FinishedGameSummary> getRecentForUser(String userId, int limit);
|
||||
|
||||
// Finished games with the most spectators, most recent first among equals.
|
||||
List<FinishedGameSummary> getPopular(int limit);
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package app.playmancala.history;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import app.playmancala.live.Cache;
|
||||
import app.playmancala.model.FinishedGameSummary;
|
||||
import app.playmancala.model.GameRecord;
|
||||
|
||||
// Reads finished games for the API, including ones not saved yet. Lists are cached briefly and come back without the
|
||||
// saved games while the database is unreachable, so the home page still loads. Blocks; call it off the game loop.
|
||||
public class GameHistoryService {
|
||||
public static final int MAX_LIST_LIMIT = 20;
|
||||
private static final Logger LOG = Logger.getLogger(GameHistoryService.class);
|
||||
private static final long CACHE_TTL_SECONDS = 10;
|
||||
private static final String POPULAR_KEY = "popular-finished";
|
||||
private static final TypeReference<List<FinishedGameSummary>> SUMMARIES = new TypeReference<>() { };
|
||||
|
||||
private static final Comparator<FinishedGameSummary> BY_NEWEST =
|
||||
Comparator.comparingLong(FinishedGameSummary::endedAt).reversed();
|
||||
private static final Comparator<FinishedGameSummary> BY_POPULARITY =
|
||||
Comparator.comparingInt(FinishedGameSummary::peakSpectators).reversed().thenComparing(BY_NEWEST);
|
||||
|
||||
private final GameHistoryRepository repository;
|
||||
private final Cache cache;
|
||||
// Games that ended but are not in the database yet (being saved, or waiting for a retry).
|
||||
private final Supplier<List<GameRecord>> unsaved;
|
||||
|
||||
public GameHistoryService(GameHistoryRepository repository, Cache cache, Supplier<List<GameRecord>> unsaved) {
|
||||
this.repository = repository;
|
||||
this.cache = cache;
|
||||
this.unsaved = unsaved;
|
||||
}
|
||||
|
||||
private static String recentKey(String userId) {
|
||||
return "recent:" + userId;
|
||||
}
|
||||
|
||||
// Null if there is no such game. Throws if the database cannot be asked.
|
||||
public GameRecord get(String id) {
|
||||
return unsaved.get().stream().filter(record -> record.id().equals(id)).findFirst()
|
||||
.orElseGet(() -> repository.get(id));
|
||||
}
|
||||
|
||||
public List<FinishedGameSummary> getRecentForUser(String userId, int limit) {
|
||||
List<FinishedGameSummary> saved = cached(recentKey(userId), () -> repository.getRecentForUser(userId, MAX_LIST_LIMIT));
|
||||
List<GameRecord> unsavedGames = unsaved.get().stream().filter(record -> record.hasPlayer(userId)).toList();
|
||||
return merge(saved, unsavedGames, BY_NEWEST, limit);
|
||||
}
|
||||
|
||||
public List<FinishedGameSummary> getPopular(int limit) {
|
||||
List<FinishedGameSummary> saved = cached(POPULAR_KEY, () -> repository.getPopular(MAX_LIST_LIMIT));
|
||||
return merge(saved, unsaved.get(), BY_POPULARITY, limit);
|
||||
}
|
||||
|
||||
// Call when a game has been saved, so lists show it right away.
|
||||
public void onGameSaved(GameRecord record) {
|
||||
cache.delete(POPULAR_KEY, recentKey(record.player1().userId()), recentKey(record.player2().userId()));
|
||||
}
|
||||
|
||||
private List<FinishedGameSummary> cached(String key, Supplier<List<FinishedGameSummary>> load) {
|
||||
Optional<List<FinishedGameSummary>> hit = cache.get(key, SUMMARIES);
|
||||
if (hit.isPresent()) return hit.get();
|
||||
try {
|
||||
List<FinishedGameSummary> games = load.get();
|
||||
cache.set(key, games, CACHE_TTL_SECONDS);
|
||||
return games;
|
||||
} catch (RuntimeException e) {
|
||||
LOG.errorf("(History) Could not load games: %s", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<FinishedGameSummary> merge(List<FinishedGameSummary> saved, List<GameRecord> unsaved,
|
||||
Comparator<FinishedGameSummary> order, int limit) {
|
||||
Set<String> savedIds = saved.stream().map(FinishedGameSummary::id).collect(Collectors.toSet());
|
||||
List<FinishedGameSummary> all = new ArrayList<>(saved);
|
||||
unsaved.stream().filter(record -> !savedIds.contains(record.id())).map(GameRecord::toSummary).forEach(all::add);
|
||||
all.sort(order);
|
||||
return all.stream().limit(limit).toList();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package app.playmancala.history;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
// Applies the migrations in src/main/resources/db/migrations that have not run yet, each in its own transaction.
|
||||
// Same table and lock as the Node backend's runner, so databases it migrated are recognised.
|
||||
public final class Migrations {
|
||||
private static final Logger LOG = Logger.getLogger(Migrations.class);
|
||||
private static final long MIGRATION_LOCK_ID = 727274;
|
||||
|
||||
// Applied in this order; never change a migration that has been released, add a new one instead.
|
||||
public static final List<String> VERSIONS = List.of("001_games", "002_clock");
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
|
||||
public static void run(DataSource dataSource) throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
connection.setAutoCommit(true);
|
||||
advisoryLock(connection, "pg_advisory_lock");
|
||||
try {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)""");
|
||||
}
|
||||
Set<String> applied = appliedVersions(connection);
|
||||
for (String version : VERSIONS) {
|
||||
if (applied.contains(version)) continue;
|
||||
apply(connection, version);
|
||||
LOG.infof("(DB) Applied migration %s", version);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
advisoryLock(connection, "pg_advisory_unlock");
|
||||
} catch (SQLException ignored) {
|
||||
// Released with the connection.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void advisoryLock(Connection connection, String function) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("SELECT " + function + "(?)")) {
|
||||
statement.setLong(1, MIGRATION_LOCK_ID);
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> appliedVersions(Connection connection) throws SQLException {
|
||||
Set<String> applied = new HashSet<>();
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations")) {
|
||||
while (rows.next()) applied.add(rows.getString(1));
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
private static void apply(Connection connection, String version) throws SQLException {
|
||||
connection.setAutoCommit(false);
|
||||
try (Statement statement = connection.createStatement();
|
||||
PreparedStatement insert = connection.prepareStatement("INSERT INTO schema_migrations (version) VALUES (?)")) {
|
||||
statement.execute(sql(version));
|
||||
insert.setString(1, version);
|
||||
insert.executeUpdate();
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
connection.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
connection.setAutoCommit(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sql(String version) {
|
||||
String path = "/db/migrations/" + version + ".sql";
|
||||
try (InputStream in = Migrations.class.getResourceAsStream(path)) {
|
||||
if (in == null) throw new IllegalStateException("Missing migration " + path);
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
package app.playmancala.history;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.FinishedGameSummary;
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.GameResult;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.MoveRecord;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
|
||||
// Throws IllegalStateException when the database cannot be reached or a query fails.
|
||||
public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
||||
private static final Pattern UUID_PATTERN =
|
||||
Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final String SUMMARY_COLUMNS = """
|
||||
g.id, g.result, g.end_reason, g.left_user_id, g.started_at, g.ended_at, g.move_count,
|
||||
g.final_board, g.peak_spectators, g.clock_initial_ms,
|
||||
p1.user_id AS p1_user_id, p1.name AS p1_name, p1.is_guest AS p1_is_guest,
|
||||
p2.user_id AS p2_user_id, p2.name AS p2_name, p2.is_guest AS p2_is_guest""";
|
||||
|
||||
private static final String FROM_GAMES = """
|
||||
FROM games g
|
||||
JOIN game_players p1 ON p1.game_id = g.id AND p1.seat = 1
|
||||
JOIN game_players p2 ON p2.game_id = g.id AND p2.seat = 2""";
|
||||
|
||||
private static final TypeReference<List<Integer>> BOARD = new TypeReference<>() { };
|
||||
private static final TypeReference<List<MoveRecord>> MOVES = new TypeReference<>() { };
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public PostgresGameHistoryRepository(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Work<T> {
|
||||
T run(Connection connection) throws SQLException, JsonProcessingException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(GameRecord record) {
|
||||
inTransaction(connection -> {
|
||||
int inserted;
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
|
||||
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms)
|
||||
VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0),
|
||||
?, ?::jsonb, ?::jsonb, ?, ?, ?)
|
||||
ON CONFLICT (id) DO NOTHING""")) {
|
||||
statement.setString(1, record.id());
|
||||
statement.setString(2, record.player1().userId());
|
||||
statement.setString(3, record.player2().userId());
|
||||
statement.setString(4, record.result() == null ? null : record.result().wireName());
|
||||
statement.setString(5, record.endReason().wireName());
|
||||
statement.setString(6, record.leftUserId());
|
||||
statement.setLong(7, record.startedAt());
|
||||
statement.setLong(8, record.endedAt());
|
||||
statement.setInt(9, record.moveCount());
|
||||
statement.setString(10, Json.write(record.board()));
|
||||
statement.setString(11, Json.write(record.moves()));
|
||||
statement.setInt(12, record.peakSpectators());
|
||||
statement.setInt(13, record.totalSpectators());
|
||||
if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER);
|
||||
else statement.setInt(14, Math.toIntExact(record.clockInitialMs()));
|
||||
inserted = statement.executeUpdate();
|
||||
}
|
||||
if (inserted == 0) return null;
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO game_players (game_id, seat, user_id, name, is_guest) VALUES (?::uuid, ?, ?, ?, ?)")) {
|
||||
PlayerSummary[] players = { record.player1(), record.player2() };
|
||||
for (int seat = 1; seat <= 2; seat++) {
|
||||
PlayerSummary player = players[seat - 1];
|
||||
statement.setString(1, record.id());
|
||||
statement.setInt(2, seat);
|
||||
statement.setString(3, player.userId());
|
||||
statement.setString(4, player.name());
|
||||
statement.setBoolean(5, player.isGuest());
|
||||
statement.addBatch();
|
||||
}
|
||||
statement.executeBatch();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameRecord get(String id) {
|
||||
if (!UUID_PATTERN.matcher(id).matches()) return null;
|
||||
return query(connection -> {
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators " + FROM_GAMES + " WHERE g.id = ?::uuid")) {
|
||||
statement.setString(1, id);
|
||||
try (ResultSet row = statement.executeQuery()) {
|
||||
if (!row.next()) return null;
|
||||
FinishedGameSummary summary = toSummary(row);
|
||||
return new GameRecord(summary.id(), summary.player1(), summary.player2(), summary.board(),
|
||||
summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(),
|
||||
summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(),
|
||||
Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FinishedGameSummary> getRecentForUser(String userId, int limit) {
|
||||
return query(connection -> {
|
||||
try (PreparedStatement statement = connection.prepareStatement("SELECT " + SUMMARY_COLUMNS + " " + FROM_GAMES
|
||||
+ " WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = ?) ORDER BY g.ended_at DESC LIMIT ?")) {
|
||||
statement.setString(1, userId);
|
||||
statement.setInt(2, limit);
|
||||
return summaries(statement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FinishedGameSummary> getPopular(int limit) {
|
||||
return query(connection -> {
|
||||
try (PreparedStatement statement = connection.prepareStatement("SELECT " + SUMMARY_COLUMNS + " " + FROM_GAMES
|
||||
+ " ORDER BY g.peak_spectators DESC, g.ended_at DESC LIMIT ?")) {
|
||||
statement.setInt(1, limit);
|
||||
return summaries(statement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<FinishedGameSummary> summaries(PreparedStatement statement) throws SQLException, JsonProcessingException {
|
||||
List<FinishedGameSummary> summaries = new ArrayList<>();
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
while (rows.next()) summaries.add(toSummary(rows));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static FinishedGameSummary toSummary(ResultSet row) throws SQLException, JsonProcessingException {
|
||||
Object clockInitialMs = row.getObject("clock_initial_ms");
|
||||
return new FinishedGameSummary(
|
||||
row.getString("id"),
|
||||
player(row, "p1_"),
|
||||
player(row, "p2_"),
|
||||
Json.MAPPER.readValue(row.getString("final_board"), BOARD),
|
||||
GameResult.fromWireName(row.getString("result")),
|
||||
EndReason.fromWireName(row.getString("end_reason")),
|
||||
row.getString("left_user_id"),
|
||||
row.getTimestamp("started_at").getTime(),
|
||||
row.getTimestamp("ended_at").getTime(),
|
||||
row.getInt("move_count"),
|
||||
row.getInt("peak_spectators"),
|
||||
clockInitialMs == null ? null : ((Number) clockInitialMs).longValue());
|
||||
}
|
||||
|
||||
private static PlayerSummary player(ResultSet row, String prefix) throws SQLException {
|
||||
return new PlayerSummary(row.getString(prefix + "user_id"), row.getString(prefix + "name"),
|
||||
row.getBoolean(prefix + "is_guest"));
|
||||
}
|
||||
|
||||
private <T> T query(Work<T> work) {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
return work.run(connection);
|
||||
} catch (SQLException | JsonProcessingException e) {
|
||||
throw new IllegalStateException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void inTransaction(Work<Void> work) {
|
||||
query(connection -> {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
work.run(connection);
|
||||
connection.commit();
|
||||
} catch (SQLException | JsonProcessingException | RuntimeException e) {
|
||||
try {
|
||||
connection.rollback();
|
||||
} catch (SQLException ignored) {
|
||||
// The connection is broken; the transaction ends with it.
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
connection.setAutoCommit(true);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package app.playmancala.http;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import app.playmancala.auth.InvalidTokenException;
|
||||
import app.playmancala.auth.TokenVerifier;
|
||||
import app.playmancala.model.AuthUser;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
// Requires an "Authorization: Bearer <access token>" header with the same Keycloak token the WebSocket uses.
|
||||
final class BearerAuth {
|
||||
private static final Logger LOG = Logger.getLogger(BearerAuth.class);
|
||||
private static final Pattern BEARER = Pattern.compile("^Bearer (.+)$");
|
||||
|
||||
private BearerAuth() {
|
||||
}
|
||||
|
||||
// The user, or a 401 response.
|
||||
static AuthUser requireUser(TokenVerifier tokenVerifier, String authorization) {
|
||||
Matcher match = BEARER.matcher(authorization == null ? "" : authorization);
|
||||
if (!match.matches()) throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
try {
|
||||
return tokenVerifier.verify(match.group(1));
|
||||
} catch (InvalidTokenException e) {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("(Auth) Token verification failed", e);
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package app.playmancala.http;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
|
||||
|
||||
import io.vertx.core.http.HttpServerRequest;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
// A failing dependency (the database, mostly) answers 503, as the Express error handler did.
|
||||
public class ErrorMapper {
|
||||
private static final Logger LOG = Logger.getLogger(ErrorMapper.class);
|
||||
|
||||
@ServerExceptionMapper
|
||||
public Response map(RuntimeException error, HttpServerRequest request) {
|
||||
if (error instanceof WebApplicationException webError) return webError.getResponse();
|
||||
LOG.errorf("(HTTP) %s %s failed: %s", request.method(), request.path(), error.getMessage());
|
||||
return Response.status(Response.Status.SERVICE_UNAVAILABLE).build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
package app.playmancala.http;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import app.playmancala.Backend;
|
||||
import app.playmancala.game.BackendGame;
|
||||
import app.playmancala.game.LiveGames;
|
||||
import app.playmancala.history.GameHistoryService;
|
||||
import app.playmancala.model.AuthUser;
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.LiveGameSummary;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
// The HTTP API. Blocking: runs on worker threads, reads live games on the game loop and saved games itself.
|
||||
// Bodies are written with the shared mapper, so they are the same JSON as the WebSocket messages.
|
||||
@Path("/")
|
||||
public class GameResource {
|
||||
private static final Pattern LEADING_INTEGER = Pattern.compile("^\\s*([+-]?\\d+)");
|
||||
|
||||
@Inject
|
||||
Backend backend;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
public String root() {
|
||||
return "Server up and running! ci test1";
|
||||
}
|
||||
|
||||
// Issues userKeys for legacy clients; token clients get their identity from Keycloak.
|
||||
@GET
|
||||
@Path("register{slash: /?}")
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
public String register() {
|
||||
if (!backend.legacyUserKeyAuth()) throw new NotFoundException();
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
// A live game as players receive it, or the saved record of a finished game.
|
||||
@GET
|
||||
@Path("game/{id}")
|
||||
public Response game(@PathParam("id") String id) {
|
||||
// Serialised on the loop, because the client game refers to the live engine objects.
|
||||
String live = backend.loop().call(() -> {
|
||||
BackendGame game = backend.gameStore().get(id);
|
||||
return game == null ? null : Json.write(backend.gameManager().toClientGame(game));
|
||||
});
|
||||
if (live != null) return json(live);
|
||||
GameRecord record = backend.history().get(id);
|
||||
if (record == null) throw new NotFoundException();
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("status", "finished");
|
||||
response.put("record", record);
|
||||
return json(response);
|
||||
}
|
||||
|
||||
// Live games if any are being played, otherwise the most spectated finished games. With kind=finished always the
|
||||
// finished games, for clients that get live games over the WebSocket.
|
||||
@GET
|
||||
@Path("games/popular")
|
||||
public Response popular(@QueryParam("limit") String limitParam, @QueryParam("kind") String kind) {
|
||||
int limit = parseLimit(limitParam, 5);
|
||||
List<LiveGameSummary> live = "finished".equals(kind) ? List.of()
|
||||
: backend.loop().call(() -> backend.liveGames().getTop(limit));
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
if (!live.isEmpty()) {
|
||||
response.put("kind", "live");
|
||||
response.put("games", live);
|
||||
} else {
|
||||
response.put("kind", "finished");
|
||||
response.put("games", backend.history().getPopular(limit));
|
||||
}
|
||||
return json(response);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("games/me/active")
|
||||
public Response activeGame(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) {
|
||||
AuthUser user = BearerAuth.requireUser(backend.tokenVerifier(), authorization);
|
||||
LiveGameSummary game = backend.loop().call(() -> {
|
||||
BackendGame active = backend.gameStore().getGameByUser(user.id());
|
||||
return active == null ? null : LiveGames.toLiveGameSummary(active);
|
||||
});
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("game", game);
|
||||
return json(response);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("games/me")
|
||||
public Response myGames(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization,
|
||||
@QueryParam("limit") String limit) {
|
||||
AuthUser user = BearerAuth.requireUser(backend.tokenVerifier(), authorization);
|
||||
return json(Map.of("games", backend.history().getRecentForUser(user.id(), parseLimit(limit, 3))));
|
||||
}
|
||||
|
||||
private static Response json(Object body) {
|
||||
return Response.ok(body instanceof String text ? text : Json.write(body), MediaType.APPLICATION_JSON).build();
|
||||
}
|
||||
|
||||
// Like parseInt in the Node backend: the leading integer, clamped to 1..MAX_LIST_LIMIT.
|
||||
static int parseLimit(String value, int fallback) {
|
||||
Matcher match = LEADING_INTEGER.matcher(value == null ? "" : value);
|
||||
if (!match.find()) return fallback;
|
||||
BigInteger limit = new BigInteger(match.group(1));
|
||||
return limit.max(BigInteger.ONE).min(BigInteger.valueOf(GameHistoryService.MAX_LIST_LIMIT)).intValue();
|
||||
}
|
||||
}
|
||||
14
apps/backend/src/main/java/app/playmancala/live/Cache.java
Normal file
14
apps/backend/src/main/java/app/playmancala/live/Cache.java
Normal file
@ -0,0 +1,14 @@
|
||||
package app.playmancala.live;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value.
|
||||
public interface Cache {
|
||||
<T> Optional<T> get(String key, TypeReference<T> type);
|
||||
|
||||
void set(String key, Object value, long ttlSeconds);
|
||||
|
||||
void delete(String... keys);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package app.playmancala.live;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.model.GameRecord;
|
||||
|
||||
// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished
|
||||
// games that could not be saved to the database yet. Methods block and throw when Valkey is unreachable.
|
||||
public interface LiveGameStore {
|
||||
void saveGame(String gameId, Object snapshot);
|
||||
|
||||
void removeGame(String gameId);
|
||||
|
||||
// As stored; the caller checks what it can use.
|
||||
List<JsonNode> loadGames();
|
||||
|
||||
void savePendingRecord(GameRecord record);
|
||||
|
||||
void removePendingRecord(String gameId);
|
||||
|
||||
List<GameRecord> loadPendingRecords();
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package app.playmancala.live;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import app.playmancala.model.Json;
|
||||
import io.quarkus.redis.datasource.RedisDataSource;
|
||||
import io.quarkus.redis.datasource.value.SetArgs;
|
||||
import io.quarkus.redis.datasource.value.ValueCommands;
|
||||
|
||||
public class ValkeyCache implements Cache {
|
||||
private static final String PREFIX = "mancala:cache:";
|
||||
|
||||
private final RedisDataSource redis;
|
||||
private final ValueCommands<String, String> values;
|
||||
|
||||
public ValkeyCache(RedisDataSource redis) {
|
||||
this.redis = redis;
|
||||
this.values = redis.value(String.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Optional<T> get(String key, TypeReference<T> type) {
|
||||
try {
|
||||
String value = values.get(PREFIX + key);
|
||||
return value == null ? Optional.empty() : Optional.of(Json.MAPPER.readValue(value, type));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, Object value, long ttlSeconds) {
|
||||
try {
|
||||
values.set(PREFIX + key, Json.write(value), new SetArgs().ex(ttlSeconds));
|
||||
} catch (RuntimeException e) {
|
||||
// A missing cache entry only costs a database query.
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String... keys) {
|
||||
if (keys.length == 0) return;
|
||||
try {
|
||||
redis.key().del(Arrays.stream(keys).map(key -> PREFIX + key).toArray(String[]::new));
|
||||
} catch (RuntimeException e) {
|
||||
// Entries expire on their own.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package app.playmancala.live;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.Json;
|
||||
import io.quarkus.redis.datasource.RedisDataSource;
|
||||
import io.quarkus.redis.datasource.keys.KeyScanArgs;
|
||||
import io.quarkus.redis.datasource.value.ValueCommands;
|
||||
|
||||
// Keys and values are the Node backend's, so either backend reads what the other wrote.
|
||||
public class ValkeyLiveGameStore implements LiveGameStore {
|
||||
private static final Logger LOG = Logger.getLogger(ValkeyLiveGameStore.class);
|
||||
private static final String LIVE_PREFIX = "mancala:live:";
|
||||
private static final String PENDING_SAVE_PREFIX = "mancala:pending-save:";
|
||||
|
||||
private final RedisDataSource redis;
|
||||
private final ValueCommands<String, String> values;
|
||||
|
||||
public ValkeyLiveGameStore(RedisDataSource redis) {
|
||||
this.redis = redis;
|
||||
this.values = redis.value(String.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveGame(String gameId, Object snapshot) {
|
||||
values.set(LIVE_PREFIX + gameId, Json.write(snapshot));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeGame(String gameId) {
|
||||
redis.key().del(LIVE_PREFIX + gameId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<JsonNode> loadGames() {
|
||||
return loadAll(LIVE_PREFIX);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void savePendingRecord(GameRecord record) {
|
||||
values.set(PENDING_SAVE_PREFIX + record.id(), Json.write(record));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePendingRecord(String gameId) {
|
||||
redis.key().del(PENDING_SAVE_PREFIX + gameId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GameRecord> loadPendingRecords() {
|
||||
List<GameRecord> records = new ArrayList<>();
|
||||
for (JsonNode json : loadAll(PENDING_SAVE_PREFIX)) {
|
||||
try {
|
||||
records.add(Json.MAPPER.treeToValue(json, GameRecord.class));
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("(Valkey) Ignoring unreadable pending record %s", json.path("id").asText());
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private List<JsonNode> loadAll(String prefix) {
|
||||
// SCAN rather than KEYS, which would block Valkey.
|
||||
List<String> keys = new ArrayList<>();
|
||||
redis.key().scan(new KeyScanArgs().match(prefix + "*").count(100)).toIterable().forEach(keys::add);
|
||||
if (keys.isEmpty()) return List.of();
|
||||
Map<String, String> stored = values.mget(keys.toArray(String[]::new));
|
||||
List<JsonNode> parsed = new ArrayList<>();
|
||||
for (String key : keys) {
|
||||
String value = stored.get(key);
|
||||
if (value == null) continue;
|
||||
try {
|
||||
parsed.add(Json.parse(value));
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("(Valkey) Ignoring unreadable value in %s", key);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package app.playmancala.loop;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Cancellable {
|
||||
void cancel();
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package app.playmancala.loop;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
// The one thread that owns all live game state: games, matchmaking, previews and the WebSocket registry. Like the
|
||||
// Node backend's event loop, it lets that state go without locks. Never block it on I/O.
|
||||
public final class GameLoop implements Scheduler, AutoCloseable {
|
||||
private static final Logger LOG = Logger.getLogger(GameLoop.class);
|
||||
|
||||
private final ScheduledExecutorService executor;
|
||||
private volatile Thread thread;
|
||||
|
||||
public GameLoop() {
|
||||
executor = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread loopThread = new Thread(runnable, "game-loop");
|
||||
loopThread.setDaemon(true);
|
||||
thread = loopThread;
|
||||
return loopThread;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public long now() {
|
||||
return System.currentTimeMillis();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Runnable task) {
|
||||
executor.execute(guarded(task));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cancellable schedule(Runnable task, long delayMs) {
|
||||
Future<?> future = executor.schedule(guarded(task), delayMs, TimeUnit.MILLISECONDS);
|
||||
return () -> future.cancel(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) {
|
||||
Future<?> future = executor.scheduleAtFixedRate(guarded(task), periodMs, periodMs, TimeUnit.MILLISECONDS);
|
||||
return () -> future.cancel(false);
|
||||
}
|
||||
|
||||
// Runs the task on the loop and waits for its result; for threads that need to read live state.
|
||||
public <T> T call(Callable<T> task) {
|
||||
if (Thread.currentThread() == thread) {
|
||||
try {
|
||||
return task.call();
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return executor.submit(task).get();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while waiting for the game loop", e);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof RuntimeException runtime) throw runtime;
|
||||
throw new IllegalStateException(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
// An exception must not stop a repeating task or reach the executor, which would swallow it.
|
||||
private static Runnable guarded(Runnable task) {
|
||||
return () -> {
|
||||
try {
|
||||
task.run();
|
||||
} catch (Throwable e) {
|
||||
LOG.error("(GameLoop) Task failed", e);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package app.playmancala.loop;
|
||||
|
||||
// What the game logic needs from the game loop: running tasks and timers on the thread that owns the game state, and
|
||||
// the time. Tests use ManualScheduler, whose clock only moves when the test advances it.
|
||||
public interface Scheduler {
|
||||
// Epoch milliseconds.
|
||||
long now();
|
||||
|
||||
void execute(Runnable task);
|
||||
|
||||
Cancellable schedule(Runnable task, long delayMs);
|
||||
|
||||
// First run after one period.
|
||||
Cancellable scheduleAtFixedRate(Runnable task, long periodMs);
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
// A logged in user. `id` is also the player id in games; `name` is null for legacy userKey clients.
|
||||
public record AuthUser(
|
||||
String id,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
|
||||
@JsonProperty("isGuest") boolean isGuest) {
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
// WebSocket channel names; must match packages/core/src/rtmt/channel_names.ts.
|
||||
public final class Channels {
|
||||
public static final String NEW_GAME = "new_game";
|
||||
public static final String CANCEL_NEW_GAME = "cancel_new_game";
|
||||
public static final String ON_GAME_START = "on_game_start";
|
||||
public static final String GAME_MOVE = "game_move";
|
||||
public static final String ON_GAME_UPDATE = "on_game_update";
|
||||
public static final String LEAVE_GAME = "leave_game";
|
||||
public static final String ON_GAME_END = "on_game_end";
|
||||
public static final String ON_GAME_CRASHED = "on_game_crashed";
|
||||
public static final String ON_GAME_USER_LEAVE = "on_game_user_leave";
|
||||
public static final String PING = "ping";
|
||||
public static final String PONG = "pong";
|
||||
public static final String ON_USER_CONNECTION_CHANGE = "channel_on_user_connection_change";
|
||||
public static final String LISTEN_GAME_EVENTS = "channel_listen_game_events";
|
||||
public static final String UNLISTEN_GAME_EVENTS = "channel_unlisten_game_events";
|
||||
// Auth handshake. client -> server: { token } right after connecting and again after a token refresh;
|
||||
// other messages are ignored until the server answers on_auth with { user }.
|
||||
public static final String AUTH = "auth";
|
||||
public static final String ON_AUTH = "on_auth";
|
||||
// server -> client: { reason }, sent right before the server closes the socket with CLOSE_AUTH_FAILED.
|
||||
public static final String ON_AUTH_ERROR = "on_auth_error";
|
||||
public static final int CLOSE_AUTH_FAILED = 4001;
|
||||
// Home page previews. client -> server: subscribe_live_games / unsubscribe_live_games;
|
||||
// server -> client: on_live_games with { games: LiveGameSummary[] }.
|
||||
public static final String SUBSCRIBE_LIVE_GAMES = "subscribe_live_games";
|
||||
public static final String UNSUBSCRIBE_LIVE_GAMES = "unsubscribe_live_games";
|
||||
public static final String ON_LIVE_GAMES = "on_live_games";
|
||||
// server -> client: { gameId }, when new_game is sent while the user has a game in progress.
|
||||
public static final String ON_NEW_GAME_REJECTED = "on_new_game_rejected";
|
||||
|
||||
private Channels() {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
|
||||
// A live game as players and spectators receive it; leaves out spectator ids and other server-side state.
|
||||
// It refers to the live MancalaGame, so serialise it on the game loop.
|
||||
public record ClientGame(
|
||||
String id,
|
||||
MancalaGame mancalaGame,
|
||||
GameUsersConnectionInfo gameUsersConnectionInfo,
|
||||
long startedAt,
|
||||
List<Long> moveTimes,
|
||||
int spectatorCount,
|
||||
GameClock clock,
|
||||
long serverTime) {
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public enum EndReason {
|
||||
@JsonProperty("completed") COMPLETED,
|
||||
@JsonProperty("left") LEFT,
|
||||
@JsonProperty("timeout") TIMEOUT,
|
||||
// Only in games saved before the game clock.
|
||||
@JsonProperty("inactive") INACTIVE;
|
||||
|
||||
// The value stored in the games.end_reason column.
|
||||
public String wireName() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
|
||||
public static EndReason fromWireName(String value) {
|
||||
return valueOf(value.toUpperCase());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// A finished game in lists. `board` is the final board; `result` is null (and sent as null) without a winner.
|
||||
@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
|
||||
"moveCount", "peakSpectators", "clockInitialMs" })
|
||||
public record FinishedGameSummary(
|
||||
String id,
|
||||
PlayerSummary player1,
|
||||
PlayerSummary player2,
|
||||
List<Integer> board,
|
||||
GameResult result,
|
||||
EndReason endReason,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
|
||||
long startedAt,
|
||||
long endedAt,
|
||||
int moveCount,
|
||||
int peakSpectators,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs) {
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
// The players' clocks (docs/specs/game-clock): each player's time left at the start of the current turn, and when
|
||||
// that turn started. `turnStartedAt` is null when no clock runs, i.e. the game has ended.
|
||||
public record GameClock(
|
||||
long initialMs,
|
||||
long player1Ms,
|
||||
long player2Ms,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Long turnStartedAt) {
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// on_game_end, sent to the players and spectators of a live game when it ends for any reason. `result` is null (and
|
||||
// sent as null) when the game ended without a winner.
|
||||
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" })
|
||||
public record GameEndMessage(
|
||||
String gameId,
|
||||
GameResult result,
|
||||
EndReason endReason,
|
||||
GameClock clock,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String winnerId,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId) {
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it
|
||||
// is a public id.
|
||||
@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
|
||||
"moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" })
|
||||
public record GameRecord(
|
||||
String id,
|
||||
PlayerSummary player1,
|
||||
PlayerSummary player2,
|
||||
List<Integer> board,
|
||||
GameResult result,
|
||||
EndReason endReason,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
|
||||
long startedAt,
|
||||
long endedAt,
|
||||
int moveCount,
|
||||
int peakSpectators,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs,
|
||||
List<MoveRecord> moves,
|
||||
int totalSpectators) {
|
||||
|
||||
public FinishedGameSummary toSummary() {
|
||||
return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt,
|
||||
moveCount, peakSpectators, clockInitialMs);
|
||||
}
|
||||
|
||||
public boolean hasPlayer(String userId) {
|
||||
return player1.userId().equals(userId) || player2.userId().equals(userId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public enum GameResult {
|
||||
@JsonProperty("player1") PLAYER1,
|
||||
@JsonProperty("player2") PLAYER2,
|
||||
@JsonProperty("draw") DRAW;
|
||||
|
||||
// The value stored in the games.result column.
|
||||
public String wireName() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
|
||||
public static GameResult fromWireName(String value) {
|
||||
return value == null ? null : valueOf(value.toUpperCase());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
public record GameUsersConnectionInfo(UserConnectionInfo user1ConnectionInfo, UserConnectionInfo user2ConnectionInfo) {
|
||||
}
|
||||
33
apps/backend/src/main/java/app/playmancala/model/Json.java
Normal file
33
apps/backend/src/main/java/app/playmancala/model/Json.java
Normal file
@ -0,0 +1,33 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
// The JSON mapper for messages, snapshots, records and the cache. Unknown properties are ignored, so data written by
|
||||
// a newer or older backend can still be read.
|
||||
public final class Json {
|
||||
public static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
|
||||
private Json() {
|
||||
}
|
||||
|
||||
public static String write(Object value) {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Cannot serialise " + value.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonNode tree(Object value) {
|
||||
return MAPPER.valueToTree(value);
|
||||
}
|
||||
|
||||
// Throws JsonProcessingException for text that is not JSON.
|
||||
public static JsonNode parse(String text) throws JsonProcessingException {
|
||||
return MAPPER.readTree(text);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
// A live game for previews. Ids are public ids.
|
||||
public record LiveGameSummary(
|
||||
String id,
|
||||
PlayerSummary player1,
|
||||
PlayerSummary player2,
|
||||
List<Integer> board,
|
||||
String turnPlayerId,
|
||||
int spectatorCount,
|
||||
int moveCount,
|
||||
long startedAt) {
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import app.playmancala.engine.GameStep;
|
||||
|
||||
// A move of a saved game. `remainingMs` is the mover's clock after the move; null for games before the game clock.
|
||||
public record MoveRecord(
|
||||
String playerId,
|
||||
int moveIndex,
|
||||
List<Integer> boardSnapshot,
|
||||
List<GameStep> gameSteps,
|
||||
long at,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Long remainingMs) {
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
// A player as captured when a game is created. In saved games and lists, `userId` is a public id (PublicIds).
|
||||
public record PlayerSummary(
|
||||
String userId,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
|
||||
@JsonProperty("isGuest") boolean isGuest) {
|
||||
|
||||
public static PlayerSummary unknown(String userId) {
|
||||
return new PlayerSummary(userId, null, true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public enum Seat {
|
||||
@JsonProperty("player1") PLAYER1,
|
||||
@JsonProperty("player2") PLAYER2
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user