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

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

View File

@ -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

View File

@ -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

View File

@ -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
View File

@ -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

View File

@ -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/

View 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

View File

@ -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"
}
}
]
}

View File

@ -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"]

View File

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

View File

@ -1,4 +0,0 @@
rm -rf node_modules
yarn
yarn build
yarn start

View File

@ -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
View 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
View 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"

View File

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

View 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)}`);

View File

@ -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",
};
}

View File

@ -1 +0,0 @@
export type { AuthUser } from "@mancala/core";

View File

@ -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,
};
}
}

View File

@ -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>;
}

View File

@ -1,4 +0,0 @@
export * from "./AuthUser";
export * from "./AuthConfig";
export * from "./TokenVerifier";
export * from "./KeycloakTokenVerifier";

View File

@ -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.
}
}
}

View File

@ -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;
}

View File

@ -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 },
];

View File

@ -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();
}
}

View File

@ -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 };
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}
});
}
}

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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 }));
}
}

View File

@ -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[]
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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();
}
}
}

View File

@ -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);
}

View File

@ -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);
});

View File

@ -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;
}
}

View File

@ -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;
}

View 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);
}
}

View File

@ -0,0 +1,7 @@
package app.playmancala.auth;
public class InvalidTokenException extends Exception {
public InvalidTokenException(String message) {
super(message);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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));
}
}

View 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;
}
}

View 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";
};
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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) {
}
}

View File

@ -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
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View 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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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)));
}
}

View File

@ -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();
}
}
}

View File

@ -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)));
}
}

View File

@ -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();
}
}

View File

@ -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());
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View 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));
}
});
}
}

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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);
}
}
}

View File

@ -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;
});
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View 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);
}

View File

@ -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();
}

View File

@ -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.
}
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
package app.playmancala.loop;
@FunctionalInterface
public interface Cancellable {
void cancel();
}

View File

@ -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);
}
};
}
}

View File

@ -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);
}

View File

@ -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) {
}

View File

@ -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() {
}
}

View File

@ -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) {
}

View File

@ -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());
}
}

View File

@ -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) {
}

View File

@ -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) {
}

View File

@ -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) {
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,4 @@
package app.playmancala.model;
public record GameUsersConnectionInfo(UserConnectionInfo user1ConnectionInfo, UserConnectionInfo user2ConnectionInfo) {
}

View 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);
}
}

View File

@ -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) {
}

View File

@ -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) {
}

View File

@ -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);
}
}

View File

@ -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