Compare commits
No commits in common. "main" and "fix/stone-count-text" have entirely different histories.
main
...
fix/stone-
@ -1,8 +0,0 @@
|
|||||||
# Images for apps/backend and apps/frontend are built with the repo root as context.
|
|
||||||
**/node_modules
|
|
||||||
**/dist
|
|
||||||
**/target
|
|
||||||
**/.parcel-cache
|
|
||||||
.git
|
|
||||||
apps/mobile
|
|
||||||
apps/keycloak
|
|
||||||
154
AGENTS.md
154
AGENTS.md
@ -1,154 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
Guidance for coding agents working in this repository. Keep it current: when you learn something that would have
|
|
||||||
saved time, add it here.
|
|
||||||
|
|
||||||
## Project
|
|
||||||
|
|
||||||
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 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` | 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 |
|
|
||||||
| `apps/postgres` | Init script creating the `keycloak` and `mancala` databases |
|
|
||||||
| `apps/compose.yaml` | Postgres, Valkey, Keycloak, backend, frontend |
|
|
||||||
| `config/nginx.conf` | The production server's nginx config (Certbot managed) |
|
|
||||||
| `docs/specs/<feature>` | Specs: `requirements.md`, `design.md`, `tasks.md` |
|
|
||||||
| `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), `web-redesign` (the web app's look: themes, board,
|
|
||||||
pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus), `game-bot` (playing against a bot
|
|
||||||
at four levels), `tutorial` (rules page, lessons, hints in bot games), `rematch` (rematches, alternating first
|
|
||||||
moves, the series strip) and `theme-polish` (the login pages in the app's themes, the web app's messages).
|
|
||||||
|
|
||||||
## 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 && ./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)
|
|
||||||
```
|
|
||||||
|
|
||||||
Postgres is on `localhost:5433` (5432 is often taken), Valkey on `6379`, Keycloak on `8080`
|
|
||||||
(admin console `/admin`). The backend's and frontend's defaults point at these. The mobile app points at production
|
|
||||||
unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`.
|
|
||||||
|
|
||||||
## Checks
|
|
||||||
|
|
||||||
| What | Command | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| 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 && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services. The bot's calibration tournament is tagged `calibration` and skipped; see `apps/backend/README.md` |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
There is no automated end-to-end suite. Changes to the game flow were checked by driving Chrome with
|
|
||||||
`playwright-core` (installed outside the repo, e.g. `npm install playwright-core@1.48.2` in a scratch folder;
|
|
||||||
`executablePath: /usr/bin/google-chrome`) against the local stack. A live game is `GET /game/:id` on the backend,
|
|
||||||
which helps to check what the server holds.
|
|
||||||
|
|
||||||
## How we work
|
|
||||||
|
|
||||||
- The user writes in Turkish; code, comments, commit messages and specs are in English.
|
|
||||||
- Larger features start with a spec in `docs/specs/<feature>/` (requirements with acceptance criteria, design with
|
|
||||||
decisions and reasons, tasks as a checklist). Implement the tasks in order, one commit each, and tick them. Small
|
|
||||||
fixes need no spec. When resuming a feature, read its spec and `tasks.md` first.
|
|
||||||
- Work on feature branches; commit messages follow `type(scope): summary` with a short body.
|
|
||||||
- Never add AI attribution (Co-Authored-By lines, "Generated with …") to commits or PRs.
|
|
||||||
- Do not commit files the user changed but did not ask to commit; stage only your own changes (hunks if needed).
|
|
||||||
- New dependencies get exact versions.
|
|
||||||
|
|
||||||
## Things that are easy to get wrong
|
|
||||||
|
|
||||||
**Packages and builds**
|
|
||||||
- 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`.
|
|
||||||
`yarn build` in `apps/frontend` writes to the `dist` that `yarn dev` serves; running it while the dev server runs
|
|
||||||
can leave stale CSS or JS being served, so restart the dev server afterwards.
|
|
||||||
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 `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 `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).
|
|
||||||
- The bot plays as `bot:<level>` (`BotLevel.isBotId`) in many games at once: `GameStore` does not map it to a game,
|
|
||||||
nothing is sent to it, it is always online and `PublicIds` keeps its id. The bot's search must never run on the
|
|
||||||
game loop; `BotPlayer` runs it on its own pool and plays the move back on the loop.
|
|
||||||
- Rematch offers (`RematchOffers`) hook into `GameManager` like `BotPlayer` (`onGameEnded`, `onPlayerSearching`) and
|
|
||||||
live in memory only. A game's series travels with the live game (`BackendGame.series`, snapshots); the seat that
|
|
||||||
moved first is derived from the history (`firstSeat()`), not stored.
|
|
||||||
- `-Dtest=GameManagerTest` skips its `@Nested` classes; use `-Dtest='GameManagerTest*'`.
|
|
||||||
- `-Dgroups=...` runs only the tests with that tag; run the untagged tests separately too.
|
|
||||||
- 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
|
|
||||||
`map` callbacks need `.parent :global(.child)` rules.
|
|
||||||
- Pages are full-height flex columns: a block with `overflow: hidden` needs `flex-shrink: 0` or it gets squeezed.
|
|
||||||
- Colours come from CSS variables of the theme class on `<html>` (`styles/app.css`); the web app's themes
|
|
||||||
(`theme/themes.ts`) are passed to core's `ThemeManager`, which the mobile app uses with its own default themes.
|
|
||||||
- Styles that need container queries (`@container`, `cqw`), such as the board's, are plain `.css` files imported by the
|
|
||||||
component; styled-jsx 5.0 does not handle them. `BoardView` fills its container and stands upright below 600 px.
|
|
||||||
- `PitAnimator` sends the same `BoardViewModel` object, changed, at every animation step. Put a new object (or a new
|
|
||||||
id in its own state, as `GamePage` does) into React state, or React skips the update and only the first step shows.
|
|
||||||
- The rules, lessons and move explanations live in `packages/core/src/tutorial`; their boards after a move come from
|
|
||||||
the engine (`getMoveOutcome`). After a rule change, run core's tests: they fail if a lesson no longer works, and
|
|
||||||
the rule texts in `en.ts`/`tr.ts` may need updating.
|
|
||||||
- The game route renders `GamePage` with `key={gameId}`, so following a rematch to the new game's id remounts it.
|
|
||||||
- Short class names collide across the global CSS files (e.g. `.st` is a stone on the board); check before adding one.
|
|
||||||
- When a script drives the app in Chrome, wait for the header (`.hdr`) and a few seconds after the first load before
|
|
||||||
navigating; navigating during the guest login leaves an invalid token. Open the lobby with the "New Game" button,
|
|
||||||
not by URL, or the matchmaking message goes out before the WebSocket is open.
|
|
||||||
- The WebSocket client emits `open` after authentication; anything the server must know about a connection
|
|
||||||
(spectating a game, live game previews) is sent on `open`, because the server forgets it when a user goes offline.
|
|
||||||
|
|
||||||
**Keycloak**
|
|
||||||
- The realm file is imported only when the realm does not exist; changes to it must also be made in a running
|
|
||||||
Keycloak (or recreate the database).
|
|
||||||
- Listing some `requiredActions` in the realm file replaces Keycloak's whole default list, so the file contains all
|
|
||||||
of them.
|
|
||||||
- The guest authenticator uses an internal Keycloak SPI: keep the version in `extension/pom.xml` and `Dockerfile` in
|
|
||||||
sync and run `mvn test` when upgrading.
|
|
||||||
- Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not.
|
|
||||||
- The login theme (`apps/keycloak/themes/mancala`) is CSS and one script on top of `keycloak.v2`. The script takes the
|
|
||||||
theme from the `mancala_theme` cookie the web app writes (`saveThemeCookie`); the three themes' colors are copied
|
|
||||||
from `apps/frontend/src/styles/app.css`, so change both. Browsers cache theme files under a path that only changes
|
|
||||||
with Keycloak's version, for an hour (`KC_SPI_THEME__STATIC_MAX_AGE` in the Dockerfile); after a change, check in a
|
|
||||||
private window or rename the file. Keycloak in `start` mode caches themes and keeps gzipped
|
|
||||||
resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for reloading it in a running
|
|
||||||
container.
|
|
||||||
|
|
||||||
**Mobile**
|
|
||||||
- `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
The instructions for this repository are in AGENTS.md, shared by all coding agents:
|
|
||||||
|
|
||||||
@AGENTS.md
|
|
||||||
40
README.md
40
README.md
@ -1,43 +1,3 @@
|
|||||||
### Mancala
|
### Mancala
|
||||||
|
|
||||||
Mancala is an open source application for playing mancala game online.
|
Mancala is an open source application for playing mancala game online.
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
After changing a package, rebuild it (or run `yarn dev` in it to rebuild on change); the apps pick up the new `dist`.
|
|
||||||
|
|
||||||
Postgres (games and Keycloak), Valkey (live games and caches) and Keycloak run with Docker Compose. Create
|
|
||||||
`apps/.env` once, then start them:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd apps
|
|
||||||
cat > .env <<EOF
|
|
||||||
POSTGRES_PASSWORD=postgres
|
|
||||||
KEYCLOAK_DB_PASSWORD=keycloak
|
|
||||||
KEYCLOAK_ADMIN_PASSWORD=admin
|
|
||||||
MANCALA_DB_PASSWORD=mancala
|
|
||||||
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 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:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker build -f apps/backend/Dockerfile -t jhalitaksoy/mancala-backend .
|
|
||||||
docker build -f apps/frontend/Dockerfile -t jhalitaksoy/mancala-frontend .
|
|
||||||
```
|
|
||||||
|
|||||||
162
TODO.md
162
TODO.md
@ -1,162 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## Auth
|
|
||||||
|
|
||||||
### Mobile login with Keycloak (phase 5)
|
|
||||||
|
|
||||||
The web app logs in with Keycloak; the mobile app still uses the legacy `?userKey=` login.
|
|
||||||
|
|
||||||
- [ ] Add a `mancala-mobile` public client to `apps/keycloak/realm/mancala-realm.json` (PKCE S256, redirect
|
|
||||||
`com.mancala_mobile://callback`) and to `KEYCLOAK_CLIENT_IDS` for the backend
|
|
||||||
- [ ] Add `react-native-app-auth` with the redirect scheme set up for Android and iOS
|
|
||||||
- [ ] Log in as a guest on first launch (`guest=true`), with `offline_access`; keep tokens in MMKV
|
|
||||||
- [ ] Connect with `rtmt.connectWebSocket(url, getToken)` instead of `?userKey=` (`apps/mobile/src/App.tsx`)
|
|
||||||
and call `reauthenticate` after refreshing the token
|
|
||||||
- [ ] Log in / sign up / log out in the app, player names on the game screen (`getPlayerDisplayName` in core)
|
|
||||||
- [ ] Decide whether guests may skip the browser tab on first launch (would need a custom grant type in the
|
|
||||||
Keycloak extension)
|
|
||||||
- [ ] Delete the unused copy of the WebSocket client in `apps/mobile/src/rtmt`
|
|
||||||
- [ ] Test on a device
|
|
||||||
|
|
||||||
### Game history on mobile
|
|
||||||
|
|
||||||
The web app has game history (see `docs/specs/game-history`); the mobile app needs Keycloak login first.
|
|
||||||
|
|
||||||
- [ ] Ongoing-game banner, live and popular games, recent games on the home screen
|
|
||||||
- [ ] Move list and looking back during a live game; replay of finished games
|
|
||||||
|
|
||||||
### After the mobile release
|
|
||||||
|
|
||||||
- [ ] Turn off `LEGACY_USERKEY_AUTH` once old app versions are no longer in use; this also removes `/register`
|
|
||||||
|
|
||||||
### Later
|
|
||||||
|
|
||||||
- [ ] Let guests turn their guest user into an account without changing identity (Keycloak required action)
|
|
||||||
- [ ] Statistics for logged in users (wins, losses), now that games are saved
|
|
||||||
- [ ] Social logins (Keycloak identity providers)
|
|
||||||
- [ ] Keycloak email theme in the game's look (password reset emails still use Keycloak's)
|
|
||||||
- [ ] Restrict the Keycloak admin console in production
|
|
||||||
|
|
||||||
## Game history
|
|
||||||
|
|
||||||
- [ ] A page with all of a user's games (the home page shows the last 3)
|
|
||||||
- [ ] Show games of deleted accounts differently, once accounts can be deleted
|
|
||||||
- [ ] Live game state, previews and spectators assume a single backend instance; share them (e.g. Valkey pub/sub)
|
|
||||||
before running more than one
|
|
||||||
- [ ] If Valkey is unreachable when the backend starts, live games are not restored until the next restart
|
|
||||||
|
|
||||||
## Game clock
|
|
||||||
|
|
||||||
See `docs/specs/game-clock`.
|
|
||||||
|
|
||||||
- [ ] Other time controls or time added per move; a long move animates on the next player's time
|
|
||||||
- [ ] Clocks in the home page's live game previews and in the ongoing-game banner
|
|
||||||
- [ ] Try the mobile clocks on a device; only the type check and the bundle were run
|
|
||||||
|
|
||||||
## Game bot
|
|
||||||
|
|
||||||
See `docs/specs/game-bot`.
|
|
||||||
|
|
||||||
- [ ] Play against the bot without a connection (a bot in the TypeScript engine on the device)
|
|
||||||
- [ ] Keep bot games apart once there are statistics or ratings
|
|
||||||
- [ ] Hints or move analysis for players, with the same search
|
|
||||||
- [ ] Rebuild `MancalaGame` on `Position`, or use `Position` to check moves before running them (see Security)
|
|
||||||
- [ ] Tune the levels from real games (`games.bot` holds each game's level, strength and seed)
|
|
||||||
- [ ] Try the bot on a mobile device; only the type check and the bundle were run
|
|
||||||
|
|
||||||
## Tutorial
|
|
||||||
|
|
||||||
See `docs/specs/tutorial`.
|
|
||||||
|
|
||||||
- [ ] Lessons and hints in the mobile app, once it has the new design (it has the rules screen)
|
|
||||||
- [ ] Hints that look ahead, e.g. a warning when a move lets the opponent capture (the bot's search could do it)
|
|
||||||
- [ ] Strategy lessons beyond the rules
|
|
||||||
- [ ] Tutorial progress per account instead of per device
|
|
||||||
- [ ] If the end-of-game rule changes (Game rules below), update `RuleEndOfGameText2` in `en.ts` and `tr.ts`
|
|
||||||
|
|
||||||
## Web redesign
|
|
||||||
|
|
||||||
See `docs/specs/web-redesign`.
|
|
||||||
|
|
||||||
- [ ] Bring the new design to the mobile app (themes, board, game screen)
|
|
||||||
- [ ] The design's "Sound effects" switch in the settings menu, once the app has sounds
|
|
||||||
|
|
||||||
## Rematch
|
|
||||||
|
|
||||||
See `docs/specs/rematch`.
|
|
||||||
|
|
||||||
- [ ] Rematch and the series strip in the mobile app, with its new design; until then a web player's request to a
|
|
||||||
mobile player ends after a minute
|
|
||||||
- [ ] Series of a fixed length ("best of 3") and a series winner
|
|
||||||
- [ ] Group series in the game history and on the home page (`games.series_id`)
|
|
||||||
- [ ] Take spectators along to the next game of a series
|
|
||||||
- [ ] Leaving the game page does not answer a request; the opponent waits for the minute to pass
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
- [ ] Document the deployment (e.g. `docs/deployment.md`, or a `Jenkinsfile` in the repo). Today it is a Jenkins
|
|
||||||
job at `ci.playmancala.app` whose shell steps live only in Jenkins; it still builds from `apps/backend` and
|
|
||||||
`apps/frontend` and runs `cd apps && docker compose up -d`. For the Java backend it needs:
|
|
||||||
- `docker build -f apps/backend/Dockerfile -t jhalitaksoy/mancala-backend:latest .` from the repo root
|
|
||||||
- `docker build -f apps/frontend/Dockerfile` with `--build-arg` `SERVER_ADDRESS=https://api.playmancala.app`,
|
|
||||||
`WS_SERVER_ADDRESS=wss://api.playmancala.app`, `KEYCLOAK_URL=https://auth.playmancala.app`
|
|
||||||
- `docker build -t jhalitaksoy/mancala-keycloak:latest apps/keycloak`
|
|
||||||
- `docker compose --env-file <path>/mancala.env up -d` in `apps`, with the env file kept outside the Jenkins
|
|
||||||
workspace (`POSTGRES_PASSWORD`, `KEYCLOAK_DB_PASSWORD`, `MANCALA_DB_PASSWORD`, `KEYCLOAK_ADMIN_USERNAME`,
|
|
||||||
`KEYCLOAK_ADMIN_PASSWORD`, `KEYCLOAK_HOSTNAME=https://auth.playmancala.app`, `LEGACY_USERKEY_AUTH`)
|
|
||||||
- Once, by hand: the `auth.playmancala.app` block of `config/nginx.conf` on the server and an nginx reload.
|
|
||||||
DNS and the certificate for `auth.playmancala.app` already exist
|
|
||||||
- If Jenkins runs in a container with the host's Docker socket, the `./postgres/init` bind mount points at a
|
|
||||||
path the host does not have and the databases are not created; check before the first deploy
|
|
||||||
- Which branch the job builds, and whether it builds on push
|
|
||||||
- [ ] Build the backend and frontend images from the repo root in CI:
|
|
||||||
`docker build -f apps/backend/Dockerfile .` and `docker build -f apps/frontend/Dockerfile .`
|
|
||||||
- [ ] Pass `--build-arg KEYCLOAK_URL=https://auth.playmancala.app` when building the frontend image
|
|
||||||
- [ ] 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`
|
|
||||||
- [ ] Set `MANCALA_DB_PASSWORD` and `POSTGRES_PASSWORD` in `apps/.env` on the server; the `postgres` volume replaces
|
|
||||||
`keycloak-db`, so Keycloak starts with a fresh database there
|
|
||||||
- [ ] Last step: publish a new `mancala.js` to npm (it is at `0.0.2-beta.3` from 2022), so the published engine skips
|
|
||||||
the opponent's store when sowing. The apps do not need it; they use `packages/mancala.js` through `link:`
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- [ ] Move the Android signing passwords out of `apps/mobile/release_android.sh` (committed in `bb1c750`) into
|
|
||||||
`~/.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`, kept as it was in the Node
|
|
||||||
backend)
|
|
||||||
|
|
||||||
## Game rules
|
|
||||||
|
|
||||||
- [ ] Decide who gets the remaining stones when a move empties the opponent's side (e.g. a capture takes their last
|
|
||||||
stones). Now the player whose side is empty always gets the other side's stones (`GRClearBoardAtEnd`), even
|
|
||||||
when the opponent emptied it; some Mangala rules give them only to a player who emptied their own side.
|
|
||||||
Analyse the rule first, then decide
|
|
||||||
|
|
||||||
## Bugs
|
|
||||||
|
|
||||||
- [ ] `PitAnimator.dispose` removes a newly bound function, so the theme listener is never removed
|
|
||||||
- [ ] `onGameError` broadcasts `{}` instead of the error (the Java backend sends `{}` on purpose, as the Node backend
|
|
||||||
did)
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
- [ ] The web and mobile apps duplicate components and stores; move shared code into `packages/core`
|
|
||||||
- [ ] 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 (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: 1 in the frontend (`react-dom/client`), 11 in mobile; the frontend has no
|
|
||||||
`tsconfig.json`, so nothing type-checks it
|
|
||||||
1
apps/.gitignore
vendored
1
apps/.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
.env
|
|
||||||
1
apps/backend/.dockerignore
Normal file
1
apps/backend/.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
43
apps/backend/.gitignore
vendored
43
apps/backend/.gitignore
vendored
@ -1,9 +1,40 @@
|
|||||||
target/
|
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
|
||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
.vscode
|
|
||||||
.factorypath
|
|
||||||
|
|
||||||
# Written at runtime
|
# OS metadata
|
||||||
access*.log
|
.DS_Store
|
||||||
crashes/
|
Thumbs.db
|
||||||
|
|
||||||
|
# Ignore built ts files
|
||||||
|
dist/**/*
|
||||||
|
|
||||||
|
crashes
|
||||||
@ -1,4 +0,0 @@
|
|||||||
wrapperVersion=3.3.4
|
|
||||||
distributionType=only-script
|
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
|
||||||
distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce
|
|
||||||
25
apps/backend/.vscode/launch.json
vendored
Normal file
25
apps/backend/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Launch Program",
|
||||||
|
"program": "${workspaceFolder}/src/index.ts",
|
||||||
|
"preLaunchTask": "tsc: build - tsconfig.json",
|
||||||
|
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Jest Current File",
|
||||||
|
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||||
|
"args": ["${relativeFile}"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen",
|
||||||
|
"windows": {
|
||||||
|
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,29 +1,19 @@
|
|||||||
# 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
|
ENV PORT=6000
|
||||||
|
|
||||||
# The app writes access.log and crashes/ to its working directory.
|
# Create app directory
|
||||||
RUN useradd --system --create-home mancala && install -d -o mancala /home/mancala/app
|
WORKDIR /usr/src/app
|
||||||
WORKDIR /home/mancala/app
|
|
||||||
COPY --from=build --chown=mancala /build/target/quarkus-app/ ./
|
COPY package.json package.json
|
||||||
USER mancala
|
COPY yarn.lock yarn.lock
|
||||||
|
|
||||||
|
RUN yarn
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
CMD ["java", "-jar", "quarkus-run.jar"]
|
CMD [ "yarn", "start"]
|
||||||
@ -1,123 +1,28 @@
|
|||||||
# Mancala Backend
|
# Mancala Backend
|
||||||
|
|
||||||
The game server: matchmaking, live games over a WebSocket, saved games and a small HTTP API. Java 25 and Quarkus 3.40,
|
This is the backend code for the Mancala project, written in Express.js.
|
||||||
built with Maven. The design and the reasons for it are in `docs/specs/quarkus-backend`.
|
|
||||||
|
|
||||||
## Getting started
|
## Getting Started
|
||||||
|
|
||||||
You need a JDK 25 (e.g. Temurin); Maven comes with the wrapper. Postgres, Valkey and Keycloak run with Docker Compose,
|
### Install Packages
|
||||||
see the README in the repo root.
|
|
||||||
|
|
||||||
```sh
|
```bash
|
||||||
./mvnw quarkus:dev # http://localhost:5005; a debugger can attach on 5006
|
yarn install
|
||||||
./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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker image is built from the repo root: `docker build -f apps/backend/Dockerfile .`. It listens on port 6000.
|
### Start in Development Mode
|
||||||
|
|
||||||
## Code
|
```bash
|
||||||
|
yarn dev
|
||||||
| Package | What |
|
|
||||||
|---|---|
|
|
||||||
| `engine` | The game rules, a port of `packages/mancala.js`; `Position`, a compact board for searching moves |
|
|
||||||
| `bot` | The bot: levels, strength, the search and `BotPlayer`, which plays its turns in live games |
|
|
||||||
| `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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authentication
|
### Start in Release Mode
|
||||||
|
|
||||||
Clients log in with Keycloak (see `apps/keycloak`), either as a guest or with an account, and send the access token
|
```bash
|
||||||
over the WebSocket right after connecting:
|
yarn build
|
||||||
|
yarn start
|
||||||
```json
|
|
||||||
{ "channel": "auth", "message": { "token": "<Keycloak access token>" } }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The server answers on `on_auth` with the user (`{ id, name, isGuest }`) or on `on_auth_error` and closes the socket
|
### Development Dependencies
|
||||||
with code 4001. Other messages are ignored until the socket is authenticated, and connections that do not
|
|
||||||
authenticate within 5 seconds are closed. Send `auth` again with the new token after refreshing it.
|
|
||||||
User ids of Keycloak users are the token subject prefixed with `kc:`.
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions.
|
||||||
|---|---|---|
|
|
||||||
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/mancala` | Token issuer; must match the `iss` claim exactly |
|
|
||||||
| `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
|
|
||||||
|
|
||||||
Finished games are saved in Postgres; live games are copied to Valkey after every change and restored when the
|
|
||||||
backend starts, so a restart or deploy does not end them. The design is in `docs/specs/game-history`.
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `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. 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
|
|
||||||
|
|
||||||
Each player has a clock for the whole game; only the clock of the player whose turn it is runs, also while they are
|
|
||||||
offline. A move that arrives after the mover's time has run out is refused, and `GameClockScheduler` ends games whose
|
|
||||||
running clock reaches zero once a second, so the opponent wins on time even if nobody is connected. Time while the
|
|
||||||
backend is down is not charged. The design is in `docs/specs/game-clock`.
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `GAME_CLOCK_MS` | `300000` | Time each player has for a game; a short value such as `40000` helps when testing by hand |
|
|
||||||
|
|
||||||
## Bot
|
|
||||||
|
|
||||||
Players can play against a bot at four levels (`new_bot_game` with `{ level }`); the lobby offers it after 10 seconds
|
|
||||||
without an opponent. Each level is a range of strengths, and the bot draws one per game, so games at the same level
|
|
||||||
differ. It plays as `bot:<level>` through the same game logic as players, with the clock, saving and spectators, and
|
|
||||||
waits for the previous move's animation and a thinking time before moving. Its moves are worked out with alpha-beta
|
|
||||||
search on `Position` on a thread pool of their own, never on the game loop. The design is in `docs/specs/game-bot`.
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `BOT_SEARCH_THREADS` | half the processors | Threads working out the bot's moves |
|
|
||||||
|
|
||||||
After changing the search or the strength table, check that every level still beats the one below:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./mvnw test -Dtest=BotTournamentTest -Dgroups=calibration -DexcludedGroups=none
|
|
||||||
```
|
|
||||||
|
|
||||||
| Route | Auth | Response |
|
|
||||||
|---|---|---|
|
|
||||||
| `GET /game/:id` | – | A live game, or `{ status: "finished", record }` |
|
|
||||||
| `GET /games/popular?limit=5` | – | `{ kind: "live" \| "finished", games }` |
|
|
||||||
| `GET /games/me/active` | Bearer token | `{ game }` or `{ game: null }` |
|
|
||||||
| `GET /games/me?limit=3` | Bearer token | `{ games }`, newest first |
|
|
||||||
|
|
||||||
Requests are logged to `access.log`, and a move that fails writes the game to `crashes/`, both in the working
|
|
||||||
directory.
|
|
||||||
4
apps/backend/build_and_deploy.sh
Normal file
4
apps/backend/build_and_deploy.sh
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
rm -rf node_modules
|
||||||
|
yarn
|
||||||
|
yarn build
|
||||||
|
yarn start
|
||||||
6
apps/backend/jest.config.js
Normal file
6
apps/backend/jest.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
transform: {'^.+\\.ts?$': 'ts-jest'},
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node']
|
||||||
|
};
|
||||||
295
apps/backend/mvnw
vendored
295
apps/backend/mvnw
vendored
@ -1,295 +0,0 @@
|
|||||||
#!/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
189
apps/backend/mvnw.cmd
vendored
@ -1,189 +0,0 @@
|
|||||||
<# : 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"
|
|
||||||
39
apps/backend/package.json
Normal file
39
apps/backend/package.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"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": "0.0.2",
|
||||||
|
"@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",
|
||||||
|
"mancala.js": "^0.0.2-beta.3",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"uuid": "^8.3.2",
|
||||||
|
"ws": "^7.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.12",
|
||||||
|
"@types/jest": "^26.0.23",
|
||||||
|
"@types/node": "^15.12.4",
|
||||||
|
"jest": "^27.0.5",
|
||||||
|
"nodemon": "^2.0.7",
|
||||||
|
"ts-jest": "^27.0.3",
|
||||||
|
"ts-node": "^10.0.0",
|
||||||
|
"typescript": "^4.7.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,115 +0,0 @@
|
|||||||
<?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>
|
|
||||||
<!-- Slow bot calibration games; run them with -Dgroups=calibration -DexcludedGroups=none. -->
|
|
||||||
<excludedGroups>calibration</excludedGroups>
|
|
||||||
</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>
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
// Plays seeded random games with the TypeScript engine (packages/mancala.js) and writes every move and its outcome,
|
|
||||||
// so EngineParityTest can check that the Java engine plays exactly the same. Run it again after changing the rules:
|
|
||||||
//
|
|
||||||
// yarn --cwd ../../packages/mancala.js build && node scripts/record-engine-games.js
|
|
||||||
//
|
|
||||||
// Moves include illegal ones (the wrong player, an empty pit, a store, out of range) and moves after the end.
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const { CommonMancalaGame } = require("../../../packages/mancala.js");
|
|
||||||
|
|
||||||
const GAME_COUNT = 15;
|
|
||||||
const MAX_MOVES = 400;
|
|
||||||
const OUTPUT = path.join(__dirname, "../src/test/resources/engine/recorded-games.json");
|
|
||||||
|
|
||||||
// mulberry32
|
|
||||||
function random(seed) {
|
|
||||||
return () => {
|
|
||||||
seed |= 0;
|
|
||||||
seed = (seed + 0x6d2b79f5) | 0;
|
|
||||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
||||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function recordGame(number) {
|
|
||||||
const next = random(number + 1);
|
|
||||||
const pick = (items) => items[Math.floor(next() * items.length)];
|
|
||||||
const game = new CommonMancalaGame(`game-${number}`, `kc:player1-${number}`, `kc:player2-${number}`);
|
|
||||||
game.turnPlayerId = next() < 0.5 ? game.player1Id : game.player2Id;
|
|
||||||
const recorded = {
|
|
||||||
id: game.id, player1Id: game.player1Id, player2Id: game.player2Id, turnPlayerId: game.turnPlayerId, moves: [],
|
|
||||||
};
|
|
||||||
let movesAfterEnd = 0;
|
|
||||||
for (let i = 0; i < MAX_MOVES && movesAfterEnd < 2; i++) {
|
|
||||||
const roll = next();
|
|
||||||
const opponent = game.turnPlayerId === game.player1Id ? game.player2Id : game.player1Id;
|
|
||||||
const playerId = roll < 0.93 ? game.turnPlayerId : opponent;
|
|
||||||
const pitsWithStones = [0, 1, 2, 3, 4, 5].filter((pit) =>
|
|
||||||
game.board.pits[game.getBoardIndexByPlayerId(playerId, pit)].stoneCount > 0);
|
|
||||||
const pitIndex = roll < 0.85 && pitsWithStones.length > 0 ? pick(pitsWithStones)
|
|
||||||
: roll < 0.97 ? Math.floor(next() * 6) : pick([-1, 6, 7, 13, 14]);
|
|
||||||
const move = { playerId, pitIndex };
|
|
||||||
try {
|
|
||||||
game.moveByPlayerPit(playerId, pitIndex);
|
|
||||||
} catch (err) {
|
|
||||||
move.error = err.message;
|
|
||||||
}
|
|
||||||
Object.assign(move, { board: game.board.getStoneArray(), turnPlayerId: game.turnPlayerId, state: game.state });
|
|
||||||
recorded.moves.push(move);
|
|
||||||
if (game.state === "ended") movesAfterEnd++;
|
|
||||||
}
|
|
||||||
recorded.wonPlayerId = game.getWonPlayerId() ?? null;
|
|
||||||
recorded.game = JSON.parse(JSON.stringify(game));
|
|
||||||
return recorded;
|
|
||||||
}
|
|
||||||
|
|
||||||
const games = Array.from({ length: GAME_COUNT }, (_, number) => recordGame(number));
|
|
||||||
fs.writeFileSync(OUTPUT, JSON.stringify({ games }) + "\n");
|
|
||||||
const moves = games.reduce((sum, game) => sum + game.moves.length, 0);
|
|
||||||
console.log(`Recorded ${games.length} games, ${moves} moves, to ${path.relative(process.cwd(), OUTPUT)}`);
|
|
||||||
1
apps/backend/src/consts/config.ts
Normal file
1
apps/backend/src/consts/config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const RTMT_WS_PING_INTERVAL = 2000;
|
||||||
38
apps/backend/src/game/GameCrashManager.ts
Normal file
38
apps/backend/src/game/GameCrashManager.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
208
apps/backend/src/game/GameManager.ts
Normal file
208
apps/backend/src/game/GameManager.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { CommonMancalaGame, MancalaGame } 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_start,
|
||||||
|
channel_on_game_update,
|
||||||
|
channel_on_game_user_leave,
|
||||||
|
channel_on_user_connection_change,
|
||||||
|
channel_unlisten_game_events
|
||||||
|
} from "@mancala/core";
|
||||||
|
import { GameMove } from "@mancala/core";
|
||||||
|
import { GameCrashManager } from "./GameCrashManager";
|
||||||
|
import { MatchMaker } from "../matchmaker/MatchMaker";
|
||||||
|
import { GameUsersConnectionInfo } from "@mancala/core";
|
||||||
|
import { BackendGame } from "../models/Game";
|
||||||
|
|
||||||
|
export class GameManager {
|
||||||
|
gameStore: GameStore;
|
||||||
|
rtmt: RTMT;
|
||||||
|
matchMaker: MatchMaker;
|
||||||
|
|
||||||
|
constructor(params: { gameStore: GameStore, rtmt: RTMT, matchMaker: MatchMaker }) {
|
||||||
|
this.gameStore = params.gameStore;
|
||||||
|
this.rtmt = params.rtmt;
|
||||||
|
this.matchMaker = params.matchMaker;
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize() {
|
||||||
|
this.listenOnPlayersPaired();
|
||||||
|
this.listenRtmtMessages();
|
||||||
|
this.listenUserConnectionChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private listenRtmtMessages() {
|
||||||
|
this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => {
|
||||||
|
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 {
|
||||||
|
game.mancalaGame.moveByPlayerPit(userKey, gameMove.index);
|
||||||
|
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
||||||
|
this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, game);
|
||||||
|
if (game.mancalaGame.state == "ended") {
|
||||||
|
this.onGameEnd(game);
|
||||||
|
}
|
||||||
|
} 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.deleteGame(game);
|
||||||
|
this.sendMessageToPlayersAndSpectators(game, channel_on_game_user_leave, userKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkUserIdisPlayer(game: BackendGame, userId: string): boolean {
|
||||||
|
return game.mancalaGame.player1Id === userId || game.mancalaGame.player2Id === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private onGameEnd(game: BackendGame) {
|
||||||
|
this.deleteGame(game);
|
||||||
|
game.spectatorIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private listenOnPlayersPaired() {
|
||||||
|
this.matchMaker.listenOnPlayersPaired((player1Id: string, player2Id: string) => {
|
||||||
|
const game = this.createMancalaGame(player1Id, player2Id);
|
||||||
|
this.startGame(game);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public fireOnPlayerConnected(playerId: string) { }
|
||||||
|
|
||||||
|
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 game: BackendGame = {
|
||||||
|
id: mancalaGame.id,
|
||||||
|
mancalaGame,
|
||||||
|
gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromUsers(mancalaGame.player1Id, mancalaGame.player2Id),
|
||||||
|
spectatorIds: []
|
||||||
|
};
|
||||||
|
|
||||||
|
this.gameStore.set(game.id, game);
|
||||||
|
this.gameStore.setGameByUser(userKey1, game);
|
||||||
|
this.gameStore.setGameByUser(userKey2, game);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo {
|
||||||
|
return this.getGameUsersConnectionInfoFromUsers(game.mancalaGame.player1Id, game.mancalaGame.player2Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getGameUsersConnectionInfoFromUsers(user1Id: string, user2Id: string): GameUsersConnectionInfo {
|
||||||
|
const isPlayer1Online = this.rtmt.isClientOnline(user1Id);
|
||||||
|
const isPlayer2Online = this.rtmt.isClientOnline(user2Id);
|
||||||
|
return {
|
||||||
|
user1ConnectionInfo: { userId: user1Id, isOnline: isPlayer1Online },
|
||||||
|
user2ConnectionInfo: { userId: user2Id, isOnline: isPlayer2Online }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public startGame(game: BackendGame) {
|
||||||
|
this.sendMessageToPlayersAndSpectators(game, channel_on_game_start, game);
|
||||||
|
this.sendUserConnectionInfo(game);
|
||||||
|
this.sendUserConnectionInfo(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendUserConnectionInfo(game: BackendGame) {
|
||||||
|
const gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
||||||
|
this.sendMessageToPlayersAndSpectators(game, channel_on_user_connection_change, gameUsersConnectionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteGame(game: BackendGame) {
|
||||||
|
if (game) {
|
||||||
|
this.gameStore.removeGameByPlayer(game.mancalaGame.player1Id);
|
||||||
|
this.gameStore.removeGameByPlayer(game.mancalaGame.player2Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, game);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/backend/src/game/gamestore/GameStore.ts
Normal file
18
apps/backend/src/game/gamestore/GameStore.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Game } from "@mancala/core";
|
||||||
|
import { BackendGame } from "../../models/Game";
|
||||||
|
|
||||||
|
export interface GameStore {
|
||||||
|
get(id: string): BackendGame | undefined;
|
||||||
|
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[]
|
||||||
|
}
|
||||||
48
apps/backend/src/game/gamestore/GameStoreImpl.ts
Normal file
48
apps/backend/src/game/gamestore/GameStoreImpl.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
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 gameId in this.gameStore.keys()) {
|
||||||
|
const game = this.gameStore.get(gameId) as BackendGame;
|
||||||
|
const isSpectator = game.spectatorIds.find((value) => value === userId);
|
||||||
|
isSpectator && speactatingGameIds.push(game.id);
|
||||||
|
}
|
||||||
|
return speactatingGameIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/backend/src/index.ts
Normal file
17
apps/backend/src/index.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
const rtmt = new RTMTWS();
|
||||||
|
const gameStore = new GameStoreImpl();
|
||||||
|
const matchMaker = new MatchMakerImpl({ rtmt });
|
||||||
|
const gameManager = new GameManager({ rtmt, gameStore, matchMaker })
|
||||||
|
|
||||||
|
const expressApp = new ExpressApp({ gameStore });
|
||||||
|
const server = new WebServer({ expressApp: expressApp.app });
|
||||||
|
|
||||||
|
rtmt.initWebSocket(server.server, (userKey: string) => gameManager.fireOnPlayerConnected(userKey));
|
||||||
@ -1,202 +0,0 @@
|
|||||||
package app.playmancala;
|
|
||||||
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.SplittableRandom;
|
|
||||||
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.bot.BotPlayer;
|
|
||||||
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.game.RematchOffers;
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 0: half the processors.
|
|
||||||
@ConfigProperty(name = "mancala.bot-search-threads")
|
|
||||||
int botSearchThreads;
|
|
||||||
|
|
||||||
private GameLoop loop;
|
|
||||||
// Valkey writes, in order.
|
|
||||||
private ExecutorService storeWriter;
|
|
||||||
// Migrations and saving finished games.
|
|
||||||
private ScheduledExecutorService recorderIo;
|
|
||||||
// Token checks.
|
|
||||||
private ExecutorService verifier;
|
|
||||||
// Working out the bot's moves.
|
|
||||||
private ExecutorService botSearch;
|
|
||||||
|
|
||||||
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();
|
|
||||||
int searchThreads = botSearchThreads > 0 ? botSearchThreads
|
|
||||||
: Math.max(1, Runtime.getRuntime().availableProcessors() / 2);
|
|
||||||
botSearch = Executors.newFixedThreadPool(searchThreads,
|
|
||||||
Thread.ofPlatform().name("bot-search-", 0).daemon().factory());
|
|
||||||
|
|
||||||
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);
|
|
||||||
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
|
|
||||||
new RematchOffers(gameManager, gameStore, rtmt, loop, new SplittableRandom());
|
|
||||||
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();
|
|
||||||
botSearch.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
package app.playmancala.auth;
|
|
||||||
|
|
||||||
public class InvalidTokenException extends Exception {
|
|
||||||
public InvalidTokenException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
package app.playmancala.bot;
|
|
||||||
|
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonValue;
|
|
||||||
|
|
||||||
// The levels players choose from. Each is a range of strengths (0-100); the bot draws its strength for a game from
|
|
||||||
// it. The ranges leave gaps so neighbouring levels stay clearly apart.
|
|
||||||
public enum BotLevel {
|
|
||||||
EASY("easy", 5, 25, 800, 1800),
|
|
||||||
MEDIUM("medium", 30, 55, 1000, 2200),
|
|
||||||
HARD("hard", 60, 80, 1200, 2600),
|
|
||||||
MASTER("master", 85, 100, 1400, 3000);
|
|
||||||
|
|
||||||
private static final String BOT_ID_PREFIX = "bot:";
|
|
||||||
|
|
||||||
private final String wireName;
|
|
||||||
private final int minStrength;
|
|
||||||
private final int maxStrength;
|
|
||||||
// How long the bot seems to think before a move, on top of the previous move's animation.
|
|
||||||
private final int minThinkMs;
|
|
||||||
private final int maxThinkMs;
|
|
||||||
|
|
||||||
BotLevel(String wireName, int minStrength, int maxStrength, int minThinkMs, int maxThinkMs) {
|
|
||||||
this.wireName = wireName;
|
|
||||||
this.minStrength = minStrength;
|
|
||||||
this.maxStrength = maxStrength;
|
|
||||||
this.minThinkMs = minThinkMs;
|
|
||||||
this.maxThinkMs = maxThinkMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonValue
|
|
||||||
public String wireName() {
|
|
||||||
return wireName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int minStrength() {
|
|
||||||
return minStrength;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int maxStrength() {
|
|
||||||
return maxStrength;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int middleStrength() {
|
|
||||||
return (minStrength + maxStrength) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int drawStrength(RandomGenerator random) {
|
|
||||||
return random.nextInt(minStrength, maxStrength + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public long drawThinkMs(RandomGenerator random) {
|
|
||||||
return random.nextLong(minThinkMs, maxThinkMs + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user id the bot plays with at this level, e.g. bot:medium.
|
|
||||||
public String botId() {
|
|
||||||
return BOT_ID_PREFIX + wireName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The level from its wire name, or null.
|
|
||||||
@JsonCreator
|
|
||||||
public static BotLevel fromWireName(String wireName) {
|
|
||||||
for (BotLevel level : values()) {
|
|
||||||
if (level.wireName.equals(wireName)) return level;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isBotId(String userId) {
|
|
||||||
return userId != null && userId.startsWith(BOT_ID_PREFIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The level of a bot id, or null if it is not one.
|
|
||||||
public static BotLevel fromBotId(String userId) {
|
|
||||||
return isBotId(userId) ? fromWireName(userId.substring(BOT_ID_PREFIX.length())) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
package app.playmancala.bot;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.IdentityHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.Executor;
|
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
|
|
||||||
import app.playmancala.engine.GameState;
|
|
||||||
import app.playmancala.engine.GameStep;
|
|
||||||
import app.playmancala.engine.MancalaGame;
|
|
||||||
import app.playmancala.engine.MoveHistoryItem;
|
|
||||||
import app.playmancala.engine.Position;
|
|
||||||
import app.playmancala.game.BackendGame;
|
|
||||||
import app.playmancala.game.GameClocks;
|
|
||||||
import app.playmancala.game.GameManager;
|
|
||||||
import app.playmancala.game.GameStore;
|
|
||||||
import app.playmancala.loop.Scheduler;
|
|
||||||
import app.playmancala.model.Seat;
|
|
||||||
|
|
||||||
// Plays the bot's turns in live games. The move is worked out on `searchExecutor`, never on the game loop; it is
|
|
||||||
// played on the loop once the previous move's animation and the bot's thinking time are over, if the game is still
|
|
||||||
// waiting for it. Everything except the search runs on the loop.
|
|
||||||
public class BotPlayer {
|
|
||||||
private static final Logger LOG = Logger.getLogger(BotPlayer.class);
|
|
||||||
// PitAnimator's DEFAULT_ANIMATION_INTERVAL in packages/core: clients animate a move one step at a time.
|
|
||||||
static final long ANIMATION_STEP_MS = 300;
|
|
||||||
// The game page needs a moment to open before the bot's first move.
|
|
||||||
static final long GAME_START_DELAY_MS = 1_500;
|
|
||||||
// With less time than this on its clock, the bot stops pretending to think.
|
|
||||||
static final long LOW_TIME_MS = 30_000;
|
|
||||||
static final long LOW_TIME_THINK_MS = 300;
|
|
||||||
|
|
||||||
private final GameManager gameManager;
|
|
||||||
private final GameStore gameStore;
|
|
||||||
private final Scheduler loop;
|
|
||||||
private final Executor searchExecutor;
|
|
||||||
private final RandomGenerator random;
|
|
||||||
// Games whose bot move is being worked out; by identity, since a restored game is a new object with the same id.
|
|
||||||
private final Set<BackendGame> pending = Collections.newSetFromMap(new IdentityHashMap<>());
|
|
||||||
|
|
||||||
public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor,
|
|
||||||
RandomGenerator random) {
|
|
||||||
this.gameManager = gameManager;
|
|
||||||
this.gameStore = gameStore;
|
|
||||||
this.loop = loop;
|
|
||||||
this.searchExecutor = searchExecutor;
|
|
||||||
this.random = random;
|
|
||||||
gameManager.onTurnStarted(this::onTurnStarted);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onTurnStarted(BackendGame game) {
|
|
||||||
if (!game.isBotTurn() || game.mancalaGame.getState() == GameState.ENDED || !pending.add(game)) return;
|
|
||||||
Position position = Position.of(game.mancalaGame);
|
|
||||||
int moveNumber = game.mancalaGame.getHistory().size();
|
|
||||||
BotSettings bot = game.bot;
|
|
||||||
long readyAt = loop.now() + delayMs(game);
|
|
||||||
searchExecutor.execute(() -> {
|
|
||||||
int pit = choose(position, bot, moveNumber);
|
|
||||||
loop.execute(() -> loop.schedule(() -> play(game, moveNumber, pit), Math.max(0, readyAt - loop.now())));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int choose(Position position, BotSettings bot, int moveNumber) {
|
|
||||||
try {
|
|
||||||
return BotPolicy.chooseMove(position, bot, moveNumber);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// A failed search must not leave the game waiting: play any move.
|
|
||||||
LOG.errorf(e, "(BotPlayer) Search failed in %s", position);
|
|
||||||
int[] pits = new int[Position.PIT_COUNT];
|
|
||||||
position.legalPits(pits);
|
|
||||||
return pits[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void play(BackendGame game, int moveNumber, int pit) {
|
|
||||||
pending.remove(game);
|
|
||||||
// The game may have ended (the player left or ran out of time) or been replaced by a restore meanwhile.
|
|
||||||
if (gameStore.get(game.id) != game || game.mancalaGame.getHistory().size() != moveNumber || !game.isBotTurn()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
gameManager.playMove(game, game.bot.botId(), pit);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The previous move's animation plus the bot's thinking time, shorter when its clock runs low.
|
|
||||||
long delayMs(BackendGame game) {
|
|
||||||
MancalaGame mancalaGame = game.mancalaGame;
|
|
||||||
Seat seat = GameClocks.getSeat(mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), game.bot.botId());
|
|
||||||
if (game.clock.turnStartedAt() != null
|
|
||||||
&& GameClocks.getRemainingMs(game.clock, seat, seat, loop.now()) < LOW_TIME_MS) {
|
|
||||||
return LOW_TIME_THINK_MS;
|
|
||||||
}
|
|
||||||
List<MoveHistoryItem> history = mancalaGame.getHistory();
|
|
||||||
long waitBefore = history.isEmpty() ? GAME_START_DELAY_MS : animationMs(history.getLast());
|
|
||||||
return waitBefore + game.bot.level().drawThinkMs(random);
|
|
||||||
}
|
|
||||||
|
|
||||||
static long animationMs(MoveHistoryItem move) {
|
|
||||||
long steps = move.getGameSteps().stream().map(GameStep::type)
|
|
||||||
.filter(MancalaGame.GAME_STEP_GAME_MOVE::equals).count();
|
|
||||||
return (steps + 1) * ANIMATION_STEP_MS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
package app.playmancala.bot;
|
|
||||||
|
|
||||||
import java.util.SplittableRandom;
|
|
||||||
|
|
||||||
import app.playmancala.engine.Position;
|
|
||||||
|
|
||||||
// Chooses the bot's move. Everything random comes from the game's seed and the move number, so a move can be
|
|
||||||
// reproduced from the saved game.
|
|
||||||
public final class BotPolicy {
|
|
||||||
// How far the strength of a single move may stray from the game's strength.
|
|
||||||
static final int MOVE_STRENGTH_SPREAD = 5;
|
|
||||||
private static final long MOVE_NUMBER_MIX = 0x9E3779B97F4A7C15L;
|
|
||||||
|
|
||||||
private BotPolicy() {
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pit (0-5) of the side to move to play. `moveNumber` is the number of moves played in the game so far.
|
|
||||||
public static int chooseMove(Position position, BotSettings bot, int moveNumber) {
|
|
||||||
return chooseMove(position, bot, moveNumber, BotSearch.DEFAULT_NODE_BUDGET);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int chooseMove(Position position, BotSettings bot, int moveNumber, long nodeBudget) {
|
|
||||||
SplittableRandom random = new SplittableRandom(bot.seed() + MOVE_NUMBER_MIX * (moveNumber + 1));
|
|
||||||
int strength = Math.clamp(bot.strength() + random.nextInt(-MOVE_STRENGTH_SPREAD, MOVE_STRENGTH_SPREAD + 1), 0, 100);
|
|
||||||
BotStrength.Params params = BotStrength.at(strength);
|
|
||||||
int[] pits = new int[Position.PIT_COUNT];
|
|
||||||
int count = position.legalPits(pits);
|
|
||||||
if (count == 0) throw new IllegalArgumentException("No moves in " + position);
|
|
||||||
// Drawn every time, so the draws that follow do not depend on how many moves there are.
|
|
||||||
double randomMoveDraw = random.nextDouble();
|
|
||||||
double choiceDraw = random.nextDouble();
|
|
||||||
if (count == 1) return pits[0];
|
|
||||||
if (randomMoveDraw < params.randomMoveChance()) return pits[(int) (choiceDraw * count)];
|
|
||||||
|
|
||||||
BotSearch.Result result = BotSearch.scoreMoves(position, params.depth(), nodeBudget);
|
|
||||||
return pick(result, params.temperature(), choiceDraw);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A move with probability proportional to exp(score / temperature); temperature in stones.
|
|
||||||
static int pick(BotSearch.Result result, double temperature, double draw) {
|
|
||||||
int[] scores = result.scores();
|
|
||||||
int best = Integer.MIN_VALUE;
|
|
||||||
for (int score : scores) best = Math.max(best, score);
|
|
||||||
double scale = Math.max(temperature, 0.001) * 100;
|
|
||||||
double[] weights = new double[scores.length];
|
|
||||||
double total = 0;
|
|
||||||
for (int i = 0; i < scores.length; i++) {
|
|
||||||
weights[i] = Math.exp((scores[i] - best) / scale);
|
|
||||||
total += weights[i];
|
|
||||||
}
|
|
||||||
double target = draw * total;
|
|
||||||
for (int i = 0; i < scores.length; i++) {
|
|
||||||
target -= weights[i];
|
|
||||||
if (target < 0) return result.pits()[i];
|
|
||||||
}
|
|
||||||
return result.pits()[scores.length - 1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
package app.playmancala.bot;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
import app.playmancala.engine.Position;
|
|
||||||
|
|
||||||
// Scores every move of a position with negamax and alpha-beta pruning. Scores are from the mover's view in
|
|
||||||
// hundredths of a stone; a move that gives an extra turn keeps the side to move, so its score is not negated.
|
|
||||||
// Deepens one ply at a time up to the depth; when the node budget runs out, the scores of the last completed depth
|
|
||||||
// are returned. Nothing depends on time, so the same position, depth and budget always give the same scores.
|
|
||||||
public final class BotSearch {
|
|
||||||
public static final long DEFAULT_NODE_BUDGET = 3_000_000;
|
|
||||||
// An ended (or decided) game scores beyond any bank difference.
|
|
||||||
public static final int WIN = 100_000;
|
|
||||||
// A bank with more than half of the 48 stones has won.
|
|
||||||
private static final int DECIDING_BANK = 24;
|
|
||||||
private static final int STONE = 100;
|
|
||||||
// Leaf score for each stone more in the mover's pits than in the opponent's.
|
|
||||||
static final int PIT_STONE_WEIGHT = 0;
|
|
||||||
|
|
||||||
public record Result(int[] pits, int[] scores, int depth, long nodes) {
|
|
||||||
public int count() {
|
|
||||||
return pits.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class OutOfBudget extends RuntimeException {
|
|
||||||
OutOfBudget() {
|
|
||||||
super(null, null, false, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final long nodeBudget;
|
|
||||||
private Position[][] children;
|
|
||||||
private int[][] order;
|
|
||||||
private boolean[][] extraTurns;
|
|
||||||
private int[][] orderKeys;
|
|
||||||
private long nodes;
|
|
||||||
private boolean budgetApplies;
|
|
||||||
|
|
||||||
private BotSearch(long nodeBudget) {
|
|
||||||
this.nodeBudget = nodeBudget;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result scoreMoves(Position root, int maxDepth) {
|
|
||||||
return scoreMoves(root, maxDepth, DEFAULT_NODE_BUDGET);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result scoreMoves(Position root, int maxDepth, long nodeBudget) {
|
|
||||||
return new BotSearch(nodeBudget).run(root, Math.max(1, maxDepth));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result run(Position root, int maxDepth) {
|
|
||||||
children = new Position[maxDepth + 1][Position.PIT_COUNT];
|
|
||||||
order = new int[maxDepth + 1][Position.PIT_COUNT];
|
|
||||||
extraTurns = new boolean[maxDepth + 1][Position.PIT_COUNT];
|
|
||||||
orderKeys = new int[maxDepth + 1][Position.PIT_COUNT];
|
|
||||||
for (Position[] ply : children) {
|
|
||||||
for (int i = 0; i < ply.length; i++) ply[i] = root.copy();
|
|
||||||
}
|
|
||||||
int[] pits = new int[Position.PIT_COUNT];
|
|
||||||
int count = root.legalPits(pits);
|
|
||||||
if (count == 0) throw new IllegalArgumentException("No moves in " + root);
|
|
||||||
pits = Arrays.copyOf(pits, count);
|
|
||||||
Position[] rootChildren = new Position[count];
|
|
||||||
boolean[] rootExtraTurns = new boolean[count];
|
|
||||||
for (int i = 0; i < count; i++) {
|
|
||||||
rootChildren[i] = root.copy();
|
|
||||||
rootExtraTurns[i] = root.play(pits[i], rootChildren[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
int[] scores = null;
|
|
||||||
int completedDepth = 0;
|
|
||||||
for (int depth = 1; depth <= maxDepth; depth++) {
|
|
||||||
budgetApplies = depth > 1;
|
|
||||||
int[] depthScores = new int[count];
|
|
||||||
try {
|
|
||||||
for (int i = 0; i < count; i++) {
|
|
||||||
nodes++;
|
|
||||||
int value = negamax(rootChildren[i], depth - 1, -Integer.MAX_VALUE, Integer.MAX_VALUE, 1);
|
|
||||||
depthScores[i] = rootExtraTurns[i] ? value : -value;
|
|
||||||
}
|
|
||||||
} catch (OutOfBudget e) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
scores = depthScores;
|
|
||||||
completedDepth = depth;
|
|
||||||
}
|
|
||||||
return new Result(pits, scores, completedDepth, nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int negamax(Position position, int depth, int alpha, int beta, int ply) {
|
|
||||||
if (budgetApplies && nodes > nodeBudget) throw new OutOfBudget();
|
|
||||||
if (position.isOver()) return endScore(position.bankDifference());
|
|
||||||
int ownBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK);
|
|
||||||
int opponentBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER2_BANK : Position.PLAYER1_BANK);
|
|
||||||
if (ownBank > DECIDING_BANK || opponentBank > DECIDING_BANK) return endScore(ownBank - opponentBank);
|
|
||||||
if (depth == 0) return STONE * (ownBank - opponentBank) + PIT_STONE_WEIGHT * position.pitStoneDifference();
|
|
||||||
|
|
||||||
// Play every move once to order them: extra turns first, then by the stones they bring to the bank.
|
|
||||||
Position[] next = children[ply];
|
|
||||||
int[] moveOrder = order[ply];
|
|
||||||
boolean[] extra = extraTurns[ply];
|
|
||||||
int[] keys = orderKeys[ply];
|
|
||||||
int count = 0;
|
|
||||||
for (int pit = 0; pit < Position.PIT_COUNT; pit++) {
|
|
||||||
if (position.stonesAt(position.boardIndex(pit)) == 0) continue;
|
|
||||||
boolean extraTurn = position.play(pit, next[pit]);
|
|
||||||
extra[pit] = extraTurn;
|
|
||||||
int gain = next[pit].stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK) - ownBank;
|
|
||||||
int key = (extraTurn ? 1000 : 0) + gain;
|
|
||||||
int at = count++;
|
|
||||||
while (at > 0 && keys[at - 1] < key) {
|
|
||||||
keys[at] = keys[at - 1];
|
|
||||||
moveOrder[at] = moveOrder[at - 1];
|
|
||||||
at--;
|
|
||||||
}
|
|
||||||
keys[at] = key;
|
|
||||||
moveOrder[at] = pit;
|
|
||||||
}
|
|
||||||
|
|
||||||
int best = -Integer.MAX_VALUE;
|
|
||||||
for (int i = 0; i < count; i++) {
|
|
||||||
int pit = moveOrder[i];
|
|
||||||
nodes++;
|
|
||||||
int value = extra[pit]
|
|
||||||
? negamax(next[pit], depth - 1, alpha, beta, ply + 1)
|
|
||||||
: -negamax(next[pit], depth - 1, -beta, -alpha, ply + 1);
|
|
||||||
if (value > best) best = value;
|
|
||||||
if (best > alpha) alpha = best;
|
|
||||||
if (alpha >= beta) break;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int endScore(int bankDifference) {
|
|
||||||
int sign = Integer.signum(bankDifference);
|
|
||||||
return sign * WIN + STONE * bankDifference;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
package app.playmancala.bot;
|
|
||||||
|
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
|
|
||||||
// How the bot plays one game: its level, the strength drawn for the game and the seed its choices come from. Kept
|
|
||||||
// with the live game and the saved game, so a restart does not change how it plays and its moves can be reproduced.
|
|
||||||
public record BotSettings(BotLevel level, int strength, long seed) {
|
|
||||||
|
|
||||||
public static BotSettings draw(BotLevel level, RandomGenerator random) {
|
|
||||||
return new BotSettings(level, level.drawStrength(random), random.nextLong());
|
|
||||||
}
|
|
||||||
|
|
||||||
public String botId() {
|
|
||||||
return level.botId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package app.playmancala.bot;
|
|
||||||
|
|
||||||
// What a strength (0-100) means for one move: how deep the bot searches, how strongly it prefers the best move
|
|
||||||
// (softmax temperature in stones; lower is more exact) and how often it plays a random move instead. Values between
|
|
||||||
// the rows of the table are interpolated linearly.
|
|
||||||
public final class BotStrength {
|
|
||||||
|
|
||||||
public record Params(int depth, double temperature, double randomMoveChance) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final double[] STRENGTHS = { 0, 25, 50, 75, 100 };
|
|
||||||
private static final double[] DEPTHS = { 1, 2, 4, 7, 16 };
|
|
||||||
private static final double[] TEMPERATURES = { 4, 2, 1, 0.4, 0.05 };
|
|
||||||
private static final double[] RANDOM_MOVE_CHANCES = { 0.35, 0.15, 0.05, 0.01, 0 };
|
|
||||||
|
|
||||||
private BotStrength() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Params at(double strength) {
|
|
||||||
double s = Math.clamp(strength, 0, 100);
|
|
||||||
return new Params((int) Math.round(interpolate(DEPTHS, s)), interpolate(TEMPERATURES, s),
|
|
||||||
interpolate(RANDOM_MOVE_CHANCES, s));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double interpolate(double[] values, double strength) {
|
|
||||||
for (int row = 1; row < STRENGTHS.length; row++) {
|
|
||||||
if (strength <= STRENGTHS[row]) {
|
|
||||||
double t = (strength - STRENGTHS[row - 1]) / (STRENGTHS[row] - STRENGTHS[row - 1]);
|
|
||||||
return values[row - 1] + t * (values[row] - values[row - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values[values.length - 1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
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";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package app.playmancala.engine;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
|
|
||||||
public enum GameState {
|
|
||||||
@JsonProperty("initial") INITIAL,
|
|
||||||
@JsonProperty("playing") PLAYING,
|
|
||||||
@JsonProperty("ended") ENDED
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
package app.playmancala.engine;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
// A board as 14 stone counts and whose turn it is, with the rules of CommonMancalaGame played on it directly. Fast
|
|
||||||
// enough for searching millions of moves: playing a move allocates nothing. Kept equal to MancalaGame by
|
|
||||||
// PositionTest; MancalaGame stays the engine for live games, since it also records the steps clients animate.
|
|
||||||
public final class Position {
|
|
||||||
public static final int SIZE = 14;
|
|
||||||
public static final int PIT_COUNT = 6;
|
|
||||||
public static final int PLAYER1_BANK = 6;
|
|
||||||
public static final int PLAYER2_BANK = 13;
|
|
||||||
private static final int INITIAL_STONES_IN_PIT = 4;
|
|
||||||
|
|
||||||
// Player 1's pits 0-5 and bank 6; player 2's pits 7-12 and bank 13, as in CommonBoard.
|
|
||||||
private final int[] stones;
|
|
||||||
private boolean player1ToMove;
|
|
||||||
|
|
||||||
private Position(int[] stones, boolean player1ToMove) {
|
|
||||||
this.stones = stones;
|
|
||||||
this.player1ToMove = player1ToMove;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Position initial(boolean player1ToMove) {
|
|
||||||
int[] stones = new int[SIZE];
|
|
||||||
for (int pit = 0; pit < PIT_COUNT; pit++) {
|
|
||||||
stones[pit] = INITIAL_STONES_IN_PIT;
|
|
||||||
stones[PLAYER1_BANK + 1 + pit] = INITIAL_STONES_IN_PIT;
|
|
||||||
}
|
|
||||||
return new Position(stones, player1ToMove);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Position of(int[] stones, boolean player1ToMove) {
|
|
||||||
if (stones.length != SIZE) throw new IllegalArgumentException("A board has " + SIZE + " pits: " + stones.length);
|
|
||||||
return new Position(stones.clone(), player1ToMove);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Position of(MancalaGame game) {
|
|
||||||
return of(game.getBoard().getStoneArray(), game.isTurnPlayer1());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Position copy() {
|
|
||||||
return new Position(stones.clone(), player1ToMove);
|
|
||||||
}
|
|
||||||
|
|
||||||
public int[] stones() {
|
|
||||||
return stones.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int stonesAt(int index) {
|
|
||||||
return stones[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isPlayer1ToMove() {
|
|
||||||
return player1ToMove;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Writes the mover's playable pits (0-5) into `into` and returns how many there are.
|
|
||||||
public int legalPits(int[] into) {
|
|
||||||
int first = firstPitIndex(player1ToMove);
|
|
||||||
int count = 0;
|
|
||||||
for (int pit = 0; pit < PIT_COUNT; pit++) {
|
|
||||||
if (stones[first + pit] > 0) into[count++] = pit;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plays the mover's pit (0-5) into `into`, which may be this position. Returns true if the mover plays again.
|
|
||||||
public boolean play(int pit, Position into) {
|
|
||||||
if (pit < 0 || pit >= PIT_COUNT) throw new IllegalArgumentException("No such pit: " + pit);
|
|
||||||
int index = firstPitIndex(player1ToMove) + pit;
|
|
||||||
int stoneCount = stones[index];
|
|
||||||
if (stoneCount <= 0) throw new IllegalArgumentException("Pit " + pit + " is empty");
|
|
||||||
int[] board = into.stones;
|
|
||||||
if (into != this) System.arraycopy(stones, 0, board, 0, SIZE);
|
|
||||||
boolean player1 = player1ToMove;
|
|
||||||
int ownBank = player1 ? PLAYER1_BANK : PLAYER2_BANK;
|
|
||||||
int opponentBank = player1 ? PLAYER2_BANK : PLAYER1_BANK;
|
|
||||||
|
|
||||||
// Sowing: a single stone goes to the next pit; otherwise the first stone goes back into the played pit.
|
|
||||||
board[index] = 0;
|
|
||||||
int current = stoneCount == 1 ? next(index) : index;
|
|
||||||
int last = current;
|
|
||||||
for (int dropped = 0; dropped < stoneCount; current = next(current)) {
|
|
||||||
if (current == opponentBank) continue;
|
|
||||||
board[current]++;
|
|
||||||
last = current;
|
|
||||||
dropped++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The last stone in an own empty pit takes the stones opposite, if there are any.
|
|
||||||
if (board[last] == 1 && isOwnPit(last, player1)) {
|
|
||||||
int opposite = 12 - last;
|
|
||||||
if (board[opposite] > 0) {
|
|
||||||
board[ownBank] += 1 + board[opposite];
|
|
||||||
board[opposite] = 0;
|
|
||||||
board[last] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The last stone making an opponent's pit even takes them.
|
|
||||||
if (board[last] % 2 == 0 && isOwnPit(last, !player1)) {
|
|
||||||
board[ownBank] += board[last];
|
|
||||||
board[last] = 0;
|
|
||||||
}
|
|
||||||
boolean extraTurn = last == ownBank;
|
|
||||||
into.player1ToMove = extraTurn == player1;
|
|
||||||
|
|
||||||
// When a side is empty, its player gets the stones left on the other side.
|
|
||||||
int player1Stones = sumPits(board, 0);
|
|
||||||
int player2Stones = sumPits(board, PLAYER1_BANK + 1);
|
|
||||||
if (player1Stones == 0) {
|
|
||||||
board[PLAYER1_BANK] += player2Stones;
|
|
||||||
Arrays.fill(board, PLAYER1_BANK + 1, PLAYER2_BANK, 0);
|
|
||||||
} else if (player2Stones == 0) {
|
|
||||||
board[PLAYER2_BANK] += player1Stones;
|
|
||||||
Arrays.fill(board, 0, PLAYER1_BANK, 0);
|
|
||||||
}
|
|
||||||
return extraTurn;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One side is empty, so the game has ended.
|
|
||||||
public boolean isOver() {
|
|
||||||
return sumPits(stones, 0) == 0 || sumPits(stones, PLAYER1_BANK + 1) == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mover's bank minus the opponent's.
|
|
||||||
public int bankDifference() {
|
|
||||||
int difference = stones[PLAYER1_BANK] - stones[PLAYER2_BANK];
|
|
||||||
return player1ToMove ? difference : -difference;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mover's stones in pits minus the opponent's.
|
|
||||||
public int pitStoneDifference() {
|
|
||||||
int difference = sumPits(stones, 0) - sumPits(stones, PLAYER1_BANK + 1);
|
|
||||||
return player1ToMove ? difference : -difference;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Board index of the mover's pit (0-5).
|
|
||||||
public int boardIndex(int pit) {
|
|
||||||
return firstPitIndex(player1ToMove) + pit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int firstPitIndex(boolean player1) {
|
|
||||||
return player1 ? 0 : PLAYER1_BANK + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isOwnPit(int index, boolean player1) {
|
|
||||||
return player1 ? index < PLAYER1_BANK : index > PLAYER1_BANK && index < PLAYER2_BANK;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int next(int index) {
|
|
||||||
return index == SIZE - 1 ? 0 : index + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int sumPits(int[] board, int first) {
|
|
||||||
int sum = 0;
|
|
||||||
for (int index = first; index < first + PIT_COUNT; index++) sum += board[index];
|
|
||||||
return sum;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object other) {
|
|
||||||
return other instanceof Position position && player1ToMove == position.player1ToMove
|
|
||||||
&& Arrays.equals(stones, position.stones);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
return 31 * Arrays.hashCode(stones) + Boolean.hashCode(player1ToMove);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return Arrays.toString(stones) + (player1ToMove ? " player 1 to move" : " player 2 to move");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
package app.playmancala.game;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import app.playmancala.bot.BotSettings;
|
|
||||||
import app.playmancala.engine.MancalaGame;
|
|
||||||
import app.playmancala.engine.MoveHistoryItem;
|
|
||||||
import app.playmancala.model.GameClock;
|
|
||||||
import app.playmancala.model.GameUsersConnectionInfo;
|
|
||||||
import app.playmancala.model.PlayerSummary;
|
|
||||||
import app.playmancala.model.Seat;
|
|
||||||
import app.playmancala.model.Series;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
// How the bot plays in a game against the bot; null in games between people.
|
|
||||||
public final BotSettings bot;
|
|
||||||
// The games the same players played before this one (docs/specs/rematch).
|
|
||||||
public final Series series;
|
|
||||||
|
|
||||||
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, BotSettings bot, Series series) {
|
|
||||||
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);
|
|
||||||
this.bot = bot;
|
|
||||||
this.series = series;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The seat that moved first: the first move's player, or the player to move before any move.
|
|
||||||
public Seat firstSeat() {
|
|
||||||
List<MoveHistoryItem> history = mancalaGame.getHistory();
|
|
||||||
String firstPlayerId = history.isEmpty() ? mancalaGame.getTurnPlayerId() : history.getFirst().getPlayerId();
|
|
||||||
return mancalaGame.getPlayer1Id().equals(firstPlayerId) ? Seat.PLAYER1 : Seat.PLAYER2;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isBotTurn() {
|
|
||||||
return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId());
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,450 +0,0 @@
|
|||||||
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.BiConsumer;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
import java.util.function.IntSupplier;
|
|
||||||
import java.util.function.LongSupplier;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
|
|
||||||
import app.playmancala.bot.BotLevel;
|
|
||||||
import app.playmancala.bot.BotSettings;
|
|
||||||
import app.playmancala.engine.Board;
|
|
||||||
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.GameResult;
|
|
||||||
import app.playmancala.model.GameUsersConnectionInfo;
|
|
||||||
import app.playmancala.model.PlayerSummary;
|
|
||||||
import app.playmancala.model.Seat;
|
|
||||||
import app.playmancala.model.Series;
|
|
||||||
import app.playmancala.model.SeriesGame;
|
|
||||||
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);
|
|
||||||
// Clients show the bot by its id ("Bot · Medium"); the name is for anything that only shows names.
|
|
||||||
static final String BOT_NAME = "Bot";
|
|
||||||
|
|
||||||
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<>();
|
|
||||||
private final List<Consumer<BackendGame>> turnListeners = new ArrayList<>();
|
|
||||||
private final List<BiConsumer<BackendGame, GameEndMessage>> gameEndedListeners = new ArrayList<>();
|
|
||||||
private final List<Consumer<String>> searchingListeners = 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called whenever a player is to move: when a game starts or is restored, and after a move that did not end it,
|
|
||||||
// including one that gives an extra turn.
|
|
||||||
public void onTurnStarted(Consumer<BackendGame> listener) {
|
|
||||||
turnListeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when a game has ended, for any reason but a crash, with what the players were sent.
|
|
||||||
public void onGameEnded(BiConsumer<BackendGame, GameEndMessage> listener) {
|
|
||||||
gameEndedListeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called with a user who asks for a new game or a bot game, before it is handled.
|
|
||||||
public void onPlayerSearching(Consumer<String> listener) {
|
|
||||||
searchingListeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void listenRtmtMessages() {
|
|
||||||
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
|
||||||
searchingListeners.forEach(listener -> listener.accept(userId));
|
|
||||||
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.NEW_BOT_GAME, this::onNewBotGame);
|
|
||||||
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 onNewBotGame(String userId, JsonNode message) {
|
|
||||||
BotLevel level = BotLevel.fromWireName(message.path("level").asText(null));
|
|
||||||
if (level == null) {
|
|
||||||
LOG.debugf("(GameManager) Unknown bot level: %s", message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
searchingListeners.forEach(listener -> listener.accept(userId));
|
|
||||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
|
||||||
if (activeGame != null) {
|
|
||||||
rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
matchMaker.cancel(userId);
|
|
||||||
BotSettings bot = BotSettings.draw(level, ThreadLocalRandom.current());
|
|
||||||
startGame(createMancalaGame(userId, bot.botId(), bot));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onGameMove(String userId, JsonNode gameMove) {
|
|
||||||
BackendGame game = gameStore.getGameByUser(userId);
|
|
||||||
if (game == null) {
|
|
||||||
LOG.debug("Game not found!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
playMove(game, userId, () -> {
|
|
||||||
JsonNode index = gameMove.path("index");
|
|
||||||
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
|
|
||||||
throw new IllegalStateException("Invalid move: " + gameMove);
|
|
||||||
}
|
|
||||||
return index.asInt();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plays a player's pit (0-5) in a live game; the bot's moves come here too.
|
|
||||||
public void playMove(BackendGame game, String userId, int pitIndex) {
|
|
||||||
playMove(game, userId, () -> pitIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pit is read after the clock check, so an invalid move after the time has run out still ends the game on time.
|
|
||||||
private void playMove(BackendGame game, String userId, IntSupplier pitIndex) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
int index = pitIndex.getAsInt();
|
|
||||||
int movesBefore = mancalaGame.getHistory().size();
|
|
||||||
Seat moverSeat = seatOf(mancalaGame, userId);
|
|
||||||
mancalaGame.moveByPlayerPit(userId, index);
|
|
||||||
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();
|
|
||||||
notifyTurnStarted(game);
|
|
||||||
}
|
|
||||||
} 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());
|
|
||||||
GameResult result = GameSerialization.getResult(game, ending);
|
|
||||||
Board board = game.mancalaGame.getBoard();
|
|
||||||
int[] stones = board.getStoneArray();
|
|
||||||
Series series = game.series.next(new SeriesGame(game.id, game.firstSeat(), result, ending.endReason(),
|
|
||||||
stones[board.player1BankIndex()], stones[board.player2BankIndex()]));
|
|
||||||
GameEndMessage endMessage = new GameEndMessage(game.id, result, ending.endReason(), game.clock,
|
|
||||||
ending.winnerId(), ending.leftUserId(), series,
|
|
||||||
ending.leftUserId() == null ? GameEndMessage.REMATCH_AVAILABLE : null);
|
|
||||||
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();
|
|
||||||
gameEndedListeners.forEach(listener -> listener.accept(game, endMessage));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
gameStore.getAll().forEach(this::notifyTurnStarted);
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message);
|
|
||||||
sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), channel, message);
|
|
||||||
List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, channel, message));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The bot has no connection to send to.
|
|
||||||
private void sendMessageToPlayer(String playerId, String channel, Object message) {
|
|
||||||
if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, 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(), game.series,
|
|
||||||
game.firstSeat());
|
|
||||||
}
|
|
||||||
|
|
||||||
public BackendGame createMancalaGame(String userId1, String userId2) {
|
|
||||||
return createMancalaGame(userId1, userId2, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `bot` is set for a game against the bot, which plays as one of the two users.
|
|
||||||
public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) {
|
|
||||||
return createMancalaGame(userId1, userId2, bot, Series.start(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A game in `series`; `firstSeat` moves first, or a player drawn at random when it is null.
|
|
||||||
public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot, Series series, Seat firstSeat) {
|
|
||||||
MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2);
|
|
||||||
boolean player1First = firstSeat != null ? firstSeat == Seat.PLAYER1 : ThreadLocalRandom.current().nextBoolean();
|
|
||||||
mancalaGame.setTurnPlayerId(player1First ? 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(), bot, series);
|
|
||||||
gameStore.set(game.id, game);
|
|
||||||
gameStore.setGameByUser(userId1, game);
|
|
||||||
gameStore.setGameByUser(userId2, game);
|
|
||||||
return game;
|
|
||||||
}
|
|
||||||
|
|
||||||
private PlayerSummary getPlayerInfo(String userId) {
|
|
||||||
if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false);
|
|
||||||
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, isOnline(player1.userId())),
|
|
||||||
UserConnectionInfo.of(player2, isOnline(player2.userId())));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The bot is always there.
|
|
||||||
private boolean isOnline(String userId) {
|
|
||||||
return BotLevel.isBotId(userId) || rtmt.isClientOnline(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void startGame(BackendGame game) {
|
|
||||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_START, toClientGame(game));
|
|
||||||
sendUserConnectionInfo(game);
|
|
||||||
saveLiveGame(game);
|
|
||||||
notifyLiveGamesChanged();
|
|
||||||
notifyTurnStarted(game);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 notifyTurnStarted(BackendGame game) {
|
|
||||||
turnListeners.forEach(listener -> listener.accept(game));
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
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 java.util.concurrent.ThreadLocalRandom;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
|
|
||||||
import app.playmancala.bot.BotLevel;
|
|
||||||
import app.playmancala.bot.BotSettings;
|
|
||||||
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.Series;
|
|
||||||
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,
|
|
||||||
// Games against the bot only.
|
|
||||||
BotSettings bot,
|
|
||||||
// Missing in snapshots from before rematches.
|
|
||||||
Series series) {
|
|
||||||
}
|
|
||||||
|
|
||||||
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)), game.bot, game.series);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, restoreBot(snapshot),
|
|
||||||
snapshot.series() != null ? snapshot.series() : Series.start());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at
|
|
||||||
// the middle of its level.
|
|
||||||
private static BotSettings restoreBot(LiveGameSnapshot snapshot) {
|
|
||||||
if (snapshot.bot() != null) return snapshot.bot();
|
|
||||||
BotLevel level = BotLevel.fromBotId(snapshot.player2Id());
|
|
||||||
if (level == null) level = BotLevel.fromBotId(snapshot.player1Id());
|
|
||||||
return level == null ? null : new BotSettings(level, level.middleStrength(), ThreadLocalRandom.current().nextLong());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(), game.bot,
|
|
||||||
game.series.id());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
package app.playmancala.game;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import app.playmancala.bot.BotLevel;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bots play many games at once, so they are not mapped to one.
|
|
||||||
public void setGameByUser(String userId, BackendGame game) {
|
|
||||||
if (BotLevel.isBotId(userId)) return;
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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;
|
|
||||||
import app.playmancala.bot.BotLevel;
|
|
||||||
|
|
||||||
// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash
|
|
||||||
// instead. Keycloak ids and bot 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)
|
|
||||||
|| BotLevel.isBotId(userId)) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
package app.playmancala.game;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
|
|
||||||
import app.playmancala.bot.BotLevel;
|
|
||||||
import app.playmancala.bot.BotSettings;
|
|
||||||
import app.playmancala.loop.Cancellable;
|
|
||||||
import app.playmancala.loop.Scheduler;
|
|
||||||
import app.playmancala.model.Channels;
|
|
||||||
import app.playmancala.model.GameEndMessage;
|
|
||||||
import app.playmancala.model.Seat;
|
|
||||||
import app.playmancala.model.Series;
|
|
||||||
import app.playmancala.rtmt.Rtmt;
|
|
||||||
|
|
||||||
// Rematches between the players of a game that just ended (docs/specs/rematch). An offer is open for a while after
|
|
||||||
// the game; one player asks, the other accepts and the next game of the series starts. Kept in memory only, and only
|
|
||||||
// touched on the game loop.
|
|
||||||
public class RematchOffers {
|
|
||||||
// How long the opponent has to answer a request.
|
|
||||||
static final long ANSWER_MS = 60_000;
|
|
||||||
// How long after a game a rematch can be asked for.
|
|
||||||
static final long OPEN_MS = 10 * 60_000;
|
|
||||||
|
|
||||||
// Why a request, or the offer, ended; sent in on_rematch_ended.
|
|
||||||
enum Reason {
|
|
||||||
DECLINED, WITHDRAWN, EXPIRED, OFFLINE, LEFT, CLOSED;
|
|
||||||
|
|
||||||
String wireName() {
|
|
||||||
return name().toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the next game needs, and the request in progress.
|
|
||||||
private static final class Offer {
|
|
||||||
final String gameId;
|
|
||||||
final String player1Id;
|
|
||||||
final String player2Id;
|
|
||||||
// Games against the bot only.
|
|
||||||
final BotLevel botLevel;
|
|
||||||
final Series series;
|
|
||||||
final Seat firstSeat;
|
|
||||||
// The player who asked; null while nobody has.
|
|
||||||
String askedBy;
|
|
||||||
Cancellable answerTimer;
|
|
||||||
Cancellable closeTimer;
|
|
||||||
|
|
||||||
Offer(String gameId, String player1Id, String player2Id, BotLevel botLevel, Series series, Seat firstSeat) {
|
|
||||||
this.gameId = gameId;
|
|
||||||
this.player1Id = player1Id;
|
|
||||||
this.player2Id = player2Id;
|
|
||||||
this.botLevel = botLevel;
|
|
||||||
this.series = series;
|
|
||||||
this.firstSeat = firstSeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isPlayer(String userId) {
|
|
||||||
return player1Id.equals(userId) || player2Id.equals(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
String opponentOf(String userId) {
|
|
||||||
return player1Id.equals(userId) ? player2Id : player1Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final GameManager gameManager;
|
|
||||||
private final GameStore gameStore;
|
|
||||||
private final Rtmt rtmt;
|
|
||||||
private final Scheduler loop;
|
|
||||||
private final RandomGenerator random;
|
|
||||||
private final Map<String, Offer> offersByGame = new HashMap<>();
|
|
||||||
// A player has at most one offer, the one of their last game. The bot has none.
|
|
||||||
private final Map<String, Offer> offersByUser = new HashMap<>();
|
|
||||||
|
|
||||||
public RematchOffers(GameManager gameManager, GameStore gameStore, Rtmt rtmt, Scheduler loop, RandomGenerator random) {
|
|
||||||
this.gameManager = gameManager;
|
|
||||||
this.gameStore = gameStore;
|
|
||||||
this.rtmt = rtmt;
|
|
||||||
this.loop = loop;
|
|
||||||
this.random = random;
|
|
||||||
gameManager.onGameEnded(this::onGameEnded);
|
|
||||||
gameManager.onPlayerSearching(userId -> close(offersByUser.get(userId), Reason.LEFT));
|
|
||||||
rtmt.listenMessage(Channels.REMATCH, (userId, message) -> onRematch(userId, gameIdOf(message)));
|
|
||||||
rtmt.listenMessage(Channels.DECLINE_REMATCH, (userId, message) -> onDecline(userId, gameIdOf(message)));
|
|
||||||
rtmt.listenOnClientConnectionChange((userId, isOnline) -> {
|
|
||||||
Offer offer = offersByUser.get(userId);
|
|
||||||
if (!isOnline && offer != null && offer.askedBy != null) endRequest(offer, Reason.OFFLINE);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String gameIdOf(JsonNode message) {
|
|
||||||
JsonNode gameId = message.path("gameId");
|
|
||||||
return gameId.isTextual() ? gameId.asText() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onGameEnded(BackendGame game, GameEndMessage endMessage) {
|
|
||||||
if (!endMessage.isRematchAvailable()) return;
|
|
||||||
String player1Id = game.mancalaGame.getPlayer1Id(), player2Id = game.mancalaGame.getPlayer2Id();
|
|
||||||
close(offersByUser.get(player1Id), Reason.CLOSED);
|
|
||||||
close(offersByUser.get(player2Id), Reason.CLOSED);
|
|
||||||
// The other player moves first next (R2.2).
|
|
||||||
Seat nextFirstSeat = game.firstSeat() == Seat.PLAYER1 ? Seat.PLAYER2 : Seat.PLAYER1;
|
|
||||||
Offer offer = new Offer(game.id, player1Id, player2Id, game.bot != null ? game.bot.level() : null,
|
|
||||||
endMessage.series(), nextFirstSeat);
|
|
||||||
offer.closeTimer = loop.schedule(() -> close(offer, Reason.CLOSED), OPEN_MS);
|
|
||||||
offersByGame.put(offer.gameId, offer);
|
|
||||||
for (String playerId : new String[] { player1Id, player2Id }) {
|
|
||||||
if (!BotLevel.isBotId(playerId)) offersByUser.put(playerId, offer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onRematch(String userId, String gameId) {
|
|
||||||
if (gameId == null) return;
|
|
||||||
Offer offer = offersByGame.get(gameId);
|
|
||||||
if (offer == null || !offer.isPlayer(userId)) {
|
|
||||||
rtmt.sendMessage(userId, Channels.ON_REMATCH_ENDED, ended(gameId, Reason.CLOSED));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String opponentId = offer.opponentOf(userId);
|
|
||||||
// The bot always accepts (R4.2).
|
|
||||||
if (BotLevel.isBotId(opponentId) || opponentId.equals(offer.askedBy)) {
|
|
||||||
startNextGame(offer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (userId.equals(offer.askedBy)) return;
|
|
||||||
if (!rtmt.isClientOnline(opponentId)) {
|
|
||||||
rtmt.sendMessage(userId, Channels.ON_REMATCH_ENDED, ended(gameId, Reason.OFFLINE));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
offer.askedBy = userId;
|
|
||||||
rtmt.sendMessage(opponentId, Channels.ON_REMATCH_REQUEST, Map.of("gameId", gameId));
|
|
||||||
offer.answerTimer = loop.schedule(() -> endRequest(offer, Reason.EXPIRED), ANSWER_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Withdraws the user's own request, or says no to the opponent's, which ends the series (R1.6).
|
|
||||||
private void onDecline(String userId, String gameId) {
|
|
||||||
Offer offer = gameId == null ? null : offersByGame.get(gameId);
|
|
||||||
if (offer == null || !offer.isPlayer(userId)) return;
|
|
||||||
if (userId.equals(offer.askedBy)) endRequest(offer, Reason.WITHDRAWN);
|
|
||||||
else close(offer, Reason.DECLINED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ends the request in progress; the offer stays open, so either player can ask again.
|
|
||||||
private void endRequest(Offer offer, Reason reason) {
|
|
||||||
cancel(offer.answerTimer);
|
|
||||||
offer.answerTimer = null;
|
|
||||||
offer.askedBy = null;
|
|
||||||
notifyPlayers(offer, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ends the offer: no rematch of this game any more.
|
|
||||||
private void close(Offer offer, Reason reason) {
|
|
||||||
if (offer == null || !remove(offer)) return;
|
|
||||||
notifyPlayers(offer, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean remove(Offer offer) {
|
|
||||||
if (offersByGame.get(offer.gameId) != offer) return false;
|
|
||||||
offersByGame.remove(offer.gameId);
|
|
||||||
offersByUser.remove(offer.player1Id, offer);
|
|
||||||
offersByUser.remove(offer.player2Id, offer);
|
|
||||||
cancel(offer.answerTimer);
|
|
||||||
cancel(offer.closeTimer);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void startNextGame(Offer offer) {
|
|
||||||
remove(offer);
|
|
||||||
// Matchmaking ends the offer, so neither player can be in a game; a restored game must not make it two.
|
|
||||||
if (gameStore.getGameByUser(offer.player1Id) != null || gameStore.getGameByUser(offer.player2Id) != null) {
|
|
||||||
notifyPlayers(offer, Reason.CLOSED);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The bot keeps its level and draws a new strength, as in any new game.
|
|
||||||
BotSettings bot = offer.botLevel != null ? BotSettings.draw(offer.botLevel, random) : null;
|
|
||||||
gameManager.startGame(gameManager.createMancalaGame(offer.player1Id, offer.player2Id, bot, offer.series,
|
|
||||||
offer.firstSeat));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void notifyPlayers(Offer offer, Reason reason) {
|
|
||||||
Map<String, String> message = ended(offer.gameId, reason);
|
|
||||||
for (String playerId : new String[] { offer.player1Id, offer.player2Id }) {
|
|
||||||
if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, Channels.ON_REMATCH_ENDED, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, String> ended(String gameId, Reason reason) {
|
|
||||||
return Map.of("gameId", gameId, "reason", reason.wireName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void cancel(Cancellable timer) {
|
|
||||||
if (timer != null) timer.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
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", "003_bot", "004_series");
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
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.bot.BotSettings;
|
|
||||||
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, bot, series_id)
|
|
||||||
VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0),
|
|
||||||
?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb, ?::uuid)
|
|
||||||
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()));
|
|
||||||
statement.setString(15, record.bot() == null ? null : Json.write(record.bot()));
|
|
||||||
statement.setString(16, record.seriesId());
|
|
||||||
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, g.bot, g.series_id " + 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"),
|
|
||||||
row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class),
|
|
||||||
row.getString("series_id"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package app.playmancala.loop;
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface Cancellable {
|
|
||||||
void cancel();
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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";
|
|
||||||
// client -> server: { level }, starts a game against the bot at that level (docs/specs/game-bot).
|
|
||||||
public static final String NEW_BOT_GAME = "new_bot_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";
|
|
||||||
// Rematches (docs/specs/rematch). client -> server: rematch { gameId } asks for a rematch of an ended game or
|
|
||||||
// accepts the opponent's request; decline_rematch { gameId } says no or withdraws one's own request.
|
|
||||||
// server -> client: on_rematch_request { gameId } when the opponent asks; on_rematch_ended { gameId, reason } to
|
|
||||||
// both players when a request or the chance of a rematch ends.
|
|
||||||
public static final String REMATCH = "rematch";
|
|
||||||
public static final String DECLINE_REMATCH = "decline_rematch";
|
|
||||||
public static final String ON_REMATCH_REQUEST = "on_rematch_request";
|
|
||||||
public static final String ON_REMATCH_ENDED = "on_rematch_ended";
|
|
||||||
|
|
||||||
private Channels() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
package app.playmancala.model;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
|
|
||||||
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. `series` holds the games before this one.
|
|
||||||
public record ClientGame(
|
|
||||||
String id,
|
|
||||||
MancalaGame mancalaGame,
|
|
||||||
GameUsersConnectionInfo gameUsersConnectionInfo,
|
|
||||||
long startedAt,
|
|
||||||
List<Long> moveTimes,
|
|
||||||
int spectatorCount,
|
|
||||||
GameClock clock,
|
|
||||||
long serverTime,
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL) Series series,
|
|
||||||
Seat firstSeat) {
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package app.playmancala.model;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
||||||
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. `series` includes the game that ended; `rematch` is "available"
|
|
||||||
// when the players can ask for a rematch, i.e. unless a player left.
|
|
||||||
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId", "series", "rematch" })
|
|
||||||
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,
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL) Series series,
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL) String rematch) {
|
|
||||||
public static final String REMATCH_AVAILABLE = "available";
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
public boolean isRematchAvailable() {
|
|
||||||
return REMATCH_AVAILABLE.equals(rematch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
package app.playmancala.model;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
|
||||||
|
|
||||||
import app.playmancala.bot.BotSettings;
|
|
||||||
|
|
||||||
// A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it
|
|
||||||
// is a public id. `bot` is how the bot played, in games against the bot; `seriesId` is missing in games saved before
|
|
||||||
// rematches.
|
|
||||||
@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
|
|
||||||
"moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot", "seriesId" })
|
|
||||||
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,
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot,
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL) String seriesId) {
|
|
||||||
|
|
||||||
// A game between people.
|
|
||||||
public GameRecord(String id, PlayerSummary player1, PlayerSummary player2, List<Integer> board, GameResult result,
|
|
||||||
EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators,
|
|
||||||
Long clockInitialMs, List<MoveRecord> moves, int totalSpectators) {
|
|
||||||
this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators,
|
|
||||||
clockInitialMs, moves, totalSpectators, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
package app.playmancala.model;
|
|
||||||
|
|
||||||
public record GameUsersConnectionInfo(UserConnectionInfo user1ConnectionInfo, UserConnectionInfo user2ConnectionInfo) {
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package app.playmancala.model;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
|
|
||||||
public enum Seat {
|
|
||||||
@JsonProperty("player1") PLAYER1,
|
|
||||||
@JsonProperty("player2") PLAYER2
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package app.playmancala.model;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
// The series a live game belongs to: its id and the games played before this one, oldest first. A game that is not a
|
|
||||||
// rematch starts a series of its own (docs/specs/rematch).
|
|
||||||
public record Series(String id, List<SeriesGame> games) {
|
|
||||||
public Series {
|
|
||||||
games = List.copyOf(games);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Series start() {
|
|
||||||
return new Series(UUID.randomUUID().toString(), List.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same series with `finished` played.
|
|
||||||
public Series next(SeriesGame finished) {
|
|
||||||
List<SeriesGame> played = new ArrayList<>(games);
|
|
||||||
played.add(finished);
|
|
||||||
return new Series(id, played);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
package app.playmancala.model;
|
|
||||||
|
|
||||||
// One finished game of a series, as the series strip shows it (docs/specs/rematch). Seats rather than user ids: the
|
|
||||||
// players keep their seats through a series, and no id can leak. `result` is null when the game ended without a winner.
|
|
||||||
public record SeriesGame(
|
|
||||||
String gameId,
|
|
||||||
Seat firstSeat,
|
|
||||||
GameResult result,
|
|
||||||
EndReason endReason,
|
|
||||||
int player1Store,
|
|
||||||
int player2Store) {
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package app.playmancala.model;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
|
|
||||||
public record UserConnectionInfo(
|
|
||||||
String userId,
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
|
|
||||||
@JsonProperty("isGuest") boolean isGuest,
|
|
||||||
@JsonProperty("isOnline") boolean isOnline) {
|
|
||||||
|
|
||||||
public static UserConnectionInfo of(PlayerSummary player, boolean isOnline) {
|
|
||||||
return new UserConnectionInfo(player.userId(), player.name(), player.isGuest(), isOnline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package app.playmancala.rtmt;
|
|
||||||
|
|
||||||
import java.util.function.BiConsumer;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
|
|
||||||
import app.playmancala.model.AuthUser;
|
|
||||||
|
|
||||||
// Real-time messaging with connected users, as the game logic sees it. Call it on the game loop only.
|
|
||||||
public interface Rtmt {
|
|
||||||
// Sends to every socket of the user; nothing happens if the user is offline.
|
|
||||||
void sendMessage(String clientId, String channel, Object message);
|
|
||||||
|
|
||||||
// One listener per channel; it gets the sender's user id and the message (JSON null if none was sent).
|
|
||||||
void listenMessage(String channel, BiConsumer<String, JsonNode> callback);
|
|
||||||
|
|
||||||
boolean isClientOnline(String clientId);
|
|
||||||
|
|
||||||
// Null if the user is not connected.
|
|
||||||
AuthUser getUser(String clientId);
|
|
||||||
|
|
||||||
// Adds a listener; every listener is called, also for each further socket of a user who is already online.
|
|
||||||
void listenOnClientConnectionChange(BiConsumer<String, Boolean> listener);
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
package app.playmancala.rtmt;
|
|
||||||
|
|
||||||
import java.net.URLDecoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
|
|
||||||
import app.playmancala.Backend;
|
|
||||||
import io.quarkus.websockets.next.CloseReason;
|
|
||||||
import io.quarkus.websockets.next.OnClose;
|
|
||||||
import io.quarkus.websockets.next.OnError;
|
|
||||||
import io.quarkus.websockets.next.OnOpen;
|
|
||||||
import io.quarkus.websockets.next.OnTextMessage;
|
|
||||||
import io.quarkus.websockets.next.WebSocket;
|
|
||||||
import io.quarkus.websockets.next.WebSocketConnection;
|
|
||||||
import io.smallrye.common.annotation.NonBlocking;
|
|
||||||
import jakarta.inject.Inject;
|
|
||||||
|
|
||||||
// Clients connect to the root path, as they did to the Node backend. Events run on the connection's event loop, in
|
|
||||||
// order, and are only handed to the game loop, where RtmtServer handles them.
|
|
||||||
@WebSocket(path = "/")
|
|
||||||
public class RtmtEndpoint {
|
|
||||||
private static final Logger LOG = Logger.getLogger(RtmtEndpoint.class);
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
Backend backend;
|
|
||||||
|
|
||||||
@OnOpen
|
|
||||||
@NonBlocking
|
|
||||||
public void onOpen(WebSocketConnection connection) {
|
|
||||||
RtmtSocket socket = new ConnectionSocket(connection);
|
|
||||||
backend.loop().execute(() -> backend.rtmt().onOpen(socket));
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnTextMessage
|
|
||||||
@NonBlocking
|
|
||||||
public void onTextMessage(WebSocketConnection connection, String text) {
|
|
||||||
RtmtSocket socket = new ConnectionSocket(connection);
|
|
||||||
backend.loop().execute(() -> backend.rtmt().onText(socket, text));
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnClose
|
|
||||||
@NonBlocking
|
|
||||||
public void onClose(WebSocketConnection connection) {
|
|
||||||
RtmtSocket socket = new ConnectionSocket(connection);
|
|
||||||
backend.loop().execute(() -> backend.rtmt().onClose(socket));
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnError
|
|
||||||
@NonBlocking
|
|
||||||
public void onError(WebSocketConnection connection, Throwable error) {
|
|
||||||
// A close event follows, which cleans up.
|
|
||||||
LOG.debugf("(RTMT) WebSocket error: %s", error.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
private record ConnectionSocket(WebSocketConnection connection) implements RtmtSocket {
|
|
||||||
@Override
|
|
||||||
public String id() {
|
|
||||||
return connection.id();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String queryParam(String name) {
|
|
||||||
String query = connection.handshakeRequest().query();
|
|
||||||
if (query == null || query.isEmpty()) return null;
|
|
||||||
for (String pair : query.split("&")) {
|
|
||||||
int equals = pair.indexOf('=');
|
|
||||||
String key = equals < 0 ? pair : pair.substring(0, equals);
|
|
||||||
if (URLDecoder.decode(key, StandardCharsets.UTF_8).equals(name)) {
|
|
||||||
return equals < 0 ? "" : URLDecoder.decode(pair.substring(equals + 1), StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isOpen() {
|
|
||||||
return connection.isOpen();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void send(String text) {
|
|
||||||
connection.sendText(text).subscribe().with(ok -> { },
|
|
||||||
error -> LOG.debugf("(RTMT) Could not send: %s", error.getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close(int code, String reason) {
|
|
||||||
connection.close(new CloseReason(code, reason)).subscribe().with(ok -> { },
|
|
||||||
error -> LOG.debugf("(RTMT) Could not close: %s", error.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,256 +0,0 @@
|
|||||||
package app.playmancala.rtmt;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.Executor;
|
|
||||||
import java.util.function.BiConsumer;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.node.NullNode;
|
|
||||||
|
|
||||||
import app.playmancala.auth.InvalidTokenException;
|
|
||||||
import app.playmancala.auth.TokenVerifier;
|
|
||||||
import app.playmancala.loop.Cancellable;
|
|
||||||
import app.playmancala.loop.Scheduler;
|
|
||||||
import app.playmancala.model.AuthUser;
|
|
||||||
import app.playmancala.model.Channels;
|
|
||||||
import app.playmancala.model.Json;
|
|
||||||
|
|
||||||
// The WebSocket protocol: authentication, routing messages by channel, users with several sockets and the ping
|
|
||||||
// keep-alive. Messages are JSON objects { channel, message }. Everything here runs on the game loop; the endpoint
|
|
||||||
// forwards socket events to it, and tokens are checked on `verifier` and the outcome handled back on the loop.
|
|
||||||
public class RtmtServer implements Rtmt {
|
|
||||||
public static final long PING_INTERVAL_MS = 2_000;
|
|
||||||
public static final long DEFAULT_AUTH_TIMEOUT_MS = 5_000;
|
|
||||||
private static final Logger LOG = Logger.getLogger(RtmtServer.class);
|
|
||||||
private static final Pattern LEGACY_USER_KEY_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 record Envelope(String channel, Object message) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class Client {
|
|
||||||
final RtmtSocket socket;
|
|
||||||
boolean alive = true;
|
|
||||||
// Set once the socket is authenticated.
|
|
||||||
String userId;
|
|
||||||
Cancellable authTimeout;
|
|
||||||
|
|
||||||
Client(RtmtSocket socket) {
|
|
||||||
this.socket = socket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final TokenVerifier tokenVerifier;
|
|
||||||
private final boolean legacyUserKeyAuth;
|
|
||||||
private final Scheduler loop;
|
|
||||||
private final Executor verifier;
|
|
||||||
private final long authTimeoutMs;
|
|
||||||
|
|
||||||
private final Map<String, BiConsumer<String, JsonNode>> messageChannels = new HashMap<>();
|
|
||||||
// By socket id.
|
|
||||||
private final Map<String, Client> clients = new HashMap<>();
|
|
||||||
// A user can be connected from several devices or tabs at once.
|
|
||||||
private final Map<String, Set<Client>> sockets = new HashMap<>();
|
|
||||||
private final Map<String, AuthUser> users = new HashMap<>();
|
|
||||||
private final List<BiConsumer<String, Boolean>> connectionChangeListeners = new ArrayList<>();
|
|
||||||
private Cancellable pingTimer;
|
|
||||||
|
|
||||||
public RtmtServer(TokenVerifier tokenVerifier, boolean legacyUserKeyAuth, Scheduler loop, Executor verifier,
|
|
||||||
long authTimeoutMs) {
|
|
||||||
this.tokenVerifier = tokenVerifier;
|
|
||||||
this.legacyUserKeyAuth = legacyUserKeyAuth;
|
|
||||||
this.loop = loop;
|
|
||||||
this.verifier = verifier;
|
|
||||||
this.authTimeoutMs = authTimeoutMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void start() {
|
|
||||||
stop();
|
|
||||||
pingTimer = loop.scheduleAtFixedRate(this::ping, PING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void stop() {
|
|
||||||
if (pingTimer != null) pingTimer.cancel();
|
|
||||||
pingTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onOpen(RtmtSocket socket) {
|
|
||||||
LOG.debug("(RTMT) Client connected");
|
|
||||||
Client client = new Client(socket);
|
|
||||||
clients.put(socket.id(), client);
|
|
||||||
|
|
||||||
String legacyUserKey = socket.queryParam("userKey");
|
|
||||||
if (legacyUserKey != null) {
|
|
||||||
if (legacyUserKeyAuth && LEGACY_USER_KEY_PATTERN.matcher(legacyUserKey).matches()) {
|
|
||||||
bindSocket(client, new AuthUser(legacyUserKey, null, true));
|
|
||||||
} else {
|
|
||||||
rejectSocket(client, "userKey login is not accepted");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.authTimeout = loop.schedule(() -> {
|
|
||||||
if (client.userId == null) rejectSocket(client, "authentication timeout");
|
|
||||||
}, authTimeoutMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onClose(RtmtSocket socket) {
|
|
||||||
Client client = clients.remove(socket.id());
|
|
||||||
if (client == null) return;
|
|
||||||
LOG.debug("(RTMT) Client disconnected");
|
|
||||||
if (client.authTimeout != null) client.authTimeout.cancel();
|
|
||||||
unbindSocket(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onText(RtmtSocket socket, String text) {
|
|
||||||
Client client = clients.get(socket.id());
|
|
||||||
if (client == null) return;
|
|
||||||
JsonNode envelope;
|
|
||||||
try {
|
|
||||||
envelope = Json.parse(text);
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOG.debug("(RTMT) Ignoring malformed message");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (envelope == null || !envelope.path("channel").isTextual()) return;
|
|
||||||
String channel = envelope.get("channel").asText();
|
|
||||||
JsonNode message = envelope.has("message") ? envelope.get("message") : NullNode.getInstance();
|
|
||||||
|
|
||||||
if (channel.equals(Channels.PONG)) {
|
|
||||||
client.alive = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (channel.equals(Channels.AUTH)) {
|
|
||||||
authenticate(client, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (client.userId == null) {
|
|
||||||
LOG.debugf("(RTMT) Ignoring message from unauthenticated client on channel %s", channel);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BiConsumer<String, JsonNode> callback = messageChannels.get(channel);
|
|
||||||
if (callback != null) {
|
|
||||||
callback.accept(client.userId, message);
|
|
||||||
} else {
|
|
||||||
LOG.debugf("(RTMT) Channel callback not found! %s", channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void authenticate(Client client, JsonNode message) {
|
|
||||||
JsonNode token = message.path("token");
|
|
||||||
if (!token.isTextual()) {
|
|
||||||
rejectSocket(client, "missing token");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
verifier.execute(() -> {
|
|
||||||
try {
|
|
||||||
AuthUser user = tokenVerifier.verify(token.asText());
|
|
||||||
loop.execute(() -> onAuthenticated(client, user));
|
|
||||||
} catch (InvalidTokenException e) {
|
|
||||||
loop.execute(() -> rejectSocket(client, "invalid token"));
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
LOG.error("(RTMT) Token verification failed", e);
|
|
||||||
loop.execute(() -> rejectSocket(client, "authentication failed"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onAuthenticated(Client client, AuthUser user) {
|
|
||||||
if (!client.socket.isOpen() || clients.get(client.socket.id()) != client) return;
|
|
||||||
if (client.userId != null && !client.userId.equals(user.id())) {
|
|
||||||
rejectSocket(client, "token belongs to another user");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (client.authTimeout != null) client.authTimeout.cancel();
|
|
||||||
if (client.userId != null) {
|
|
||||||
// Token refresh on an already authenticated socket.
|
|
||||||
users.put(user.id(), user);
|
|
||||||
} else {
|
|
||||||
bindSocket(client, user);
|
|
||||||
}
|
|
||||||
send(client, Channels.ON_AUTH, Map.of("user", user));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void rejectSocket(Client client, String reason) {
|
|
||||||
if (!client.socket.isOpen()) return;
|
|
||||||
send(client, Channels.ON_AUTH_ERROR, Map.of("reason", reason));
|
|
||||||
client.socket.close(Channels.CLOSE_AUTH_FAILED, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void bindSocket(Client client, AuthUser user) {
|
|
||||||
client.userId = user.id();
|
|
||||||
users.put(user.id(), user);
|
|
||||||
sockets.computeIfAbsent(user.id(), id -> new LinkedHashSet<>()).add(client);
|
|
||||||
// Fired for every new socket so each device gets the current game state.
|
|
||||||
List.copyOf(connectionChangeListeners).forEach(listener -> listener.accept(user.id(), true));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void unbindSocket(Client client) {
|
|
||||||
String userId = client.userId;
|
|
||||||
if (userId == null) return;
|
|
||||||
Set<Client> userSockets = sockets.get(userId);
|
|
||||||
if (userSockets == null || !userSockets.remove(client)) return;
|
|
||||||
if (userSockets.isEmpty()) {
|
|
||||||
sockets.remove(userId);
|
|
||||||
users.remove(userId);
|
|
||||||
List.copyOf(connectionChangeListeners).forEach(listener -> listener.accept(userId, false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ping() {
|
|
||||||
for (Client client : List.copyOf(clients.values())) {
|
|
||||||
if (!client.alive) {
|
|
||||||
LOG.debug("(RTMT) Closing a socket that did not answer the ping");
|
|
||||||
client.socket.close(1001, "ping timeout");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
client.alive = false;
|
|
||||||
send(client, Channels.PING, Map.of());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void sendMessage(String clientId, String channel, Object message) {
|
|
||||||
Set<Client> userSockets = sockets.get(clientId);
|
|
||||||
if (userSockets == null) {
|
|
||||||
LOG.debugf("(RTMT) Client not connected: %s", clientId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String text = Json.write(new Envelope(channel, message));
|
|
||||||
userSockets.forEach(client -> {
|
|
||||||
if (client.socket.isOpen()) client.socket.send(text);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void send(Client client, String channel, Object message) {
|
|
||||||
if (client.socket.isOpen()) client.socket.send(Json.write(new Envelope(channel, message)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void listenMessage(String channel, BiConsumer<String, JsonNode> callback) {
|
|
||||||
messageChannels.put(channel, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isClientOnline(String clientId) {
|
|
||||||
return sockets.containsKey(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AuthUser getUser(String clientId) {
|
|
||||||
return users.get(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void listenOnClientConnectionChange(BiConsumer<String, Boolean> listener) {
|
|
||||||
connectionChangeListeners.add(listener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package app.playmancala.rtmt;
|
|
||||||
|
|
||||||
// A client's WebSocket as RtmtServer sees it. Sends and closes do not block.
|
|
||||||
public interface RtmtSocket {
|
|
||||||
String id();
|
|
||||||
|
|
||||||
// A query parameter of the URL the client connected to, or null.
|
|
||||||
String queryParam(String name);
|
|
||||||
|
|
||||||
boolean isOpen();
|
|
||||||
|
|
||||||
void send(String text);
|
|
||||||
|
|
||||||
void close(int code, String reason);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user