Compare commits
89 Commits
69664796ce
...
a07390c4cc
| Author | SHA1 | Date | |
|---|---|---|---|
| a07390c4cc | |||
| e763682483 | |||
| a3fe708746 | |||
| 79f68416ee | |||
| aa68f04b28 | |||
| 94fc7fcbc7 | |||
| af11fd1cf1 | |||
| dbaa1d2492 | |||
| ee9e5261eb | |||
| c56d1f2c33 | |||
| 4144805a95 | |||
| 47b75b5456 | |||
| 57fa54e77d | |||
| acd60816f7 | |||
| 636127dae4 | |||
| f8ce8f215f | |||
| d444447425 | |||
| 9beb8ba7d4 | |||
| 8511e0b3f2 | |||
| 3a46833e86 | |||
| 9522224bf9 | |||
| a6a309b78d | |||
| f7a76c861e | |||
| d1918fed10 | |||
| e66c4a3a41 | |||
| 32d1ec98e7 | |||
| 4a018c7b53 | |||
| f0b75e5cee | |||
| 31b51d04af | |||
| f9a96fc5f5 | |||
| 91577c0898 | |||
| f6506801d8 | |||
| 0903f61455 | |||
| 6aeff37eb8 | |||
| 0c112eb716 | |||
| d86fca8c54 | |||
| 219b50b491 | |||
| f0246cca61 | |||
| f0ffd6ba87 | |||
| ec833b4ec5 | |||
| 5b0cddf416 | |||
| e81e0e0841 | |||
| 3ea2ba18e3 | |||
| 27f13e2f9a | |||
| d8ea5d00f8 | |||
| 6b08fef1e6 | |||
| 9619825605 | |||
| ce27c71176 | |||
| efe97da0b7 | |||
| e3e7916265 | |||
| 6279b5a820 | |||
| e0c66a1265 | |||
| 193efd1458 | |||
| 8ad833cdbb | |||
| 61d169ecdc | |||
| 3cf789fd5d | |||
| 08fc5c8b7b | |||
| b472c2813c | |||
| f9ea00d138 | |||
| 8671bb2553 | |||
| 20359b7a8f | |||
| c610844e26 | |||
| 3d4f5d7d59 | |||
| 999ae79279 | |||
| 03e1543d5d | |||
| cb34c03246 | |||
| 6e6d37650d | |||
| d52960fa0a | |||
| bb32989279 | |||
| 862c57da5e | |||
| ab2424b648 | |||
| b94c9336dd | |||
| 3bb151dbed | |||
| aa265f7e6c | |||
| a65662f695 | |||
| 9278ed7447 | |||
| 85e3fb60f6 | |||
| 7239bbb6ed | |||
| f6136be007 | |||
| f404b92aaf | |||
| ed2a909f16 | |||
| 16026fb6cb | |||
| 29fa443b93 | |||
| d0004742f5 | |||
| 20efa40e6c | |||
| f5dcf930fa | |||
| e165b0a0eb | |||
| 30ad51c493 | |||
| 7c99a76af7 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 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
|
||||||
150
AGENTS.md
Normal file
150
AGENTS.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# 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) and `rematch` (rematches, alternating first
|
||||||
|
moves, the series strip).
|
||||||
|
|
||||||
|
## 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 only CSS on top of `keycloak.v2`. 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.
|
||||||
3
CLAUDE.md
Normal file
3
CLAUDE.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
The instructions for this repository are in AGENTS.md, shared by all coding agents:
|
||||||
|
|
||||||
|
@AGENTS.md
|
||||||
40
README.md
40
README.md
@ -1,3 +1,43 @@
|
|||||||
### 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 .
|
||||||
|
```
|
||||||
|
|||||||
147
TODO.md
Normal file
147
TODO.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# 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)
|
||||||
|
- [ ] Login pages follow the system color scheme; they could follow the theme chosen in the app instead
|
||||||
|
- [ ] 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)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- [ ] 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
Normal file
1
apps/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
@ -1 +0,0 @@
|
|||||||
node_modules
|
|
||||||
43
apps/backend/.gitignore
vendored
43
apps/backend/.gitignore
vendored
@ -1,40 +1,9 @@
|
|||||||
lib-cov
|
target/
|
||||||
*.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
|
||||||
|
|
||||||
# OS metadata
|
# Written at runtime
|
||||||
.DS_Store
|
access*.log
|
||||||
Thumbs.db
|
crashes/
|
||||||
|
|
||||||
# Ignore built ts files
|
|
||||||
dist/**/*
|
|
||||||
|
|
||||||
crashes
|
|
||||||
|
|||||||
4
apps/backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
4
apps/backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
wrapperVersion=3.3.4
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||||
|
distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce
|
||||||
25
apps/backend/.vscode/launch.json
vendored
25
apps/backend/.vscode/launch.json
vendored
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Launch Program",
|
|
||||||
"program": "${workspaceFolder}/src/index.ts",
|
|
||||||
"preLaunchTask": "tsc: build - tsconfig.json",
|
|
||||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Jest Current File",
|
|
||||||
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
|
||||||
"args": ["${relativeFile}"],
|
|
||||||
"console": "integratedTerminal",
|
|
||||||
"internalConsoleOptions": "neverOpen",
|
|
||||||
"windows": {
|
|
||||||
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,19 +1,29 @@
|
|||||||
FROM node:lts-buster-slim
|
# Build from the repo root: docker build -f apps/backend/Dockerfile .
|
||||||
|
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
|
||||||
|
|
||||||
# Create app directory
|
# The app writes access.log and crashes/ to its working directory.
|
||||||
WORKDIR /usr/src/app
|
RUN useradd --system --create-home mancala && install -d -o mancala /home/mancala/app
|
||||||
|
WORKDIR /home/mancala/app
|
||||||
COPY package.json package.json
|
COPY --from=build --chown=mancala /build/target/quarkus-app/ ./
|
||||||
COPY yarn.lock yarn.lock
|
USER mancala
|
||||||
|
|
||||||
RUN yarn
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN yarn build
|
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
CMD [ "yarn", "start"]
|
CMD ["java", "-jar", "quarkus-run.jar"]
|
||||||
|
|||||||
@ -1,28 +1,123 @@
|
|||||||
# Mancala Backend
|
# Mancala Backend
|
||||||
|
|
||||||
This is the backend code for the Mancala project, written in Express.js.
|
The game server: matchmaking, live games over a WebSocket, saved games and a small HTTP API. Java 25 and Quarkus 3.40,
|
||||||
|
built with Maven. The design and the reasons for it are in `docs/specs/quarkus-backend`.
|
||||||
|
|
||||||
## Getting Started
|
## Getting started
|
||||||
|
|
||||||
### Install Packages
|
You need a JDK 25 (e.g. Temurin); Maven comes with the wrapper. Postgres, Valkey and Keycloak run with Docker Compose,
|
||||||
|
see the README in the repo root.
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
yarn install
|
./mvnw quarkus:dev # http://localhost:5005; a debugger can attach on 5006
|
||||||
|
./mvnw test # needs Docker: Postgres and Valkey run with Quarkus Dev Services
|
||||||
|
./mvnw package # target/quarkus-app/quarkus-run.jar
|
||||||
|
java -jar target/quarkus-app/quarkus-run.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
### Start in Development Mode
|
The Docker image is built from the repo root: `docker build -f apps/backend/Dockerfile .`. It listens on port 6000.
|
||||||
|
|
||||||
```bash
|
## Code
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Start in Release Mode
|
## Authentication
|
||||||
|
|
||||||
```bash
|
Clients log in with Keycloak (see `apps/keycloak`), either as a guest or with an account, and send the access token
|
||||||
yarn build
|
over the WebSocket right after connecting:
|
||||||
yarn start
|
|
||||||
|
```json
|
||||||
|
{ "channel": "auth", "message": { "token": "<Keycloak access token>" } }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development Dependencies
|
The server answers on `on_auth` with the user (`{ id, name, isGuest }`) or on `on_auth_error` and closes the socket
|
||||||
|
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:`.
|
||||||
|
|
||||||
This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions.
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `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.
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
rm -rf node_modules
|
|
||||||
yarn
|
|
||||||
yarn build
|
|
||||||
yarn start
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
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
Executable file
295
apps/backend/mvnw
vendored
Executable file
@ -0,0 +1,295 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
if [ -n "${JAVA_HOME-}" ]; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptDir="$(dirname "$0")"
|
||||||
|
scriptName="$(basename "$0")"
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||||
|
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||||
|
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||||
|
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||||
|
|
||||||
|
exec_maven() {
|
||||||
|
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||||
|
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "$MAVEN_HOME" ]; then
|
||||||
|
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
exec_maven "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${distributionUrl-}" in
|
||||||
|
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||||
|
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||||
|
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||||
|
trap clean HUP INT TERM EXIT
|
||||||
|
else
|
||||||
|
die "cannot create temp dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
verbose "Downloading from: $distributionUrl"
|
||||||
|
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
# select .zip or .tar.gz
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# verbose opt
|
||||||
|
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||||
|
|
||||||
|
# normalize http auth
|
||||||
|
case "${MVNW_PASSWORD:+has-password}" in
|
||||||
|
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||||
|
verbose "Found wget ... using wget"
|
||||||
|
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||||
|
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||||
|
verbose "Found curl ... using curl"
|
||||||
|
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||||
|
elif set_java_home; then
|
||||||
|
verbose "Falling back to use Java to download"
|
||||||
|
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||||
|
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
cat >"$javaSource" <<-END
|
||||||
|
public class Downloader extends java.net.Authenticator
|
||||||
|
{
|
||||||
|
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||||
|
{
|
||||||
|
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||||
|
}
|
||||||
|
public static void main( String[] args ) throws Exception
|
||||||
|
{
|
||||||
|
setDefault( new Downloader() );
|
||||||
|
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END
|
||||||
|
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||||
|
verbose " - Compiling Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||||
|
verbose " - Running Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
if [ -n "${distributionSha256Sum-}" ]; then
|
||||||
|
distributionSha256Result=false
|
||||||
|
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||||
|
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||||
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
elif command -v sha256sum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
elif command -v shasum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||||
|
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $distributionSha256Result = false ]; then
|
||||||
|
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||||
|
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
if command -v unzip >/dev/null; then
|
||||||
|
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||||
|
else
|
||||||
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
actualDistributionDir=""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||||
|
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$distributionUrlNameMain"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
# enable globbing to iterate over items
|
||||||
|
set +f
|
||||||
|
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$(basename "$dir")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
set -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||||
|
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||||
|
die "Could not find Maven distribution directory in extracted archive"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
|
clean || :
|
||||||
|
exec_maven "$@"
|
||||||
189
apps/backend/mvnw.cmd
vendored
Normal file
189
apps/backend/mvnw.cmd
vendored
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
if ($env:MVNW_REPOURL) {
|
||||||
|
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||||
|
}
|
||||||
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
|
||||||
|
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||||
|
if ($env:MAVEN_USER_HOME) {
|
||||||
|
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||||
|
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_WRAPPER_DISTS = $null
|
||||||
|
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||||
|
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||||
|
} else {
|
||||||
|
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
|
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||||
|
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||||
|
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||||
|
trap {
|
||||||
|
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
Write-Verbose "Downloading from: $distributionUrl"
|
||||||
|
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
$webclient = New-Object System.Net.WebClient
|
||||||
|
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||||
|
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||||
|
if ($distributionSha256Sum) {
|
||||||
|
if ($USE_MVND) {
|
||||||
|
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||||
|
}
|
||||||
|
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||||
|
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||||
|
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
$actualDistributionDir = ""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||||
|
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||||
|
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||||
|
$actualDistributionDir = $distributionUrlNameMain
|
||||||
|
}
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||||
|
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||||
|
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||||
|
$actualDistributionDir = $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
|
try {
|
||||||
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
|
} catch {
|
||||||
|
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||||
|
Write-Error "fail to move MAVEN_HOME"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mancala/backend",
|
|
||||||
"version": "0.4.7",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "nodemon src/index.ts",
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "jest",
|
|
||||||
"coverage": "jest --coverage"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@mancala/core": "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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
115
apps/backend/pom.xml
Normal file
115
apps/backend/pom.xml
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<?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>
|
||||||
61
apps/backend/scripts/record-engine-games.js
Normal file
61
apps/backend/scripts/record-engine-games.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// Plays seeded random games with the TypeScript engine (packages/mancala.js) and writes every move and its outcome,
|
||||||
|
// so EngineParityTest can check that the Java engine plays exactly the same. Run it again after changing the rules:
|
||||||
|
//
|
||||||
|
// yarn --cwd ../../packages/mancala.js build && node scripts/record-engine-games.js
|
||||||
|
//
|
||||||
|
// Moves include illegal ones (the wrong player, an empty pit, a store, out of range) and moves after the end.
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { CommonMancalaGame } = require("../../../packages/mancala.js");
|
||||||
|
|
||||||
|
const GAME_COUNT = 15;
|
||||||
|
const MAX_MOVES = 400;
|
||||||
|
const OUTPUT = path.join(__dirname, "../src/test/resources/engine/recorded-games.json");
|
||||||
|
|
||||||
|
// mulberry32
|
||||||
|
function random(seed) {
|
||||||
|
return () => {
|
||||||
|
seed |= 0;
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordGame(number) {
|
||||||
|
const next = random(number + 1);
|
||||||
|
const pick = (items) => items[Math.floor(next() * items.length)];
|
||||||
|
const game = new CommonMancalaGame(`game-${number}`, `kc:player1-${number}`, `kc:player2-${number}`);
|
||||||
|
game.turnPlayerId = next() < 0.5 ? game.player1Id : game.player2Id;
|
||||||
|
const recorded = {
|
||||||
|
id: game.id, player1Id: game.player1Id, player2Id: game.player2Id, turnPlayerId: game.turnPlayerId, moves: [],
|
||||||
|
};
|
||||||
|
let movesAfterEnd = 0;
|
||||||
|
for (let i = 0; i < MAX_MOVES && movesAfterEnd < 2; i++) {
|
||||||
|
const roll = next();
|
||||||
|
const opponent = game.turnPlayerId === game.player1Id ? game.player2Id : game.player1Id;
|
||||||
|
const playerId = roll < 0.93 ? game.turnPlayerId : opponent;
|
||||||
|
const pitsWithStones = [0, 1, 2, 3, 4, 5].filter((pit) =>
|
||||||
|
game.board.pits[game.getBoardIndexByPlayerId(playerId, pit)].stoneCount > 0);
|
||||||
|
const pitIndex = roll < 0.85 && pitsWithStones.length > 0 ? pick(pitsWithStones)
|
||||||
|
: roll < 0.97 ? Math.floor(next() * 6) : pick([-1, 6, 7, 13, 14]);
|
||||||
|
const move = { playerId, pitIndex };
|
||||||
|
try {
|
||||||
|
game.moveByPlayerPit(playerId, pitIndex);
|
||||||
|
} catch (err) {
|
||||||
|
move.error = err.message;
|
||||||
|
}
|
||||||
|
Object.assign(move, { board: game.board.getStoneArray(), turnPlayerId: game.turnPlayerId, state: game.state });
|
||||||
|
recorded.moves.push(move);
|
||||||
|
if (game.state === "ended") movesAfterEnd++;
|
||||||
|
}
|
||||||
|
recorded.wonPlayerId = game.getWonPlayerId() ?? null;
|
||||||
|
recorded.game = JSON.parse(JSON.stringify(game));
|
||||||
|
return recorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const games = Array.from({ length: GAME_COUNT }, (_, number) => recordGame(number));
|
||||||
|
fs.writeFileSync(OUTPUT, JSON.stringify({ games }) + "\n");
|
||||||
|
const moves = games.reduce((sum, game) => sum + game.moves.length, 0);
|
||||||
|
console.log(`Recorded ${games.length} games, ${moves} moves, to ${path.relative(process.cwd(), OUTPUT)}`);
|
||||||
@ -1 +0,0 @@
|
|||||||
export const RTMT_WS_PING_INTERVAL = 2000;
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import { Game } from "@mancala/core";
|
|
||||||
import { BackendGame } from "../models/Game";
|
|
||||||
const crashFolder = "./crashes";
|
|
||||||
export class GameCrashManager {
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
static createCrashFolderIfNotExist(): void {
|
|
||||||
if (!fs.existsSync(crashFolder)) {
|
|
||||||
fs.mkdirSync(crashFolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getTime(): string {
|
|
||||||
let date_ob = new Date();
|
|
||||||
let date = ("0" + date_ob.getDate()).slice(-2);
|
|
||||||
let month = ("0" + (date_ob.getMonth() + 1)).slice(-2);
|
|
||||||
let year = date_ob.getFullYear();
|
|
||||||
let hours = date_ob.getHours();
|
|
||||||
let minutes = date_ob.getMinutes();
|
|
||||||
let seconds = date_ob.getSeconds();
|
|
||||||
let miliSeconds = date_ob.getMilliseconds();
|
|
||||||
return `${year}-${month}-${date}-${hours}:${minutes}:${seconds}:${miliSeconds}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static logGameCrash(error: Error, game: BackendGame): string {
|
|
||||||
this.createCrashFolderIfNotExist();
|
|
||||||
const crash = {
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
game,
|
|
||||||
};
|
|
||||||
const crashStr = JSON.stringify(crash);
|
|
||||||
const crashFileName = `${game.id}::${this.getTime()}`;
|
|
||||||
fs.writeFile(`${crashFolder}/${crashFileName}`, crashStr, () => {});
|
|
||||||
return crashFileName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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[]
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { GameStore } from "./GameStore";
|
|
||||||
import { BackendGame } from "../../models/Game";
|
|
||||||
|
|
||||||
export class GameStoreImpl implements GameStore {
|
|
||||||
gameStore: Map<string, BackendGame> = new Map<string, BackendGame>()
|
|
||||||
userGameMap: Map<string, string> = new Map<string, string>()
|
|
||||||
|
|
||||||
get(id: string): BackendGame | undefined {
|
|
||||||
return this.gameStore.get(id);
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
|
|
||||||
import { RTMTWS } from "./rtmt/rtmt_websocket";
|
|
||||||
import { GameManager } from "./game/GameManager";
|
|
||||||
import { GameStoreImpl } from "./game/gamestore/GameStoreImpl";
|
|
||||||
import { MatchMakerImpl } from "./matchmaker/MatchMakerImpl";
|
|
||||||
import { ExpressApp } from "./server/ExpressApp";
|
|
||||||
import { WebServer } from "./server/WebServer";
|
|
||||||
|
|
||||||
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));
|
|
||||||
202
apps/backend/src/main/java/app/playmancala/Backend.java
Normal file
202
apps/backend/src/main/java/app/playmancala/Backend.java
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package app.playmancala.auth;
|
||||||
|
|
||||||
|
public class InvalidTokenException extends Exception {
|
||||||
|
public InvalidTokenException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package app.playmancala.auth;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.jose4j.http.Get;
|
||||||
|
import org.jose4j.jwa.AlgorithmConstraints;
|
||||||
|
import org.jose4j.jwk.HttpsJwks;
|
||||||
|
import org.jose4j.jws.AlgorithmIdentifiers;
|
||||||
|
import org.jose4j.jwt.JwtClaims;
|
||||||
|
import org.jose4j.jwt.consumer.InvalidJwtException;
|
||||||
|
import org.jose4j.jwt.consumer.JwtConsumer;
|
||||||
|
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
|
||||||
|
import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver;
|
||||||
|
import org.jose4j.keys.resolvers.VerificationKeyResolver;
|
||||||
|
|
||||||
|
import app.playmancala.model.AuthUser;
|
||||||
|
|
||||||
|
// Verifies Keycloak access tokens offline against the realm's published signing keys. Player ids are the token
|
||||||
|
// subject prefixed with "kc:", so they can never collide with ids that legacy userKey clients choose for themselves.
|
||||||
|
public class KeycloakTokenVerifier implements TokenVerifier {
|
||||||
|
public static final String KEYCLOAK_USER_ID_PREFIX = "kc:";
|
||||||
|
public static final String GUEST_ROLE = "guest";
|
||||||
|
|
||||||
|
private static final int JWKS_TIMEOUT_MS = 5_000;
|
||||||
|
// Keys are cached this long, and kept this long while Keycloak cannot be reached.
|
||||||
|
private static final long JWKS_CACHE_SECONDS = 600;
|
||||||
|
// A token with an unknown key id refetches the keys at most this often.
|
||||||
|
private static final long JWKS_REFRESH_COOLDOWN_MS = 30_000;
|
||||||
|
|
||||||
|
private final List<String> allowedClientIds;
|
||||||
|
private final JwtConsumer consumer;
|
||||||
|
|
||||||
|
// `jwksUrl` defaults to the issuer's certs endpoint.
|
||||||
|
public static KeycloakTokenVerifier create(String issuer, List<String> allowedClientIds, String jwksUrl) {
|
||||||
|
HttpsJwks jwks = new HttpsJwks(jwksUrl != null ? jwksUrl : issuer + "/protocol/openid-connect/certs");
|
||||||
|
Get get = new Get();
|
||||||
|
get.setConnectTimeout(JWKS_TIMEOUT_MS);
|
||||||
|
get.setReadTimeout(JWKS_TIMEOUT_MS);
|
||||||
|
jwks.setSimpleHttpGet(get);
|
||||||
|
jwks.setDefaultCacheDuration(JWKS_CACHE_SECONDS);
|
||||||
|
jwks.setRetainCacheOnErrorDuration(JWKS_CACHE_SECONDS * 1000);
|
||||||
|
jwks.setRefreshReprieveThreshold(JWKS_REFRESH_COOLDOWN_MS);
|
||||||
|
return new KeycloakTokenVerifier(issuer, allowedClientIds, new HttpsJwksVerificationKeyResolver(jwks));
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeycloakTokenVerifier(String issuer, List<String> allowedClientIds, VerificationKeyResolver keys) {
|
||||||
|
this.allowedClientIds = List.copyOf(allowedClientIds);
|
||||||
|
this.consumer = new JwtConsumerBuilder()
|
||||||
|
.setExpectedIssuer(issuer)
|
||||||
|
.setRequireSubject()
|
||||||
|
.setRequireExpirationTime()
|
||||||
|
// Keycloak access tokens are checked by azp below, not by audience.
|
||||||
|
.setSkipDefaultAudienceValidation()
|
||||||
|
.setJwsAlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.RSA_USING_SHA256)
|
||||||
|
.setVerificationKeyResolver(keys)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AuthUser verify(String token) throws InvalidTokenException {
|
||||||
|
JwtClaims claims;
|
||||||
|
try {
|
||||||
|
claims = consumer.processToClaims(token);
|
||||||
|
} catch (InvalidJwtException e) {
|
||||||
|
throw new InvalidTokenException(e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens.
|
||||||
|
if (!"Bearer".equals(claims.getClaimValue("typ"))) {
|
||||||
|
throw new InvalidTokenException("not an access token");
|
||||||
|
}
|
||||||
|
Object azp = claims.getClaimValue("azp");
|
||||||
|
if (!(azp instanceof String clientId) || !allowedClientIds.contains(clientId)) {
|
||||||
|
throw new InvalidTokenException("token issued to an unknown client");
|
||||||
|
}
|
||||||
|
|
||||||
|
Object username = claims.getClaimValue("preferred_username");
|
||||||
|
return new AuthUser(KEYCLOAK_USER_ID_PREFIX + claims.getClaimValue("sub"),
|
||||||
|
username instanceof String name ? name : null, hasGuestRole(claims.getClaimValue("realm_access")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasGuestRole(Object realmAccess) {
|
||||||
|
return realmAccess instanceof Map<?, ?> access && access.get("roles") instanceof List<?> roles
|
||||||
|
&& roles.contains(GUEST_ROLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package app.playmancala.auth;
|
||||||
|
|
||||||
|
import app.playmancala.model.AuthUser;
|
||||||
|
|
||||||
|
public interface TokenVerifier {
|
||||||
|
// The token's user. May block (fetching signing keys); call it off the game loop.
|
||||||
|
AuthUser verify(String token) throws InvalidTokenException;
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package app.playmancala.auth;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
||||||
|
import jakarta.enterprise.inject.Produces;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
public class TokenVerifierProducer {
|
||||||
|
@Produces
|
||||||
|
@Singleton
|
||||||
|
TokenVerifier tokenVerifier(
|
||||||
|
@ConfigProperty(name = "mancala.auth.issuer") String issuer,
|
||||||
|
@ConfigProperty(name = "mancala.auth.jwks-url") Optional<String> jwksUrl,
|
||||||
|
@ConfigProperty(name = "mancala.auth.client-ids") String clientIds) {
|
||||||
|
// Comma-separated; spaces and empty entries are ignored, as in the Node backend.
|
||||||
|
List<String> allowedClientIds = Arrays.stream(clientIds.split(",")).map(String::trim).filter(id -> !id.isEmpty()).toList();
|
||||||
|
return KeycloakTokenVerifier.create(issuer, allowedClientIds, jwksUrl.orElse(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/backend/src/main/java/app/playmancala/bot/BotLevel.java
Normal file
80
apps/backend/src/main/java/app/playmancala/bot/BotLevel.java
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java
Normal file
106
apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
140
apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
Normal file
140
apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/backend/src/main/java/app/playmancala/engine/Bank.java
Normal file
15
apps/backend/src/main/java/app/playmancala/engine/Bank.java
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
|
public class Bank extends Pit {
|
||||||
|
public Bank(int index, int stoneCount) {
|
||||||
|
super(index, stoneCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isBank() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
221
apps/backend/src/main/java/app/playmancala/engine/Board.java
Normal file
221
apps/backend/src/main/java/app/playmancala/engine/Board.java
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.IntConsumer;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||||
|
|
||||||
|
// Port of packages/mancala.js/src/core/Board.ts. Pits are indexed counterclockwise: player 1's pits, player 1's bank,
|
||||||
|
// player 2's pits, player 2's bank.
|
||||||
|
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
|
||||||
|
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
|
||||||
|
@JsonPropertyOrder({ "pits", "playerPitCount", "initialStoneCountInPits" })
|
||||||
|
public class Board {
|
||||||
|
private static final int BANK_COUNT = 2;
|
||||||
|
|
||||||
|
private final List<Pit> pits;
|
||||||
|
private final int playerPitCount;
|
||||||
|
private final int initialStoneCountInPits;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
IntConsumer onGameMoveStart = index -> { };
|
||||||
|
@JsonIgnore
|
||||||
|
IntConsumer onGameMove = index -> { };
|
||||||
|
@JsonIgnore
|
||||||
|
IntConsumer onGameMoveEnd = index -> { };
|
||||||
|
|
||||||
|
public Board(int playerPitCount, int initialStoneCountInPits) {
|
||||||
|
this(playerPitCount, initialStoneCountInPits, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Board(int playerPitCount, int initialStoneCountInPits, List<Pit> pits) {
|
||||||
|
this.playerPitCount = playerPitCount;
|
||||||
|
this.initialStoneCountInPits = initialStoneCountInPits;
|
||||||
|
this.pits = pits != null ? pits : createPits(initialStoneCountInPits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Pit> createPits(int initialStoneCountInPits) {
|
||||||
|
List<Pit> pitList = new ArrayList<>(playerPitCount * 2 + BANK_COUNT);
|
||||||
|
for (int index = 0; index < totalPitCount(); index++) {
|
||||||
|
PitType pitType = getPitTypeByIndex(index);
|
||||||
|
pitList.add(pitType.isBank() ? new Bank(index, 0) : new Pit(index, initialStoneCountInPits));
|
||||||
|
}
|
||||||
|
return pitList;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Pit> getPits() {
|
||||||
|
return pits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPlayerPitCount() {
|
||||||
|
return playerPitCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getInitialStoneCountInPits() {
|
||||||
|
return initialStoneCountInPits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int player1PitStartIndex() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int player1PitStopIndex() {
|
||||||
|
return player1PitStartIndex() + playerPitCount - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int player1BankIndex() {
|
||||||
|
return player1PitStopIndex() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int player2PitStartIndex() {
|
||||||
|
return player1BankIndex() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int player2PitStopIndex() {
|
||||||
|
return player2PitStartIndex() + playerPitCount - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int player2BankIndex() {
|
||||||
|
return player2PitStopIndex() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int totalPitCount() {
|
||||||
|
return player2BankIndex() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkIndex(int index) {
|
||||||
|
return index >= player1PitStartIndex() && index <= player2BankIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkIndexAndMaybeThrowError(int index) {
|
||||||
|
if (!checkIndex(index)) {
|
||||||
|
throw new IllegalStateException("IndexOutOfRange => index : " + index + " [start, stop] = ["
|
||||||
|
+ player1PitStartIndex() + ", " + player2BankIndex() + "]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PitType getPitTypeByIndex(int index) {
|
||||||
|
checkIndexAndMaybeThrowError(index);
|
||||||
|
if (index >= player1PitStartIndex() && index <= player1PitStopIndex()) {
|
||||||
|
return PitType.PLAYER1_PIT;
|
||||||
|
} else if (index == player1BankIndex()) {
|
||||||
|
return PitType.PLAYER1_BANK;
|
||||||
|
} else if (index <= player2PitStopIndex()) {
|
||||||
|
return PitType.PLAYER2_PIT;
|
||||||
|
} else {
|
||||||
|
return PitType.PLAYER2_BANK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkPitTypeIsNormalPitByIndex(int index) {
|
||||||
|
return getPitTypeByIndex(index).isNormalPit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkPitTypeIsBankByIndex(int index) {
|
||||||
|
return getPitTypeByIndex(index).isBank();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void move(int index) {
|
||||||
|
checkIndexAndMaybeThrowError(index);
|
||||||
|
PitType pitType = getPitTypeByIndex(index);
|
||||||
|
if (pitType.isBank()) {
|
||||||
|
throw new IllegalStateException("InCorrectPit => index : " + index + " pitType = " + pitTypeName(pitType));
|
||||||
|
}
|
||||||
|
Pit pit = pits.get(index);
|
||||||
|
if (pit.getStoneCount() <= 0) {
|
||||||
|
throw new IllegalStateException("StoneNotFound => index : " + index + " stoneCount = " + pit.getStoneCount());
|
||||||
|
}
|
||||||
|
List<Integer> path = getSowingPath(index);
|
||||||
|
pit.setStoneCount(0);
|
||||||
|
onGameMoveStart.accept(path.getFirst());
|
||||||
|
path.forEach(this::onGameMoveStep);
|
||||||
|
onGameMoveEnd.accept(path.getLast());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Integer> getSowingPath(int index) {
|
||||||
|
return getSowingPath(index, pits.get(index).getStoneCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexes the stones of the pit are dropped into, in order. A single stone goes to the next pit; otherwise the
|
||||||
|
// first stone goes back into the played pit. The player's own bank gets a stone, the opponent's bank is skipped.
|
||||||
|
// The last index is where the last stone lands. Empty for a bank or an empty pit.
|
||||||
|
public List<Integer> getSowingPath(int index, int stoneCount) {
|
||||||
|
checkIndexAndMaybeThrowError(index);
|
||||||
|
if (stoneCount <= 0 || checkPitTypeIsBankByIndex(index)) return List.of();
|
||||||
|
int opponentBankIndex = getPitTypeByIndex(index) == PitType.PLAYER1_PIT ? player2BankIndex() : player1BankIndex();
|
||||||
|
List<Integer> path = new ArrayList<>(stoneCount);
|
||||||
|
int current = stoneCount == 1 ? getNextPitIndexCircularly(index) : index;
|
||||||
|
while (path.size() < stoneCount) {
|
||||||
|
if (current != opponentBankIndex) path.add(current);
|
||||||
|
current = getNextPitIndexCircularly(current);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onGameMoveStep(int index) {
|
||||||
|
pits.get(getPitIndexCircularly(index)).increaseStone();
|
||||||
|
onGameMove.accept(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNextPitIndexCircularly(int currentPitIndex) {
|
||||||
|
checkIndexAndMaybeThrowError(currentPitIndex);
|
||||||
|
return getPitIndexCircularly(currentPitIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Pit getPitCircularly(int currentPitIndex) {
|
||||||
|
return pits.get(getPitIndexCircularly(currentPitIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPitIndexCircularly(int currentPitIndex) {
|
||||||
|
return currentPitIndex % totalPitCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int[] getStoneArray() {
|
||||||
|
return pits.stream().mapToInt(Pit::getStoneCount).toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Pit> player1Pits() {
|
||||||
|
return pits.subList(player1PitStartIndex(), player1BankIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Pit> player2Pits() {
|
||||||
|
return pits.subList(player2PitStartIndex(), player2BankIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Pit player1Bank() {
|
||||||
|
return pits.get(player1BankIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Pit player2Bank() {
|
||||||
|
return pits.get(player2BankIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getOppositePitIndex(int index) {
|
||||||
|
if (index > player1BankIndex()) {
|
||||||
|
return player2PitStopIndex() - index;
|
||||||
|
} else {
|
||||||
|
return player1BankIndex() + playerPitCount - index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearPlayer1Pits() {
|
||||||
|
player1Pits().forEach(pit -> pit.setStoneCount(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearPlayer2Pits() {
|
||||||
|
player2Pits().forEach(pit -> pit.setStoneCount(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The TypeScript name of a pit type, for error messages.
|
||||||
|
static String pitTypeName(PitType pitType) {
|
||||||
|
return switch (pitType) {
|
||||||
|
case PLAYER1_PIT -> "player1Pit";
|
||||||
|
case PLAYER1_BANK -> "player1Bank";
|
||||||
|
case PLAYER2_PIT -> "player2Pit";
|
||||||
|
case PLAYER2_BANK -> "player2Bank";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
public class CommonBoard extends Board {
|
||||||
|
private static final int PIT_COUNT_BY_USER = 6;
|
||||||
|
private static final int INITIAL_STONE_COUNT_IN_PIT = 4;
|
||||||
|
|
||||||
|
public CommonBoard() {
|
||||||
|
super(PIT_COUNT_BY_USER, INITIAL_STONE_COUNT_IN_PIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import app.playmancala.engine.rules.GRClearBoardAtEnd;
|
||||||
|
import app.playmancala.engine.rules.GRDoubleStoneInPit;
|
||||||
|
import app.playmancala.engine.rules.GRLastStoneInBank;
|
||||||
|
import app.playmancala.engine.rules.GRLastStoneInEmptyPit;
|
||||||
|
|
||||||
|
// The Turkish Mangala rules. The order of the rules matters: each sees the board as the previous one left it.
|
||||||
|
public class CommonMancalaGame extends MancalaGame {
|
||||||
|
public CommonMancalaGame(String id, String player1Id, String player2Id) {
|
||||||
|
super(id, new CommonBoard(), player1Id, player2Id, player1Id,
|
||||||
|
List.of(new GRLastStoneInEmptyPit(), new GRDoubleStoneInPit(), new GRLastStoneInBank(),
|
||||||
|
new GRClearBoardAtEnd()),
|
||||||
|
List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
public interface GameRule {
|
||||||
|
default void onGameMoveStart(MancalaGame game, int index) {
|
||||||
|
}
|
||||||
|
|
||||||
|
default void onGameMove(MancalaGame game, int index) {
|
||||||
|
}
|
||||||
|
|
||||||
|
default void onGameMoveEnd(MancalaGame game, int index) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public enum GameState {
|
||||||
|
@JsonProperty("initial") INITIAL,
|
||||||
|
@JsonProperty("playing") PLAYING,
|
||||||
|
@JsonProperty("ended") ENDED
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||||
|
|
||||||
|
// One thing that happened during a move, for clients to animate. `data` depends on the type and is null for most.
|
||||||
|
@JsonPropertyOrder({ "index", "type", "data" })
|
||||||
|
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||||
|
public record GameStep(int index, String type, Object data) {
|
||||||
|
public GameStep(int index, String type) {
|
||||||
|
this(index, type, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||||
|
|
||||||
|
// Port of packages/mancala.js/src/core/MancalaGame.ts. Its JSON is what JSON.stringify makes of the TypeScript object,
|
||||||
|
// because clients rebuild the game from it with MancalaGame.createFromMancalaGame.
|
||||||
|
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
|
||||||
|
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
|
||||||
|
@JsonPropertyOrder({ "id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history",
|
||||||
|
"currentHistoryItem" })
|
||||||
|
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||||
|
public class MancalaGame {
|
||||||
|
public static final String GAME_STEP_GAME_MOVE = "GAME_STEP_GAME_MOVE";
|
||||||
|
|
||||||
|
private final String id;
|
||||||
|
private final Board board;
|
||||||
|
private final String player1Id;
|
||||||
|
private final String player2Id;
|
||||||
|
private String turnPlayerId;
|
||||||
|
private GameState state;
|
||||||
|
@JsonIgnore
|
||||||
|
private final List<GameRule> rules;
|
||||||
|
private final List<MoveHistoryItem> history;
|
||||||
|
private MoveHistoryItem currentHistoryItem;
|
||||||
|
|
||||||
|
public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId,
|
||||||
|
List<GameRule> gameRules, List<MoveHistoryItem> history) {
|
||||||
|
this(id, board, player1Id, player2Id, turnPlayerId, gameRules, history, GameState.INITIAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId,
|
||||||
|
List<GameRule> gameRules, List<MoveHistoryItem> history, GameState state) {
|
||||||
|
this.id = id;
|
||||||
|
this.board = board;
|
||||||
|
this.player1Id = player1Id;
|
||||||
|
this.player2Id = player2Id;
|
||||||
|
this.turnPlayerId = turnPlayerId;
|
||||||
|
this.state = state;
|
||||||
|
this.rules = List.copyOf(gameRules);
|
||||||
|
this.history = new ArrayList<>(history);
|
||||||
|
listenBoardMoveEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void listenBoardMoveEvents() {
|
||||||
|
board.onGameMoveStart = index -> rules.forEach(gameRule -> gameRule.onGameMoveStart(this, index));
|
||||||
|
board.onGameMove = index -> {
|
||||||
|
addGameStep(new GameStep(index, GAME_STEP_GAME_MOVE));
|
||||||
|
rules.forEach(gameRule -> gameRule.onGameMove(this, index));
|
||||||
|
};
|
||||||
|
board.onGameMoveEnd = index -> rules.forEach(gameRule -> gameRule.onGameMoveEnd(this, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Board getBoard() {
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPlayer1Id() {
|
||||||
|
return player1Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPlayer2Id() {
|
||||||
|
return player2Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTurnPlayerId() {
|
||||||
|
return turnPlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTurnPlayerId(String turnPlayerId) {
|
||||||
|
this.turnPlayerId = turnPlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameState getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(GameState state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MoveHistoryItem> getHistory() {
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MoveHistoryItem getCurrentHistoryItem() {
|
||||||
|
return currentHistoryItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules have no state; the TypeScript objects serialise as {}.
|
||||||
|
@JsonProperty("gameRules")
|
||||||
|
private List<Map<String, Object>> gameRulesJson() {
|
||||||
|
return rules.stream().map(rule -> Map.<String, Object>of()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void changePlayerTurn() {
|
||||||
|
turnPlayerId = turnPlayerId.equals(player1Id) ? player2Id : player1Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTurnPlayer1() {
|
||||||
|
return player1Id.equals(turnPlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTurnPlayer2() {
|
||||||
|
return player2Id.equals(turnPlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPlayerIdByPitType(PitType pitType) {
|
||||||
|
return switch (pitType) {
|
||||||
|
case PLAYER1_PIT, PLAYER1_BANK -> player1Id;
|
||||||
|
case PLAYER2_PIT, PLAYER2_BANK -> player2Id;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPlayerIdByIndex(int index) {
|
||||||
|
return getPlayerIdByPitType(board.getPitTypeByIndex(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkIsPlayerTurnByIndex(int index) {
|
||||||
|
return checkIsPlayerTurn(getPlayerIdByIndex(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBoardIndexByPlayerId(String playerId, int pitIndex) {
|
||||||
|
if (player1Id.equals(playerId)) {
|
||||||
|
return board.player1PitStartIndex() + pitIndex;
|
||||||
|
} else if (player2Id.equals(playerId)) {
|
||||||
|
return board.player2PitStartIndex() + pitIndex;
|
||||||
|
} else {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkIsPlayerTurn(String playerId) {
|
||||||
|
return playerId.equals(turnPlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkPitIndexForPlayerId(String playerId, int pitIndex) {
|
||||||
|
return playerId.equals(getPlayerIdByIndex(getBoardIndexByPlayerId(playerId, pitIndex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkIsPitIndexBank(String playerId, int pitIndex) {
|
||||||
|
return board.getPitTypeByIndex(getBoardIndexByPlayerId(playerId, pitIndex)).isBank();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canPlayerMove(String playerId, int pitIndex) {
|
||||||
|
boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex);
|
||||||
|
boolean isPitIndexBank = checkIsPitIndexBank(playerId, pitIndex);
|
||||||
|
boolean isPlayerTurn = checkIsPlayerTurn(playerId);
|
||||||
|
return isPitIndexCorrect && !isPitIndexBank && isPlayerTurn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void moveByPlayerPit(String playerId, int pitIndex) {
|
||||||
|
if (state == GameState.ENDED) return;
|
||||||
|
if (state == GameState.INITIAL) {
|
||||||
|
state = GameState.PLAYING;
|
||||||
|
}
|
||||||
|
if (canPlayerMove(playerId, pitIndex)) {
|
||||||
|
int moveIndex = getBoardIndexByPlayerId(playerId, pitIndex);
|
||||||
|
currentHistoryItem = new MoveHistoryItem(playerId, moveIndex, new int[0], List.of());
|
||||||
|
board.move(moveIndex);
|
||||||
|
currentHistoryItem.setBoardSnapshot(board.getStoneArray());
|
||||||
|
history.add(currentHistoryItem);
|
||||||
|
if (checkGameIsEnded()) {
|
||||||
|
state = GameState.ENDED;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex);
|
||||||
|
boolean isPlayerTurn = checkIsPlayerTurn(playerId);
|
||||||
|
throw new IllegalStateException("Player cannot move reason : isPitIndexCorrect = " + isPitIndexCorrect
|
||||||
|
+ " isPlayerTurn = " + isPlayerTurn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPlayer1StoneCountInPits() {
|
||||||
|
return getPlayerStoneCountInPits(board.player1PitStartIndex(), board.player1BankIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPlayer2StoneCountInPits() {
|
||||||
|
return getPlayerStoneCountInPits(board.player2PitStartIndex(), board.player2BankIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getPlayerStoneCountInPits(int playerPitStartIndex, int playerBankIndex) {
|
||||||
|
return board.getPits().subList(playerPitStartIndex, playerBankIndex).stream()
|
||||||
|
.mapToInt(Pit::getStoneCount).sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkGameIsEnded() {
|
||||||
|
return getPlayer1StoneCountInPits() == 0 || getPlayer2StoneCountInPits() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The winner once the game has ended; null for a draw or a game still being played.
|
||||||
|
public String getWonPlayerId() {
|
||||||
|
int player1StoneCount = board.player1Bank().getStoneCount();
|
||||||
|
int player2StoneCount = board.player2Bank().getStoneCount();
|
||||||
|
if (!checkGameIsEnded() || player1StoneCount == player2StoneCount) return null;
|
||||||
|
return player1StoneCount > player2StoneCount ? player1Id : player2Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addGameStep(GameStep gameStep) {
|
||||||
|
if (currentHistoryItem != null) currentHistoryItem.getGameSteps().add(gameStep);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||||
|
|
||||||
|
// A played move. The property order is that of the TypeScript MoveHistoryItem, whose base class sets the board first.
|
||||||
|
@JsonPropertyOrder({ "boardSnapshot", "gameSteps", "playerId", "moveIndex" })
|
||||||
|
public class MoveHistoryItem {
|
||||||
|
private int[] boardSnapshot;
|
||||||
|
private final List<GameStep> gameSteps;
|
||||||
|
private final String playerId;
|
||||||
|
private final int moveIndex;
|
||||||
|
|
||||||
|
@JsonCreator
|
||||||
|
public MoveHistoryItem(
|
||||||
|
@JsonProperty("playerId") String playerId,
|
||||||
|
@JsonProperty("moveIndex") int moveIndex,
|
||||||
|
@JsonProperty("boardSnapshot") int[] boardSnapshot,
|
||||||
|
@JsonProperty("gameSteps") List<GameStep> gameSteps) {
|
||||||
|
this.playerId = playerId;
|
||||||
|
this.moveIndex = moveIndex;
|
||||||
|
this.boardSnapshot = boardSnapshot == null ? new int[0] : boardSnapshot;
|
||||||
|
this.gameSteps = gameSteps == null ? new ArrayList<>() : new ArrayList<>(gameSteps);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int[] getBoardSnapshot() {
|
||||||
|
return boardSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setBoardSnapshot(int[] boardSnapshot) {
|
||||||
|
this.boardSnapshot = boardSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GameStep> getGameSteps() {
|
||||||
|
return gameSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPlayerId() {
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMoveIndex() {
|
||||||
|
return moveIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/backend/src/main/java/app/playmancala/engine/Pit.java
Normal file
40
apps/backend/src/main/java/app/playmancala/engine/Pit.java
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||||
|
|
||||||
|
@JsonPropertyOrder({ "index", "stoneCount" })
|
||||||
|
public class Pit {
|
||||||
|
private final int index;
|
||||||
|
private int stoneCount;
|
||||||
|
|
||||||
|
public Pit(int index, int stoneCount) {
|
||||||
|
this.index = index;
|
||||||
|
this.stoneCount = stoneCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getIndex() {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStoneCount() {
|
||||||
|
return stoneCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoneCount(int stoneCount) {
|
||||||
|
this.stoneCount = stoneCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isBank() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void increaseStone() {
|
||||||
|
stoneCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addStones(int count) {
|
||||||
|
stoneCount += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package app.playmancala.engine;
|
||||||
|
|
||||||
|
public enum PitType {
|
||||||
|
PLAYER1_PIT,
|
||||||
|
PLAYER1_BANK,
|
||||||
|
PLAYER2_PIT,
|
||||||
|
PLAYER2_BANK;
|
||||||
|
|
||||||
|
public boolean isBank() {
|
||||||
|
return this == PLAYER1_BANK || this == PLAYER2_BANK;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNormalPit() {
|
||||||
|
return this == PLAYER1_PIT || this == PLAYER2_PIT;
|
||||||
|
}
|
||||||
|
}
|
||||||
176
apps/backend/src/main/java/app/playmancala/engine/Position.java
Normal file
176
apps/backend/src/main/java/app/playmancala/engine/Position.java
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package app.playmancala.engine.rules;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import app.playmancala.engine.Board;
|
||||||
|
import app.playmancala.engine.GameRule;
|
||||||
|
import app.playmancala.engine.GameStep;
|
||||||
|
import app.playmancala.engine.MancalaGame;
|
||||||
|
|
||||||
|
// When a move empties one side, the player whose side is empty gets the stones left on the other side.
|
||||||
|
public class GRClearBoardAtEnd implements GameRule {
|
||||||
|
public static final String GAME_STEP_BOARD_CLEARED = "GAME_STEP_BOARD_CLEARED";
|
||||||
|
|
||||||
|
public record ClearBoardAtEndData(List<Integer> pitIndexesThatHasStone) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onGameMoveEnd(MancalaGame game, int index) {
|
||||||
|
Board board = game.getBoard();
|
||||||
|
if (game.getPlayer1StoneCountInPits() == 0) {
|
||||||
|
ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board));
|
||||||
|
board.player1Bank().addStones(game.getPlayer2StoneCountInPits());
|
||||||
|
board.clearPlayer2Pits();
|
||||||
|
game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data));
|
||||||
|
} else if (game.getPlayer2StoneCountInPits() == 0) {
|
||||||
|
ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board));
|
||||||
|
board.player2Bank().addStones(game.getPlayer1StoneCountInPits());
|
||||||
|
board.clearPlayer1Pits();
|
||||||
|
game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> getPitIndexesThatHasStone(Board board) {
|
||||||
|
List<Integer> indexList = new ArrayList<>();
|
||||||
|
int[] stones = board.getStoneArray();
|
||||||
|
for (int index = 0; index < stones.length; index++) {
|
||||||
|
if (stones[index] > 0 && board.checkPitTypeIsNormalPitByIndex(index)) {
|
||||||
|
indexList.add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indexList;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package app.playmancala.engine.rules;
|
||||||
|
|
||||||
|
import app.playmancala.engine.Board;
|
||||||
|
import app.playmancala.engine.GameRule;
|
||||||
|
import app.playmancala.engine.GameStep;
|
||||||
|
import app.playmancala.engine.MancalaGame;
|
||||||
|
import app.playmancala.engine.Pit;
|
||||||
|
import app.playmancala.engine.PitType;
|
||||||
|
|
||||||
|
// When the last stone makes the number of stones in an opponent's pit even, the mover takes them.
|
||||||
|
public class GRDoubleStoneInPit implements GameRule {
|
||||||
|
public static final String GAME_STEP_DOUBLE_STONE_IN_PIT = "GAME_STEP_DOUBLE_STONE_IN_PIT";
|
||||||
|
|
||||||
|
public record DoubleStoneInPitData(int pitIndex, int bankIndex) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onGameMoveEnd(MancalaGame game, int index) {
|
||||||
|
Board board = game.getBoard();
|
||||||
|
index = board.getPitIndexCircularly(index);
|
||||||
|
Pit pit = board.getPits().get(index);
|
||||||
|
PitType pitType = board.getPitTypeByIndex(index);
|
||||||
|
if (pit.getStoneCount() % 2 != 0) return;
|
||||||
|
if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer2()) {
|
||||||
|
capture(game, index, board.player2Bank(), board.player2BankIndex());
|
||||||
|
} else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer1()) {
|
||||||
|
capture(game, index, board.player1Bank(), board.player1BankIndex());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void capture(MancalaGame game, int pitIndex, Pit bank, int bankIndex) {
|
||||||
|
Pit pit = game.getBoard().getPitCircularly(pitIndex);
|
||||||
|
bank.addStones(pit.getStoneCount());
|
||||||
|
pit.setStoneCount(0);
|
||||||
|
game.addGameStep(new GameStep(pitIndex, GAME_STEP_DOUBLE_STONE_IN_PIT, new DoubleStoneInPitData(pitIndex, bankIndex)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package app.playmancala.engine.rules;
|
||||||
|
|
||||||
|
import app.playmancala.engine.GameRule;
|
||||||
|
import app.playmancala.engine.GameStep;
|
||||||
|
import app.playmancala.engine.MancalaGame;
|
||||||
|
import app.playmancala.engine.PitType;
|
||||||
|
|
||||||
|
// When the last stone lands in the mover's bank, the mover plays again; otherwise the turn passes.
|
||||||
|
public class GRLastStoneInBank implements GameRule {
|
||||||
|
public static final String GAME_STEP_LAST_STONE_IN_BANK = "GAME_STEP_LAST_STONE_IN_BANK";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onGameMoveEnd(MancalaGame game, int index) {
|
||||||
|
index = game.getBoard().getPitIndexCircularly(index);
|
||||||
|
PitType pitType = game.getBoard().getPitTypeByIndex(index);
|
||||||
|
if ((pitType == PitType.PLAYER1_BANK && game.isTurnPlayer1())
|
||||||
|
|| (pitType == PitType.PLAYER2_BANK && game.isTurnPlayer2())) {
|
||||||
|
game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_BANK));
|
||||||
|
} else {
|
||||||
|
game.changePlayerTurn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package app.playmancala.engine.rules;
|
||||||
|
|
||||||
|
import app.playmancala.engine.Board;
|
||||||
|
import app.playmancala.engine.GameRule;
|
||||||
|
import app.playmancala.engine.GameStep;
|
||||||
|
import app.playmancala.engine.MancalaGame;
|
||||||
|
import app.playmancala.engine.Pit;
|
||||||
|
import app.playmancala.engine.PitType;
|
||||||
|
|
||||||
|
// When the last stone lands in an empty pit of the mover's own, the mover takes it and the stones opposite.
|
||||||
|
public class GRLastStoneInEmptyPit implements GameRule {
|
||||||
|
public static final String GAME_STEP_LAST_STONE_IN_EMPTY_PIT = "GAME_STEP_LAST_STONE_IN_EMPTY_PIT";
|
||||||
|
|
||||||
|
public record LastStoneInEmptyPitData(int oppositeIndex) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onGameMoveEnd(MancalaGame game, int index) {
|
||||||
|
Board board = game.getBoard();
|
||||||
|
index = board.getPitIndexCircularly(index);
|
||||||
|
Pit pit = board.getPits().get(index);
|
||||||
|
PitType pitType = board.getPitTypeByIndex(index);
|
||||||
|
if (pit.getStoneCount() != 1) return;
|
||||||
|
if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer1()) {
|
||||||
|
capture(game, index, pit, board.player1Bank());
|
||||||
|
} else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer2()) {
|
||||||
|
capture(game, index, pit, board.player2Bank());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void capture(MancalaGame game, int index, Pit pit, Pit bank) {
|
||||||
|
int oppositeIndex = game.getBoard().getOppositePitIndex(index);
|
||||||
|
Pit oppositePit = game.getBoard().getPits().get(oppositeIndex);
|
||||||
|
if (oppositePit.getStoneCount() <= 0) return;
|
||||||
|
bank.addStones(1 + oppositePit.getStoneCount());
|
||||||
|
oppositePit.setStoneCount(0);
|
||||||
|
pit.setStoneCount(0);
|
||||||
|
game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_EMPTY_PIT, new LastStoneInEmptyPitData(oppositeIndex)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package app.playmancala.game;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
import app.playmancala.model.Json;
|
||||||
|
|
||||||
|
// Writes a file to ./crashes (or the folder in the mancala.crash-dir system property) for each game that failed while a move was played, with the error and the game.
|
||||||
|
public final class CrashLog {
|
||||||
|
private static final Logger LOG = Logger.getLogger(CrashLog.class);
|
||||||
|
private static final Path CRASH_FOLDER = Path.of(System.getProperty("mancala.crash-dir", "crashes"));
|
||||||
|
|
||||||
|
private CrashLog() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// The JSON to write; call it on the game loop, while the game cannot change.
|
||||||
|
public static String describe(Throwable error, BackendGame game) {
|
||||||
|
StringWriter stack = new StringWriter();
|
||||||
|
error.printStackTrace(new PrintWriter(stack));
|
||||||
|
Map<String, Object> crash = new LinkedHashMap<>();
|
||||||
|
crash.put("message", error.getMessage());
|
||||||
|
crash.put("stack", stack.toString());
|
||||||
|
crash.put("game", game);
|
||||||
|
return Json.write(crash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the file name, e.g. "<game id>::2026-10-16-12:5:3:42" as the Node backend named them.
|
||||||
|
public static String fileName(String gameId) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
return String.format("%s::%d-%02d-%02d-%d:%d:%d:%d", gameId, now.getYear(), now.getMonthValue(),
|
||||||
|
now.getDayOfMonth(), now.getHour(), now.getMinute(), now.getSecond(), now.getNano() / 1_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void write(String fileName, String crash) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CRASH_FOLDER);
|
||||||
|
Files.writeString(CRASH_FOLDER.resolve(fileName), crash);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.errorf("(CrashLog) Could not write %s: %s", fileName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package app.playmancala.game;
|
||||||
|
|
||||||
|
import app.playmancala.loop.Cancellable;
|
||||||
|
import app.playmancala.loop.Scheduler;
|
||||||
|
|
||||||
|
// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected
|
||||||
|
// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs
|
||||||
|
// only decides how soon the end is announced, never who wins.
|
||||||
|
public class GameClockScheduler {
|
||||||
|
public static final long CLOCK_CHECK_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
|
private final Runnable endGamesOutOfTime;
|
||||||
|
private final Scheduler scheduler;
|
||||||
|
private final long intervalMs;
|
||||||
|
private Cancellable timer;
|
||||||
|
|
||||||
|
public GameClockScheduler(Runnable endGamesOutOfTime, Scheduler scheduler, long intervalMs) {
|
||||||
|
this.endGamesOutOfTime = endGamesOutOfTime;
|
||||||
|
this.scheduler = scheduler;
|
||||||
|
this.intervalMs = intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (timer != null) return;
|
||||||
|
timer = scheduler.scheduleAtFixedRate(endGamesOutOfTime, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (timer != null) timer.cancel();
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package app.playmancala.game;
|
||||||
|
|
||||||
|
import app.playmancala.model.GameClock;
|
||||||
|
import app.playmancala.model.Seat;
|
||||||
|
|
||||||
|
// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn
|
||||||
|
// plus when that turn started, so it only changes on moves; the time left at any moment is computed.
|
||||||
|
public final class GameClocks {
|
||||||
|
private GameClocks() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ClockTimes(long player1Ms, long player2Ms) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ChargedMove(GameClock clock, long remainingMs) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seat of the player, or null if they do not play in the game.
|
||||||
|
public static Seat getSeat(String player1Id, String player2Id, String playerId) {
|
||||||
|
if (playerId.equals(player1Id)) return Seat.PLAYER1;
|
||||||
|
if (playerId.equals(player2Id)) return Seat.PLAYER2;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GameClock start(long initialMs, long now) {
|
||||||
|
return new GameClock(initialMs, initialMs, initialMs, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both players' time at `now`; never below zero. Port of getClockTimes in packages/core.
|
||||||
|
public static ClockTimes getClockTimes(GameClock clock, Seat runningSeat, long now) {
|
||||||
|
long elapsed = runningSeat != null && clock.turnStartedAt() != null ? Math.max(0, now - clock.turnStartedAt()) : 0;
|
||||||
|
return new ClockTimes(
|
||||||
|
Math.max(0, clock.player1Ms() - (runningSeat == Seat.PLAYER1 ? elapsed : 0)),
|
||||||
|
Math.max(0, clock.player2Ms() - (runningSeat == Seat.PLAYER2 ? elapsed : 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long getRemainingMs(GameClock clock, Seat seat, Seat runningSeat, long now) {
|
||||||
|
ClockTimes times = getClockTimes(clock, runningSeat, now);
|
||||||
|
return seat == Seat.PLAYER1 ? times.player1Ms() : times.player2Ms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn.
|
||||||
|
public static ChargedMove chargeMove(GameClock clock, Seat moverSeat, long now) {
|
||||||
|
long remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now);
|
||||||
|
GameClock charged = moverSeat == Seat.PLAYER1
|
||||||
|
? new GameClock(clock.initialMs(), remainingMs, clock.player2Ms(), now)
|
||||||
|
: new GameClock(clock.initialMs(), clock.player1Ms(), remainingMs, now);
|
||||||
|
return new ChargedMove(charged, remainingMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deducts the running seat's time, if any, and stops the clock.
|
||||||
|
public static GameClock stop(GameClock clock, Seat runningSeat, long now) {
|
||||||
|
ClockTimes times = getClockTimes(clock, runningSeat, now);
|
||||||
|
return new GameClock(clock.initialMs(), times.player1Ms(), times.player2Ms(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts the running clock again from the time it had at the start of the turn, e.g. after a restart.
|
||||||
|
public static GameClock resume(GameClock clock, long now) {
|
||||||
|
return new GameClock(clock.initialMs(), clock.player1Ms(), clock.player2Ms(), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
450
apps/backend/src/main/java/app/playmancala/game/GameManager.java
Normal file
450
apps/backend/src/main/java/app/playmancala/game/GameManager.java
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
package app.playmancala.game;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
import app.playmancala.history.GameHistoryRepository;
|
||||||
|
import app.playmancala.live.LiveGameStore;
|
||||||
|
import app.playmancala.model.GameRecord;
|
||||||
|
|
||||||
|
// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is
|
||||||
|
// down too) and saved later, so a game is never lost because a dependency was briefly unavailable. Saves run on the
|
||||||
|
// given executor; the lists of unsaved games are read from any thread.
|
||||||
|
public class GameRecorder {
|
||||||
|
public static final long RETRY_INTERVAL_MS = 30_000;
|
||||||
|
private static final Logger LOG = Logger.getLogger(GameRecorder.class);
|
||||||
|
|
||||||
|
private final GameHistoryRepository repository;
|
||||||
|
private final LiveGameStore liveGameStore;
|
||||||
|
private final Executor io;
|
||||||
|
// Being saved right now.
|
||||||
|
private final Map<String, GameRecord> saving = new ConcurrentHashMap<>();
|
||||||
|
// Failed to save; retried later.
|
||||||
|
private final Map<String, GameRecord> pending = new ConcurrentHashMap<>();
|
||||||
|
private final List<Consumer<GameRecord>> savedListeners = new CopyOnWriteArrayList<>();
|
||||||
|
private final AtomicBoolean retrying = new AtomicBoolean();
|
||||||
|
|
||||||
|
public GameRecorder(GameHistoryRepository repository, LiveGameStore liveGameStore, Executor io) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.liveGameStore = liveGameStore;
|
||||||
|
this.io = io;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onSaved(Consumer<GameRecord> listener) {
|
||||||
|
savedListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(GameRecord record) {
|
||||||
|
saving.put(record.id(), record);
|
||||||
|
io.execute(() -> {
|
||||||
|
try {
|
||||||
|
repository.save(record);
|
||||||
|
saving.remove(record.id());
|
||||||
|
notifySaved(record);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.errorf("(GameRecorder) Could not save game %s, will retry: %s", record.id(), e.getMessage());
|
||||||
|
pending.put(record.id(), record);
|
||||||
|
saving.remove(record.id());
|
||||||
|
try {
|
||||||
|
liveGameStore.savePendingRecord(record);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Kept in memory.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finished games that are not in the database yet, so readers can include them.
|
||||||
|
public List<GameRecord> getUnsaved() {
|
||||||
|
List<GameRecord> unsaved = new ArrayList<>(saving.values());
|
||||||
|
unsaved.addAll(pending.values());
|
||||||
|
return unsaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if the game is saved or waiting to be saved. Throws if the database cannot be asked.
|
||||||
|
public boolean isRecorded(String gameId) {
|
||||||
|
return pending.containsKey(gameId) || repository.get(gameId) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saves the records that failed before, including those a previous run left in Valkey. Blocks.
|
||||||
|
public void retryPending() {
|
||||||
|
if (!retrying.compareAndSet(false, true)) return;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
liveGameStore.loadPendingRecords().forEach(record -> pending.put(record.id(), record));
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Valkey is down; retry what is in memory.
|
||||||
|
}
|
||||||
|
for (GameRecord record : List.copyOf(pending.values())) {
|
||||||
|
try {
|
||||||
|
repository.save(record);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return; // still unreachable; try again later
|
||||||
|
}
|
||||||
|
pending.remove(record.id());
|
||||||
|
try {
|
||||||
|
liveGameStore.removePendingRecord(record.id());
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Saving it again later is a no-op.
|
||||||
|
}
|
||||||
|
LOG.infof("(GameRecorder) Saved game %s after retrying", record.id());
|
||||||
|
notifySaved(record);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
retrying.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifySaved(GameRecord record) {
|
||||||
|
savedListeners.forEach(listener -> listener.accept(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package app.playmancala.game;
|
||||||
|
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import app.playmancala.loop.Cancellable;
|
||||||
|
import app.playmancala.loop.Scheduler;
|
||||||
|
import app.playmancala.model.Channels;
|
||||||
|
import app.playmancala.model.Json;
|
||||||
|
import app.playmancala.model.LiveGameSummary;
|
||||||
|
import app.playmancala.rtmt.Rtmt;
|
||||||
|
|
||||||
|
// Live games for the home page: most spectated first, then most recently active. Subscribers get the top games
|
||||||
|
// when they subscribe and again whenever they change, at most once per second. Subscribing does not make anyone a
|
||||||
|
// spectator. Only touched on the game loop.
|
||||||
|
public class LiveGames {
|
||||||
|
public static final int LIVE_GAMES_PUSH_COUNT = 5;
|
||||||
|
private static final long PUSH_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
|
private final Rtmt rtmt;
|
||||||
|
private final GameStore gameStore;
|
||||||
|
private final Scheduler scheduler;
|
||||||
|
private final Set<String> subscribers = new LinkedHashSet<>();
|
||||||
|
private String lastPushed = "";
|
||||||
|
private long lastPushAt;
|
||||||
|
private Cancellable pushTimer;
|
||||||
|
|
||||||
|
public LiveGames(Rtmt rtmt, GameStore gameStore, GameManager gameManager, Scheduler scheduler) {
|
||||||
|
this.rtmt = rtmt;
|
||||||
|
this.gameStore = gameStore;
|
||||||
|
this.scheduler = scheduler;
|
||||||
|
rtmt.listenMessage(Channels.SUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribe(userId));
|
||||||
|
rtmt.listenMessage(Channels.UNSUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribers.remove(userId));
|
||||||
|
rtmt.listenOnClientConnectionChange((userId, isOnline) -> {
|
||||||
|
if (!isOnline) subscribers.remove(userId);
|
||||||
|
});
|
||||||
|
gameManager.onLiveGamesChanged(this::schedulePush);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LiveGameSummary toLiveGameSummary(BackendGame game) {
|
||||||
|
var mancalaGame = game.mancalaGame;
|
||||||
|
return new LiveGameSummary(game.id,
|
||||||
|
GameSerialization.publicSummary(game, mancalaGame.getPlayer1Id()),
|
||||||
|
GameSerialization.publicSummary(game, mancalaGame.getPlayer2Id()),
|
||||||
|
GameSerialization.boxed(mancalaGame.getBoard().getStoneArray()),
|
||||||
|
PublicIds.publicUserId(mancalaGame.getTurnPlayerId()), game.spectatorIds.size(),
|
||||||
|
mancalaGame.getHistory().size(), game.startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long lastActivity(BackendGame game) {
|
||||||
|
return game.moveTimes.isEmpty() ? game.startedAt : game.moveTimes.getLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<LiveGameSummary> getTop(int limit) {
|
||||||
|
return gameStore.getAll().stream()
|
||||||
|
.sorted(Comparator.<BackendGame>comparingInt(game -> game.spectatorIds.size())
|
||||||
|
.thenComparingLong(LiveGames::lastActivity).reversed())
|
||||||
|
.limit(limit)
|
||||||
|
.map(LiveGames::toLiveGameSummary)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void dispose() {
|
||||||
|
if (pushTimer != null) pushTimer.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void subscribe(String userId) {
|
||||||
|
subscribers.add(userId);
|
||||||
|
rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, Map.of("games", getTop(LIVE_GAMES_PUSH_COUNT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void schedulePush() {
|
||||||
|
if (pushTimer != null) return;
|
||||||
|
long wait = Math.max(0, lastPushAt + PUSH_INTERVAL_MS - scheduler.now());
|
||||||
|
pushTimer = scheduler.schedule(() -> {
|
||||||
|
pushTimer = null;
|
||||||
|
push();
|
||||||
|
}, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void push() {
|
||||||
|
List<LiveGameSummary> games = getTop(LIVE_GAMES_PUSH_COUNT);
|
||||||
|
String serialized = Json.write(games);
|
||||||
|
if (serialized.equals(lastPushed)) return;
|
||||||
|
lastPushed = serialized;
|
||||||
|
lastPushAt = scheduler.now();
|
||||||
|
Map<String, Object> message = Map.of("games", games);
|
||||||
|
List.copyOf(subscribers).forEach(userId -> rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package app.playmancala.game;
|
||||||
|
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
import app.playmancala.rtmt.Rtmt;
|
||||||
|
|
||||||
|
// Pairs the next two players who ask for a game. Only touched on the game loop.
|
||||||
|
public class MatchMaker {
|
||||||
|
private final Rtmt rtmt;
|
||||||
|
private String waitingPlayerId;
|
||||||
|
private BiConsumer<String, String> onPlayersPaired = (player1Id, player2Id) -> { };
|
||||||
|
|
||||||
|
public MatchMaker(Rtmt rtmt) {
|
||||||
|
this.rtmt = rtmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void join(String newcomerPlayerId) {
|
||||||
|
if (newcomerPlayerId.equals(waitingPlayerId)) return;
|
||||||
|
if (waitingPlayerId != null && rtmt.isClientOnline(waitingPlayerId)) {
|
||||||
|
String waiting = waitingPlayerId;
|
||||||
|
waitingPlayerId = null;
|
||||||
|
onPlayersPaired.accept(waiting, newcomerPlayerId);
|
||||||
|
} else {
|
||||||
|
waitingPlayerId = newcomerPlayerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean cancel(String playerId) {
|
||||||
|
if (playerId.equals(waitingPlayerId)) {
|
||||||
|
waitingPlayerId = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void listenOnPlayersPaired(BiConsumer<String, String> onPlayersPaired) {
|
||||||
|
this.onPlayersPaired = onPlayersPaired;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package app.playmancala.history;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import app.playmancala.model.FinishedGameSummary;
|
||||||
|
import app.playmancala.model.GameRecord;
|
||||||
|
|
||||||
|
// Saved, finished games. Ids in records are public ids (see PublicIds). Methods block and throw when the database
|
||||||
|
// is unreachable.
|
||||||
|
public interface GameHistoryRepository {
|
||||||
|
// Saving the same game twice is a no-op, so failed saves can be retried.
|
||||||
|
void save(GameRecord record);
|
||||||
|
|
||||||
|
// Null if there is no such game.
|
||||||
|
GameRecord get(String id);
|
||||||
|
|
||||||
|
// The user's finished games, most recent first.
|
||||||
|
List<FinishedGameSummary> getRecentForUser(String userId, int limit);
|
||||||
|
|
||||||
|
// Finished games with the most spectators, most recent first among equals.
|
||||||
|
List<FinishedGameSummary> getPopular(int limit);
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
package app.playmancala.history;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
|
||||||
|
import app.playmancala.live.Cache;
|
||||||
|
import app.playmancala.model.FinishedGameSummary;
|
||||||
|
import app.playmancala.model.GameRecord;
|
||||||
|
|
||||||
|
// Reads finished games for the API, including ones not saved yet. Lists are cached briefly and come back without the
|
||||||
|
// saved games while the database is unreachable, so the home page still loads. Blocks; call it off the game loop.
|
||||||
|
public class GameHistoryService {
|
||||||
|
public static final int MAX_LIST_LIMIT = 20;
|
||||||
|
private static final Logger LOG = Logger.getLogger(GameHistoryService.class);
|
||||||
|
private static final long CACHE_TTL_SECONDS = 10;
|
||||||
|
private static final String POPULAR_KEY = "popular-finished";
|
||||||
|
private static final TypeReference<List<FinishedGameSummary>> SUMMARIES = new TypeReference<>() { };
|
||||||
|
|
||||||
|
private static final Comparator<FinishedGameSummary> BY_NEWEST =
|
||||||
|
Comparator.comparingLong(FinishedGameSummary::endedAt).reversed();
|
||||||
|
private static final Comparator<FinishedGameSummary> BY_POPULARITY =
|
||||||
|
Comparator.comparingInt(FinishedGameSummary::peakSpectators).reversed().thenComparing(BY_NEWEST);
|
||||||
|
|
||||||
|
private final GameHistoryRepository repository;
|
||||||
|
private final Cache cache;
|
||||||
|
// Games that ended but are not in the database yet (being saved, or waiting for a retry).
|
||||||
|
private final Supplier<List<GameRecord>> unsaved;
|
||||||
|
|
||||||
|
public GameHistoryService(GameHistoryRepository repository, Cache cache, Supplier<List<GameRecord>> unsaved) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.cache = cache;
|
||||||
|
this.unsaved = unsaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String recentKey(String userId) {
|
||||||
|
return "recent:" + userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null if there is no such game. Throws if the database cannot be asked.
|
||||||
|
public GameRecord get(String id) {
|
||||||
|
return unsaved.get().stream().filter(record -> record.id().equals(id)).findFirst()
|
||||||
|
.orElseGet(() -> repository.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FinishedGameSummary> getRecentForUser(String userId, int limit) {
|
||||||
|
List<FinishedGameSummary> saved = cached(recentKey(userId), () -> repository.getRecentForUser(userId, MAX_LIST_LIMIT));
|
||||||
|
List<GameRecord> unsavedGames = unsaved.get().stream().filter(record -> record.hasPlayer(userId)).toList();
|
||||||
|
return merge(saved, unsavedGames, BY_NEWEST, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FinishedGameSummary> getPopular(int limit) {
|
||||||
|
List<FinishedGameSummary> saved = cached(POPULAR_KEY, () -> repository.getPopular(MAX_LIST_LIMIT));
|
||||||
|
return merge(saved, unsaved.get(), BY_POPULARITY, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call when a game has been saved, so lists show it right away.
|
||||||
|
public void onGameSaved(GameRecord record) {
|
||||||
|
cache.delete(POPULAR_KEY, recentKey(record.player1().userId()), recentKey(record.player2().userId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FinishedGameSummary> cached(String key, Supplier<List<FinishedGameSummary>> load) {
|
||||||
|
Optional<List<FinishedGameSummary>> hit = cache.get(key, SUMMARIES);
|
||||||
|
if (hit.isPresent()) return hit.get();
|
||||||
|
try {
|
||||||
|
List<FinishedGameSummary> games = load.get();
|
||||||
|
cache.set(key, games, CACHE_TTL_SECONDS);
|
||||||
|
return games;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.errorf("(History) Could not load games: %s", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FinishedGameSummary> merge(List<FinishedGameSummary> saved, List<GameRecord> unsaved,
|
||||||
|
Comparator<FinishedGameSummary> order, int limit) {
|
||||||
|
Set<String> savedIds = saved.stream().map(FinishedGameSummary::id).collect(Collectors.toSet());
|
||||||
|
List<FinishedGameSummary> all = new ArrayList<>(saved);
|
||||||
|
unsaved.stream().filter(record -> !savedIds.contains(record.id())).map(GameRecord::toSummary).forEach(all::add);
|
||||||
|
all.sort(order);
|
||||||
|
return all.stream().limit(limit).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package app.playmancala.history;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
// Applies the migrations in src/main/resources/db/migrations that have not run yet, each in its own transaction.
|
||||||
|
// Same table and lock as the Node backend's runner, so databases it migrated are recognised.
|
||||||
|
public final class Migrations {
|
||||||
|
private static final Logger LOG = Logger.getLogger(Migrations.class);
|
||||||
|
private static final long MIGRATION_LOCK_ID = 727274;
|
||||||
|
|
||||||
|
// Applied in this order; never change a migration that has been released, add a new one instead.
|
||||||
|
public static final List<String> VERSIONS = List.of("001_games", "002_clock", "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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package app.playmancala.http;
|
||||||
|
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
import app.playmancala.auth.InvalidTokenException;
|
||||||
|
import app.playmancala.auth.TokenVerifier;
|
||||||
|
import app.playmancala.model.AuthUser;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
// Requires an "Authorization: Bearer <access token>" header with the same Keycloak token the WebSocket uses.
|
||||||
|
final class BearerAuth {
|
||||||
|
private static final Logger LOG = Logger.getLogger(BearerAuth.class);
|
||||||
|
private static final Pattern BEARER = Pattern.compile("^Bearer (.+)$");
|
||||||
|
|
||||||
|
private BearerAuth() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user, or a 401 response.
|
||||||
|
static AuthUser requireUser(TokenVerifier tokenVerifier, String authorization) {
|
||||||
|
Matcher match = BEARER.matcher(authorization == null ? "" : authorization);
|
||||||
|
if (!match.matches()) throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||||
|
try {
|
||||||
|
return tokenVerifier.verify(match.group(1));
|
||||||
|
} catch (InvalidTokenException e) {
|
||||||
|
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("(Auth) Token verification failed", e);
|
||||||
|
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package app.playmancala.http;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
|
||||||
|
|
||||||
|
import io.vertx.core.http.HttpServerRequest;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
// A failing dependency (the database, mostly) answers 503, as the Express error handler did.
|
||||||
|
public class ErrorMapper {
|
||||||
|
private static final Logger LOG = Logger.getLogger(ErrorMapper.class);
|
||||||
|
|
||||||
|
@ServerExceptionMapper
|
||||||
|
public Response map(RuntimeException error, HttpServerRequest request) {
|
||||||
|
if (error instanceof WebApplicationException webError) return webError.getResponse();
|
||||||
|
LOG.errorf("(HTTP) %s %s failed: %s", request.method(), request.path(), error.getMessage());
|
||||||
|
return Response.status(Response.Status.SERVICE_UNAVAILABLE).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package app.playmancala.http;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import app.playmancala.Backend;
|
||||||
|
import app.playmancala.game.BackendGame;
|
||||||
|
import app.playmancala.game.LiveGames;
|
||||||
|
import app.playmancala.history.GameHistoryService;
|
||||||
|
import app.playmancala.model.AuthUser;
|
||||||
|
import app.playmancala.model.GameRecord;
|
||||||
|
import app.playmancala.model.Json;
|
||||||
|
import app.playmancala.model.LiveGameSummary;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.HeaderParam;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.HttpHeaders;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
// The HTTP API. Blocking: runs on worker threads, reads live games on the game loop and saved games itself.
|
||||||
|
// Bodies are written with the shared mapper, so they are the same JSON as the WebSocket messages.
|
||||||
|
@Path("/")
|
||||||
|
public class GameResource {
|
||||||
|
private static final Pattern LEADING_INTEGER = Pattern.compile("^\\s*([+-]?\\d+)");
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Backend backend;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.TEXT_PLAIN)
|
||||||
|
public String root() {
|
||||||
|
return "Server up and running! ci test1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issues userKeys for legacy clients; token clients get their identity from Keycloak.
|
||||||
|
@GET
|
||||||
|
@Path("register{slash: /?}")
|
||||||
|
@Produces(MediaType.TEXT_PLAIN)
|
||||||
|
public String register() {
|
||||||
|
if (!backend.legacyUserKeyAuth()) throw new NotFoundException();
|
||||||
|
return UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live game as players receive it, or the saved record of a finished game.
|
||||||
|
@GET
|
||||||
|
@Path("game/{id}")
|
||||||
|
public Response game(@PathParam("id") String id) {
|
||||||
|
// Serialised on the loop, because the client game refers to the live engine objects.
|
||||||
|
String live = backend.loop().call(() -> {
|
||||||
|
BackendGame game = backend.gameStore().get(id);
|
||||||
|
return game == null ? null : Json.write(backend.gameManager().toClientGame(game));
|
||||||
|
});
|
||||||
|
if (live != null) return json(live);
|
||||||
|
GameRecord record = backend.history().get(id);
|
||||||
|
if (record == null) throw new NotFoundException();
|
||||||
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("status", "finished");
|
||||||
|
response.put("record", record);
|
||||||
|
return json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live games if any are being played, otherwise the most spectated finished games. With kind=finished always the
|
||||||
|
// finished games, for clients that get live games over the WebSocket.
|
||||||
|
@GET
|
||||||
|
@Path("games/popular")
|
||||||
|
public Response popular(@QueryParam("limit") String limitParam, @QueryParam("kind") String kind) {
|
||||||
|
int limit = parseLimit(limitParam, 5);
|
||||||
|
List<LiveGameSummary> live = "finished".equals(kind) ? List.of()
|
||||||
|
: backend.loop().call(() -> backend.liveGames().getTop(limit));
|
||||||
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
if (!live.isEmpty()) {
|
||||||
|
response.put("kind", "live");
|
||||||
|
response.put("games", live);
|
||||||
|
} else {
|
||||||
|
response.put("kind", "finished");
|
||||||
|
response.put("games", backend.history().getPopular(limit));
|
||||||
|
}
|
||||||
|
return json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("games/me/active")
|
||||||
|
public Response activeGame(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) {
|
||||||
|
AuthUser user = BearerAuth.requireUser(backend.tokenVerifier(), authorization);
|
||||||
|
LiveGameSummary game = backend.loop().call(() -> {
|
||||||
|
BackendGame active = backend.gameStore().getGameByUser(user.id());
|
||||||
|
return active == null ? null : LiveGames.toLiveGameSummary(active);
|
||||||
|
});
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("game", game);
|
||||||
|
return json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("games/me")
|
||||||
|
public Response myGames(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization,
|
||||||
|
@QueryParam("limit") String limit) {
|
||||||
|
AuthUser user = BearerAuth.requireUser(backend.tokenVerifier(), authorization);
|
||||||
|
return json(Map.of("games", backend.history().getRecentForUser(user.id(), parseLimit(limit, 3))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Response json(Object body) {
|
||||||
|
return Response.ok(body instanceof String text ? text : Json.write(body), MediaType.APPLICATION_JSON).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like parseInt in the Node backend: the leading integer, clamped to 1..MAX_LIST_LIMIT.
|
||||||
|
static int parseLimit(String value, int fallback) {
|
||||||
|
Matcher match = LEADING_INTEGER.matcher(value == null ? "" : value);
|
||||||
|
if (!match.find()) return fallback;
|
||||||
|
BigInteger limit = new BigInteger(match.group(1));
|
||||||
|
return limit.max(BigInteger.ONE).min(BigInteger.valueOf(GameHistoryService.MAX_LIST_LIMIT)).intValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/backend/src/main/java/app/playmancala/live/Cache.java
Normal file
14
apps/backend/src/main/java/app/playmancala/live/Cache.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package app.playmancala.live;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
|
||||||
|
// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value.
|
||||||
|
public interface Cache {
|
||||||
|
<T> Optional<T> get(String key, TypeReference<T> type);
|
||||||
|
|
||||||
|
void set(String key, Object value, long ttlSeconds);
|
||||||
|
|
||||||
|
void delete(String... keys);
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package app.playmancala.live;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
import app.playmancala.model.GameRecord;
|
||||||
|
|
||||||
|
// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished
|
||||||
|
// games that could not be saved to the database yet. Methods block and throw when Valkey is unreachable.
|
||||||
|
public interface LiveGameStore {
|
||||||
|
void saveGame(String gameId, Object snapshot);
|
||||||
|
|
||||||
|
void removeGame(String gameId);
|
||||||
|
|
||||||
|
// As stored; the caller checks what it can use.
|
||||||
|
List<JsonNode> loadGames();
|
||||||
|
|
||||||
|
void savePendingRecord(GameRecord record);
|
||||||
|
|
||||||
|
void removePendingRecord(String gameId);
|
||||||
|
|
||||||
|
List<GameRecord> loadPendingRecords();
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package app.playmancala.live;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
|
||||||
|
import app.playmancala.model.Json;
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource;
|
||||||
|
import io.quarkus.redis.datasource.value.SetArgs;
|
||||||
|
import io.quarkus.redis.datasource.value.ValueCommands;
|
||||||
|
|
||||||
|
public class ValkeyCache implements Cache {
|
||||||
|
private static final String PREFIX = "mancala:cache:";
|
||||||
|
|
||||||
|
private final RedisDataSource redis;
|
||||||
|
private final ValueCommands<String, String> values;
|
||||||
|
|
||||||
|
public ValkeyCache(RedisDataSource redis) {
|
||||||
|
this.redis = redis;
|
||||||
|
this.values = redis.value(String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> Optional<T> get(String key, TypeReference<T> type) {
|
||||||
|
try {
|
||||||
|
String value = values.get(PREFIX + key);
|
||||||
|
return value == null ? Optional.empty() : Optional.of(Json.MAPPER.readValue(value, type));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(String key, Object value, long ttlSeconds) {
|
||||||
|
try {
|
||||||
|
values.set(PREFIX + key, Json.write(value), new SetArgs().ex(ttlSeconds));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// A missing cache entry only costs a database query.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String... keys) {
|
||||||
|
if (keys.length == 0) return;
|
||||||
|
try {
|
||||||
|
redis.key().del(Arrays.stream(keys).map(key -> PREFIX + key).toArray(String[]::new));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Entries expire on their own.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package app.playmancala.live;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
import app.playmancala.model.GameRecord;
|
||||||
|
import app.playmancala.model.Json;
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource;
|
||||||
|
import io.quarkus.redis.datasource.keys.KeyScanArgs;
|
||||||
|
import io.quarkus.redis.datasource.value.ValueCommands;
|
||||||
|
|
||||||
|
// Keys and values are the Node backend's, so either backend reads what the other wrote.
|
||||||
|
public class ValkeyLiveGameStore implements LiveGameStore {
|
||||||
|
private static final Logger LOG = Logger.getLogger(ValkeyLiveGameStore.class);
|
||||||
|
private static final String LIVE_PREFIX = "mancala:live:";
|
||||||
|
private static final String PENDING_SAVE_PREFIX = "mancala:pending-save:";
|
||||||
|
|
||||||
|
private final RedisDataSource redis;
|
||||||
|
private final ValueCommands<String, String> values;
|
||||||
|
|
||||||
|
public ValkeyLiveGameStore(RedisDataSource redis) {
|
||||||
|
this.redis = redis;
|
||||||
|
this.values = redis.value(String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveGame(String gameId, Object snapshot) {
|
||||||
|
values.set(LIVE_PREFIX + gameId, Json.write(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeGame(String gameId) {
|
||||||
|
redis.key().del(LIVE_PREFIX + gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<JsonNode> loadGames() {
|
||||||
|
return loadAll(LIVE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void savePendingRecord(GameRecord record) {
|
||||||
|
values.set(PENDING_SAVE_PREFIX + record.id(), Json.write(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removePendingRecord(String gameId) {
|
||||||
|
redis.key().del(PENDING_SAVE_PREFIX + gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<GameRecord> loadPendingRecords() {
|
||||||
|
List<GameRecord> records = new ArrayList<>();
|
||||||
|
for (JsonNode json : loadAll(PENDING_SAVE_PREFIX)) {
|
||||||
|
try {
|
||||||
|
records.add(Json.MAPPER.treeToValue(json, GameRecord.class));
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.errorf("(Valkey) Ignoring unreadable pending record %s", json.path("id").asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<JsonNode> loadAll(String prefix) {
|
||||||
|
// SCAN rather than KEYS, which would block Valkey.
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
redis.key().scan(new KeyScanArgs().match(prefix + "*").count(100)).toIterable().forEach(keys::add);
|
||||||
|
if (keys.isEmpty()) return List.of();
|
||||||
|
Map<String, String> stored = values.mget(keys.toArray(String[]::new));
|
||||||
|
List<JsonNode> parsed = new ArrayList<>();
|
||||||
|
for (String key : keys) {
|
||||||
|
String value = stored.get(key);
|
||||||
|
if (value == null) continue;
|
||||||
|
try {
|
||||||
|
parsed.add(Json.parse(value));
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.errorf("(Valkey) Ignoring unreadable value in %s", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package app.playmancala.loop;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface Cancellable {
|
||||||
|
void cancel();
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package app.playmancala.loop;
|
||||||
|
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
// The one thread that owns all live game state: games, matchmaking, previews and the WebSocket registry. Like the
|
||||||
|
// Node backend's event loop, it lets that state go without locks. Never block it on I/O.
|
||||||
|
public final class GameLoop implements Scheduler, AutoCloseable {
|
||||||
|
private static final Logger LOG = Logger.getLogger(GameLoop.class);
|
||||||
|
|
||||||
|
private final ScheduledExecutorService executor;
|
||||||
|
private volatile Thread thread;
|
||||||
|
|
||||||
|
public GameLoop() {
|
||||||
|
executor = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||||
|
Thread loopThread = new Thread(runnable, "game-loop");
|
||||||
|
loopThread.setDaemon(true);
|
||||||
|
thread = loopThread;
|
||||||
|
return loopThread;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long now() {
|
||||||
|
return System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(Runnable task) {
|
||||||
|
executor.execute(guarded(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cancellable schedule(Runnable task, long delayMs) {
|
||||||
|
Future<?> future = executor.schedule(guarded(task), delayMs, TimeUnit.MILLISECONDS);
|
||||||
|
return () -> future.cancel(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) {
|
||||||
|
Future<?> future = executor.scheduleAtFixedRate(guarded(task), periodMs, periodMs, TimeUnit.MILLISECONDS);
|
||||||
|
return () -> future.cancel(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the task on the loop and waits for its result; for threads that need to read live state.
|
||||||
|
public <T> T call(Callable<T> task) {
|
||||||
|
if (Thread.currentThread() == thread) {
|
||||||
|
try {
|
||||||
|
return task.call();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return executor.submit(task).get();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException("Interrupted while waiting for the game loop", e);
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
if (e.getCause() instanceof RuntimeException runtime) throw runtime;
|
||||||
|
throw new IllegalStateException(e.getCause());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exception must not stop a repeating task or reach the executor, which would swallow it.
|
||||||
|
private static Runnable guarded(Runnable task) {
|
||||||
|
return () -> {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (Throwable e) {
|
||||||
|
LOG.error("(GameLoop) Task failed", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package app.playmancala.loop;
|
||||||
|
|
||||||
|
// What the game logic needs from the game loop: running tasks and timers on the thread that owns the game state, and
|
||||||
|
// the time. Tests use ManualScheduler, whose clock only moves when the test advances it.
|
||||||
|
public interface Scheduler {
|
||||||
|
// Epoch milliseconds.
|
||||||
|
long now();
|
||||||
|
|
||||||
|
void execute(Runnable task);
|
||||||
|
|
||||||
|
Cancellable schedule(Runnable task, long delayMs);
|
||||||
|
|
||||||
|
// First run after one period.
|
||||||
|
Cancellable scheduleAtFixedRate(Runnable task, long periodMs);
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
// A logged in user. `id` is also the player id in games; `name` is null for legacy userKey clients.
|
||||||
|
public record AuthUser(
|
||||||
|
String id,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
|
||||||
|
@JsonProperty("isGuest") boolean isGuest) {
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
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) {
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public enum EndReason {
|
||||||
|
@JsonProperty("completed") COMPLETED,
|
||||||
|
@JsonProperty("left") LEFT,
|
||||||
|
@JsonProperty("timeout") TIMEOUT,
|
||||||
|
// Only in games saved before the game clock.
|
||||||
|
@JsonProperty("inactive") INACTIVE;
|
||||||
|
|
||||||
|
// The value stored in the games.end_reason column.
|
||||||
|
public String wireName() {
|
||||||
|
return name().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EndReason fromWireName(String value) {
|
||||||
|
return valueOf(value.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||||
|
|
||||||
|
// A finished game in lists. `board` is the final board; `result` is null (and sent as null) without a winner.
|
||||||
|
@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
|
||||||
|
"moveCount", "peakSpectators", "clockInitialMs" })
|
||||||
|
public record FinishedGameSummary(
|
||||||
|
String id,
|
||||||
|
PlayerSummary player1,
|
||||||
|
PlayerSummary player2,
|
||||||
|
List<Integer> board,
|
||||||
|
GameResult result,
|
||||||
|
EndReason endReason,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
|
||||||
|
long startedAt,
|
||||||
|
long endedAt,
|
||||||
|
int moveCount,
|
||||||
|
int peakSpectators,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs) {
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
// The players' clocks (docs/specs/game-clock): each player's time left at the start of the current turn, and when
|
||||||
|
// that turn started. `turnStartedAt` is null when no clock runs, i.e. the game has ended.
|
||||||
|
public record GameClock(
|
||||||
|
long initialMs,
|
||||||
|
long player1Ms,
|
||||||
|
long player2Ms,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) Long turnStartedAt) {
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public enum GameResult {
|
||||||
|
@JsonProperty("player1") PLAYER1,
|
||||||
|
@JsonProperty("player2") PLAYER2,
|
||||||
|
@JsonProperty("draw") DRAW;
|
||||||
|
|
||||||
|
// The value stored in the games.result column.
|
||||||
|
public String wireName() {
|
||||||
|
return name().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GameResult fromWireName(String value) {
|
||||||
|
return value == null ? null : valueOf(value.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
public record GameUsersConnectionInfo(UserConnectionInfo user1ConnectionInfo, UserConnectionInfo user2ConnectionInfo) {
|
||||||
|
}
|
||||||
33
apps/backend/src/main/java/app/playmancala/model/Json.java
Normal file
33
apps/backend/src/main/java/app/playmancala/model/Json.java
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
// The JSON mapper for messages, snapshots, records and the cache. Unknown properties are ignored, so data written by
|
||||||
|
// a newer or older backend can still be read.
|
||||||
|
public final class Json {
|
||||||
|
public static final ObjectMapper MAPPER = new ObjectMapper()
|
||||||
|
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||||
|
|
||||||
|
private Json() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String write(Object value) {
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("Cannot serialise " + value.getClass().getSimpleName(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonNode tree(Object value) {
|
||||||
|
return MAPPER.valueToTree(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throws JsonProcessingException for text that is not JSON.
|
||||||
|
public static JsonNode parse(String text) throws JsonProcessingException {
|
||||||
|
return MAPPER.readTree(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
// A live game for previews. Ids are public ids.
|
||||||
|
public record LiveGameSummary(
|
||||||
|
String id,
|
||||||
|
PlayerSummary player1,
|
||||||
|
PlayerSummary player2,
|
||||||
|
List<Integer> board,
|
||||||
|
String turnPlayerId,
|
||||||
|
int spectatorCount,
|
||||||
|
int moveCount,
|
||||||
|
long startedAt) {
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import app.playmancala.engine.GameStep;
|
||||||
|
|
||||||
|
// A move of a saved game. `remainingMs` is the mover's clock after the move; null for games before the game clock.
|
||||||
|
public record MoveRecord(
|
||||||
|
String playerId,
|
||||||
|
int moveIndex,
|
||||||
|
List<Integer> boardSnapshot,
|
||||||
|
List<GameStep> gameSteps,
|
||||||
|
long at,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) Long remainingMs) {
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
// A player as captured when a game is created. In saved games and lists, `userId` is a public id (PublicIds).
|
||||||
|
public record PlayerSummary(
|
||||||
|
String userId,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
|
||||||
|
@JsonProperty("isGuest") boolean isGuest) {
|
||||||
|
|
||||||
|
public static PlayerSummary unknown(String userId) {
|
||||||
|
return new PlayerSummary(userId, null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public enum Seat {
|
||||||
|
@JsonProperty("player1") PLAYER1,
|
||||||
|
@JsonProperty("player2") PLAYER2
|
||||||
|
}
|
||||||
24
apps/backend/src/main/java/app/playmancala/model/Series.java
Normal file
24
apps/backend/src/main/java/app/playmancala/model/Series.java
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
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) {
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/backend/src/main/java/app/playmancala/rtmt/Rtmt.java
Normal file
24
apps/backend/src/main/java/app/playmancala/rtmt/Rtmt.java
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
256
apps/backend/src/main/java/app/playmancala/rtmt/RtmtServer.java
Normal file
256
apps/backend/src/main/java/app/playmancala/rtmt/RtmtServer.java
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
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