Compare commits

...

89 Commits

Author SHA1 Message Date
a07390c4cc Merge pull request 'Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch' (#3) from develop into main
Reviewed-on: #3
2026-10-17 03:23:37 +03:00
e763682483 Merge branch 'feature/rematch' into develop 2026-10-17 02:22:49 +03:00
a3fe708746 docs: describe rematches and series
The rematch spec is implemented; AGENTS.md lists it with notes on the
offers, the keyed game route and stale dev builds, TODO.md takes the
items left out.
2026-10-17 02:21:01 +03:00
79f68416ee feat(frontend): show the series above the game
From the second game of a series, a strip shows the series score and a
box per game in the winner's colour, with the stores, who moved first
and how it ended; a finished game opens its replay in a new tab. The
game over card shows the series score too. Spectators see names.
2026-10-17 02:15:53 +03:00
aa68f04b28 feat(frontend): offer a rematch on the game over card
The card asks for a rematch, shows a request from the opponent with
Rematch and No, waits with Cancel and says why a request ended. The game
page follows the next game, keyed by its id so it starts afresh. Play
again against the bot is a rematch. The score is two columns, each name
above its stones, the winner's stones stronger.
2026-10-17 02:09:22 +03:00
94fc7fcbc7 feat(core): add rematch channels, series types and texts
The four rematch channels, Series and SeriesGame, the series and first
seat of a game, getSeriesScore and the texts in English and Turkish.
2026-10-17 02:05:12 +03:00
af11fd1cf1 feat(backend): save the series of each game
Migration 004_series adds games.series_id with an index, so the history
can group a series later. Games saved before have none.
2026-10-17 02:04:24 +03:00
dbaa1d2492 feat(backend): let players ask for a rematch
RematchOffers keeps an offer open for ten minutes after a game nobody
left. A request goes to the opponent; when both have asked the next game
of the series starts with the other player moving first. Requests end on
no, withdrawal, a minute without answer, going offline or looking for
another game. The bot accepts at once.
2026-10-17 02:03:16 +03:00
ee9e5261eb feat(backend): keep the series a live game belongs to
Every game starts a series; a game created in a series gets its seat to
move first. Clients get the series and the first seat with the game, the
end message the series with the ended game; snapshots keep the series.
2026-10-17 02:00:44 +03:00
c56d1f2c33 docs(rematch): add the spec
Rematch from the game over card, alternating first moves and a series
strip with the score of the games the same two players played in a row.
2026-10-17 01:58:58 +03:00
4144805a95 docs(todo): correct the web app's known type errors
Only the react-dom/client error is left in the web app.
2026-10-16 16:24:49 +03:00
47b75b5456 Merge branch 'feature/tutorial' into develop 2026-10-16 16:22:02 +03:00
57fa54e77d fix(frontend): animate the lesson moves
The pit animator sends the same board model, changed, at every step, so
React skipped the lesson page's updates and only the first step showed.
Each update now gets a new model.
2026-10-16 16:05:39 +03:00
acd60816f7 docs: describe the tutorial
AGENTS.md and TODO.md notes for the tutorial, and the end-to-end results. A
move that ends the game is no longer explained as giving an extra turn.
2026-10-16 15:00:44 +03:00
636127dae4 feat(mobile): show the rules
A How to play screen with the same rules and examples as the web app, opened
from the help screen and from the game screen's help button.
2026-10-16 14:59:33 +03:00
f8ce8f215f feat(frontend): show hints in games against the bot
With hints on, the player's pits show whether their move gives an extra turn
or how many stones it takes, and a line under the board says what the last
move did and why. A Hints switch appears only in games against the bot.
2026-10-16 14:58:56 +03:00
d444447425 feat(frontend): offer the tutorial to newcomers and a practice game
The home page shows newcomers a banner to start the lessons; it goes away for
good after Not now, a lesson or a game. The lessons end with a practice game
against the Easy bot with hints on.
2026-10-16 14:55:51 +03:00
9beb8ba7d4 feat(frontend): add the lessons
Six lessons, one per rule, on the real board with the TypeScript engine: a
task, the move animated, praise or an explanation and a retry. Completed
lessons are remembered on the device.
2026-10-16 14:55:07 +03:00
8511e0b3f2 feat(frontend): show the rules
A How to play page with every rule and a before/after board computed by the
engine, a help button in the header, and the rules in a side panel over the
game page so a game can go on while a rule is looked up.
2026-10-16 14:53:18 +03:00
3a46833e86 feat(core): add the rules, lessons and move explanations
RULES defines every rule with an example and a lesson; getMoveOutcome works
out a move's effect with the engine, explainMove says what happened, and
describeMove now tells the kind of capture and how many stones it took.
2026-10-16 14:50:42 +03:00
9522224bf9 docs(tutorial): add the spec
Requirements, design and tasks for a rules page, interactive lessons and a
practice game with hints against the Easy bot.
2026-10-16 14:46:50 +03:00
a6a309b78d Merge branch 'feature/game-bot' into develop 2026-10-16 14:41:19 +03:00
f7a76c861e docs: describe the game bot
The bot in the backend README with the calibration command, AGENTS.md notes on
bot ids and test tags, and the open work in TODO.md.
2026-10-16 14:27:49 +03:00
d1918fed10 feat(mobile): offer games against the bot
The lobby offers the bot levels after 10 seconds without an opponent, the
home screen has Play with bot, the bot is shown with its level, and a new
game after a bot game is one at the same level.
2026-10-16 14:23:22 +03:00
e66c4a3a41 feat(frontend): offer games against the bot
The lobby offers the bot levels after 10 seconds without an opponent, the
home page has a Play with bot menu, and a game against the bot ends with
Play again at the same level.
2026-10-16 14:22:04 +03:00
32d1ec98e7 feat(core): add bot levels, the bot game channel and bot names
getPlayerDisplayName shows the bot as e.g. "Bot · Medium" from its id.
2026-10-16 14:21:06 +03:00
4a018c7b53 feat(backend): save how the bot played
Saved games against the bot keep the bot's level, strength and seed in a new
bot column, so the levels can later be tuned from real games.
2026-10-16 14:19:56 +03:00
f0b75e5cee feat(backend): play games against the bot
new_bot_game starts a game against the bot at the chosen level and ends the
search for an opponent. BotPlayer works out the bot's moves on its own thread
pool and plays them on the game loop after the previous move's animation and a
thinking time. Bot games keep their settings across restarts.
2026-10-16 14:18:45 +03:00
31b51d04af feat(backend): choose bot moves by strength
Negamax with alpha-beta on Position scores every move; the strength sets the
depth, the softmax temperature and the chance of a random move. The levels
are ranges of strengths drawn per game. A calibration tournament checks that
each level beats the one below.
2026-10-16 14:18:00 +03:00
f9a96fc5f5 feat(backend): add a compact board for searching moves
Position keeps the board as 14 stone counts and plays the Mangala rules on
it without allocating. Tested against MancalaGame with the recorded games and
random games.
2026-10-16 12:47:32 +03:00
91577c0898 docs(game-bot): add the spec
Requirements, design and tasks for playing against a bot with four levels
and a strength drawn per game.
2026-10-16 12:46:20 +03:00
f6506801d8 Merge branch 'feature/quarkus-backend' into develop 2026-10-16 12:08:57 +03:00
0903f61455 docs: describe the Java backend
The backend README, how to run and test it, what is easy to get wrong
with the game loop and the shared data formats, and the deployment note.
The web app was played against it in Chrome: a full game with a
spectator, a reload, a restart mid-game, the replay and a game lost on
time.
2026-10-16 11:53:13 +03:00
6aeff37eb8 feat(backend): replace the Node backend with the Quarkus backend
The Quarkus project moves to apps/backend and the Express sources go.
The image is built from the repo root as before, with Temurin 25, and
runs as a non-root user on port 6000. Compose passes the database as a
JDBC URL with the same MANCALA_DB_PASSWORD.
2026-10-16 11:47:40 +03:00
0c112eb716 feat(backend): serve the WebSocket protocol and the HTTP API
RtmtServer ports the WebSocket protocol (auth handshake, sockets per
user, ping) and runs on the game loop; the websockets-next endpoint at /
only forwards socket events. The HTTP routes, CORS and the access log
match the Express app. Backend wires everything and restores live games
before handling socket events. End-to-end tests play games over real
sockets against Postgres and Valkey.
2026-10-16 11:33:07 +03:00
d86fca8c54 feat(backend): save games in Postgres and live state in Valkey
The Node backend's migrations, runner, queries, keys and JSON, so the
same database and Valkey serve either backend. Integration tests run
Postgres and Valkey with Quarkus Dev Services, including a game row the
Node backend wrote and an unreachable Valkey.
2026-10-16 11:26:28 +03:00
219b50b491 feat(backend): port the game logic to Java
GameManager, matchmaking, the game clock, live game snapshots, saving
finished games and live previews, ported from the Node backend with its
tests. Snapshots keep the Node backend's format; a test restores one the
Node backend wrote. Valkey writes and saves run off the game loop.
2026-10-16 11:23:35 +03:00
f0246cca61 feat(backend): add the wire types, the game loop and token verification
Records for the JSON the clients read, leaving out only the fields
TypeScript leaves undefined. GameLoop is the single thread that owns live
state; ManualScheduler replaces Jest's fake timers in tests. Keycloak
tokens are verified with jose4j and the same checks as before.
2026-10-16 11:09:16 +03:00
f0ffd6ba87 feat(backend): add the Quarkus project with a Java port of the engine
Quarkus 3.40 on Java 25, built with the Maven wrapper. The engine is a
line-by-line port of packages/mancala.js; a parity test replays games
recorded with the TypeScript engine, including illegal moves, and
compares every board, turn, error and the game JSON.
2026-10-16 11:06:43 +03:00
ec833b4ec5 docs(backend): spec the move to Java and Quarkus
Requirements, design and tasks for rewriting the backend in Java 25 with
Quarkus while keeping the protocol, the API and the stored data.
2026-10-16 11:00:44 +03:00
5b0cddf416 docs: add publishing mancala.js to the TODO
The apps use the engine from the repo, so publishing the fixed engine to
npm is left as the last deployment step.
2026-10-16 10:52:15 +03:00
e81e0e0841 docs: update the TODO for the rule fixes
Remove the sowing and animation bugs, which are fixed, and add the
open question of who gets the remaining stones when a move empties
the opponent's side.
2026-10-16 10:49:29 +03:00
3ea2ba18e3 fix(core): empty the first pit when animating a single stone move
PitAnimator guessed the played pit of a single stone move from the
first sowing step and skipped index 0, so playing one stone from
player 1's first pit showed it still holding the stone until the
animation ended. It now empties the move's own pit (moveIndex).
2026-10-16 10:49:09 +03:00
27f13e2f9a fix(mancala.js): skip the opponent's store when sowing
Mangala skips the opponent's store, but Board.move dropped a stone in
every pit and store. Moves with enough stones to pass it gave the
opponent a stone and ended one pit too early, so captures and extra
turns were decided on the wrong pit.

Board.getSowingPath now computes the pits a move sows into, and
Board.move and core's getSowingPath (the web board's move preview)
both use it. Game step indexes are now always within the board.
2026-10-16 10:48:19 +03:00
d8ea5d00f8 Merge branch 'feature/keycloak-theme' into develop 2026-10-16 10:38:12 +03:00
6b08fef1e6 docs: document the web redesign
AGENTS.md notes on the themes, plain CSS for container queries and
driving the app in Chrome; TODO.md gets the mobile app and rematch as
open work; the spec is marked implemented.
2026-10-16 10:26:59 +03:00
9619825605 feat(frontend): lay out the home page in the new design
The game in progress comes first with a large mini board; live,
popular and recent games are grids of cards with the score, a mini
board and a status pill. Live and popular games are shown together;
live games are hidden while nothing is being played.
2026-10-16 10:21:46 +03:00
ce27c71176 feat(frontend): lay out the replay page in the new design
A summary card with the result, date, duration, move count, players and
score; players and the move number beside the board; a controls card
with the transport buttons, speed, timeline and the shown move with its
tags; the landing pit of the shown move is ringed.
2026-10-16 10:19:57 +03:00
efe97da0b7 feat(frontend): lay out the game page in the new design
Players beside the board with avatar, online status, store count and
clock, the turn between them, a game-over card over the board, and a
move list with the newest move first and tags for extra turns,
captures and store gains. On phones the bottom player is shown below
the board.
2026-10-16 09:41:47 +03:00
e3e7916265 feat(frontend): draw the new board
Pits and stores are wells with scattered stones and count badges, the
viewer's store on the right. Below 600 px the board stands upright.
Playable pits preview their move on hover, animated pits are ringed and
the pit of the last move has a dashed ring.
2026-10-16 09:37:50 +03:00
6279b5a820 feat(frontend): add the new themes and header
Ceviz, Gece and Yeşim themes as CSS variables on <html>, Bricolage
Grotesque, shared styles, and one header for every page with theme
swatches, the account menu and the page's main action. The lobby,
privacy page and status panels use the new look.
2026-10-16 09:35:39 +03:00
e0c66a1265 fix(core): save the chosen theme
ThemeManager called storage.set, which neither app's storage has, so
choosing a theme threw and the choice was lost on reload.
2026-10-16 09:32:10 +03:00
193efd1458 feat(backend): list popular finished games while games are live
GET /games/popular?kind=finished returns the most watched finished
games even when games are being played, so the web app can show live
and popular games together.
2026-10-16 09:31:44 +03:00
8ad833cdbb feat(core): add what the web redesign needs
ThemeManager takes its list of themes, PitAnimator marks animated pits
with PitViewModel.highlight, getSowingPath lists the pits a move sows
into, and new texts in English and Turkish.
2026-10-16 09:31:28 +03:00
61d169ecdc docs(web-redesign): add the spec
Requirements, design and tasks for implementing the new web design
(themes, board, game, replay and home pages).
2026-10-16 09:30:28 +03:00
3cf789fd5d chore(mobile): bump version to 0.5.1 (9) 2026-10-16 09:26:02 +03:00
08fc5c8b7b fix(frontend): confirm leaving a game with Yes and go home
The dialog's buttons were in the wrong order and its result was read inverted; leaving now needs Yes and returns to the home page.
2026-10-16 09:26:02 +03:00
b472c2813c docs: document the game clock
Backend README (GAME_CLOCK_MS), AGENTS.md notes, TODO items for the open points and the mobile release order; game-history R3.3 and R3.4 marked as replaced. Checked end to end in Chrome: moves, a backend restart, a timeout and the replay.
2026-10-16 01:54:01 +03:00
f9ea00d138 feat(mobile): show the game clocks
Both clocks next to the players, counting down in server time with the same running and low-time styles as the web app. The game screen now handles on_game_end, so a game that ends on time ends on the phone too and says why.
2026-10-16 01:50:39 +03:00
8671bb2553 feat(frontend): show the clocks in replays
Each player's clock after the shown move, from the saved move times; games saved before the game clock show none.
2026-10-16 01:40:05 +03:00
20359b7a8f feat(frontend): show the game clocks in live games
Both clocks sit next to the players' names and count down in server time; the running one is highlighted and turns red under 30 seconds. They replace the offline countdown, so core drops TURN_TIMEOUT_MS, INACTIVE_TIMEOUT_MS, offlineSince and its text. Long info texts wrap on phones.
2026-10-16 01:39:36 +03:00
c610844e26 feat(backend): keep the game clock across restarts and in saved games
Live game snapshots become version 2 with the clock and each move's time left. On restore the running clock goes on from the start of the turn, so downtime is not charged; version 1 snapshots get a new clock. Saved games keep the initial time (migration 002_clock) and each move's time left.
2026-10-16 01:30:39 +03:00
3d4f5d7d59 feat(backend): end games on time with a scheduled service
GameClockScheduler checks every live game once a second and ends those whose running clock has reached zero, whether or not anyone is connected. The clock replaces the offline turn timeout and the inactivity rule, so both are removed with the offline times they used.
2026-10-16 01:29:22 +03:00
999ae79279 feat(backend): run a game clock for each player
Each player starts with GAME_CLOCK_MS (5 minutes). A move deducts the turn's time from the mover; a move that arrives after the time has run out is not played and the opponent wins on time. Every ending stops the clock, and clients get the clock and the server time with each game message.
2026-10-16 01:27:28 +03:00
03e1543d5d feat(core): add game clock types and helpers
GameClock and optional clock fields on live games, end messages, move records and finished games; getClockTimes, ClockSync, getClockAtMove and formatClock with tests.
2026-10-16 01:25:15 +03:00
cb34c03246 docs: add game clock spec
Each player has 5 minutes; the server keeps the time and a scheduled service ends games whose running clock reaches zero. Replaces the offline turn and inactivity rules.
2026-10-16 01:23:32 +03:00
6e6d37650d feat(frontend): open Keycloak pages in the app's language
Login and sign-up redirects pass the current language as ui_locales.
2026-10-16 01:13:20 +03:00
d52960fa0a feat(keycloak): add a login theme in the game's look
The mancala login theme extends keycloak.v2 with one stylesheet: the game's
app bar and logo, the board's colors, pit-shaped inputs, pill buttons and a
board with stones on top of the card. Dark colors follow the system color
scheme. The realm turns the theme on and offers English and Turkish.

The test container now loads the theme and checks that the login page uses
it in the requested language.
2026-10-16 01:13:20 +03:00
bb32989279 docs: add AGENTS.md with setup, checks and pitfalls, and CLAUDE.md pointing to it 2026-10-16 00:44:09 +03:00
862c57da5e Merge branch 'feature/keycloak-auth' into feature/game-history 2026-10-16 00:40:57 +03:00
ab2424b648 docs: add Keycloak login spec
Requirements, design decisions and tasks for the Keycloak login, written
after the implementation; mobile login and turning off legacy userKey
login remain open.
2026-10-16 00:40:57 +03:00
b94c9336dd docs: document game history and update the TODO
- Backend README: DATABASE_URL, VALKEY_URL, the new routes and how the
  backend behaves when Postgres or Valkey is down
- TODO: drop the fixed spectator and memory bugs, add game history on
  mobile, a page with all games, single-instance limits, backups and the
  new .env variables
- Spec marked as implemented, all tasks ticked
2026-10-16 00:36:48 +03:00
3bb151dbed feat(frontend): replay finished games move by move
- Opening a finished game shows its final position, result, score, date
  and duration, with Watch from start
- First / previous / play-pause / next / last, speed 1x, 2x, 4x, a
  timeline, the move list and arrow keys and space
- Stepping forward animates the move like a live game; jumps show the
  position directly
- ?move=n links to a move and follows the shown move
- Players see the board from their own side
- PitAnimator takes its step interval as a parameter
2026-10-16 00:34:49 +03:00
aa265f7e6c feat(frontend): show the moves of a live game and let players look back
- Move list under the board with player, pit and what happened (extra
  turn, capture, stones to the store, game over); collapsed on phones
- Selecting a move or using the arrow keys shows that position with a
  banner and Back to live; pits are locked meanwhile and the banner says
  when it is your turn
- Countdown while the opponent is offline on their turn
- on_game_end is handled, so games that end by timeout or inactivity end
  on the page with the reason
2026-10-16 00:31:43 +03:00
a65662f695 feat(frontend): show ongoing, live, popular and recent games on the home page
- Banner for the user's game in progress with a Continue button
- Live games as cards with the real board, updated over WebSocket; the
  most watched finished games when nothing is live
- The user's last 3 games, from their side of the board, with the result
  as they would say it
- The lobby offers to continue the game in progress when a new one is
  refused
- Spectators register once connected and identified, and again after a
  reconnect, so they are counted
- The backend includes games that ended but are not saved yet in /game/:id
  and the lists
2026-10-16 00:27:06 +03:00
9278ed7447 feat(frontend): let the board render at any width
Board components size themselves from a --board-unit CSS variable instead
of vw. By default it is 1vw, so the game page looks the same; with the new
width prop the same board renders small, e.g. in previews.
2026-10-16 00:21:53 +03:00
85e3fb60f6 feat(backend): add game history API and live game previews
- GET /games/me/active and /games/me (Bearer token), /games/popular (live
  games, or the most spectated finished ones), and /game/:id now also
  returns saved records of finished games
- Lists are cached in Valkey for 10 seconds, cleared when a game is saved,
  and empty instead of failing while the database is down
- Live game previews over WebSocket: subscribers get the top 5 on
  subscribe and on change, at most once per second, without counting as
  spectators; ids are public ids
- RTMT supports several connection listeners
2026-10-16 00:21:09 +03:00
7239bbb6ed feat(backend): save every game and keep live games across restarts
- Games record start and move times, spectator peak and distinct count
- Games end as completed, left (the leaver loses), timeout (5 minutes
  offline on your turn while the opponent is online) or inactive (both
  offline for 30 minutes); everyone gets on_game_end
- Connection info carries offlineSince for the countdown
- A new game is rejected while the user has one in progress
- Live games are written to Valkey after every change and restored on
  startup; finished games are saved to Postgres with public ids, and
  saves that fail are kept and retried every 30 seconds
- Clients get a trimmed game without spectator ids
- Startup waits for Valkey, restores games, then accepts connections;
  migrations are retried while Postgres is unreachable
2026-10-16 00:18:33 +03:00
f6136be007 feat(backend): keep live games and caches in Valkey
- Valkey client that fails commands at once while Valkey is down instead
  of queueing them, and logs connection errors once
- ValkeyLiveGameStore: live game snapshots and finished games waiting to
  be saved, loaded with SCAN on startup
- ValkeyCache: JSON values with a TTL; errors count as cache misses
- Integration tests against a real Valkey, including an unreachable one
2026-10-16 00:14:07 +03:00
f404b92aaf feat(backend): save finished games in Postgres
- Connection pool and a small migration runner (schema_migrations, one
  transaction per migration, advisory lock)
- games and game_players tables with indexes for a user's games and for
  popular games
- PostgresGameHistoryRepository: idempotent save, get, recent games of a
  user, popular games
- Integration tests against a real Postgres via testcontainers; a custom
  Jest environment gives tests Node's web globals, which Jest 27 leaves out
- GameRecord carries the number of distinct spectators
2026-10-16 00:11:12 +03:00
ed2a909f16 fix(backend): clean up spectators and finished games
- Spectators are removed from games when they disconnect; the lookup
  iterated an iterator with for...in and never found anything
- Finished and left games are removed from memory, not only from the
  player mappings
- The connection info is sent once when a game starts
2026-10-16 00:08:38 +03:00
16026fb6cb feat(core): add game history types and move history helpers
- PlayerSummary, MoveRecord, live and finished game summaries, GameRecord,
  timeout constants, startedAt/moveTimes on Game and offlineSince on
  UserConnectionInfo
- Channels for live game previews and for rejecting a new game while one
  is in progress
- getInitialBoard, getBoardAfterMove, describeMove and createGameAtMove,
  with tests against games played with the engine
- English and Turkish strings for the new screens
2026-10-16 00:07:36 +03:00
29fa443b93 build: add shared Postgres and Valkey to compose
- postgres replaces keycloak-db and holds a keycloak and a mancala database,
  each with its own user, created by apps/postgres/init
- valkey with an append-only file for live game state and caches
- backend gets DATABASE_URL and VALKEY_URL
2026-10-16 00:05:21 +03:00
d0004742f5 docs: add game history and live games spec 2026-10-16 00:03:32 +03:00
20efa40e6c docs: add TODO with mobile login and open issues 2026-10-14 09:13:52 +03:00
f5dcf930fa feat(frontend): log in with Keycloak, as a guest or with an account
- First visit logs in as a new guest through Keycloak without a form;
  the session, including an offline refresh token, is kept in local storage
- Account menu on the home page: guests can log in or sign up, users can
  log out (which starts a new guest session)
- The WebSocket client in core authenticates with the access token after
  connecting, reports "connected" once the server accepts it, and re-sends
  renewed tokens on the open connection
- Game page shows player names; guests appear as "Guest 4F2A"
- Auth channels, AuthUser and player names in UserConnectionInfo move to
  core and the backend uses them from there
- Mobile keeps using userKey login for now
2026-10-14 02:22:13 +03:00
e165b0a0eb build: use local mancala.js and @mancala/core packages
- Apps and @mancala/core depend on the packages in this repo through
  link: dependencies instead of the npm releases, so changes no longer
  need a publish
- Root package.json gets a build:packages script that installs and
  builds both packages
- Backend and frontend Docker images are built from the repo root so
  they can include the packages; compose builds them that way
- Metro watches the packages folder and resolves their imports from the
  app's node_modules
2026-10-14 02:09:05 +03:00
30ad51c493 feat(backend): authenticate WebSocket clients with Keycloak tokens
- New auth module verifies Keycloak access tokens offline against the
  realm's signing keys (issuer, RS256, typ=Bearer, allowed azp); guests
  are recognised by the guest realm role
- Clients send an auth message after connecting and get on_auth or
  on_auth_error; unauthenticated sockets are closed after 5 seconds and
  their messages ignored; a socket can refresh its token but not switch user
- A user can be connected from several sockets; a late close of an old
  socket no longer drops the new one
- Malformed messages are ignored instead of throwing
- Games store player names and guest flags and send them with the
  connection info
- Legacy ?userKey= clients and /register stay available behind
  LEGACY_USERKEY_AUTH; legacy keys must be UUIDs so they cannot take a
  Keycloak user's kc: id
- TypeScript target raised to es2020 for async/await
2026-10-14 01:52:07 +03:00
7c99a76af7 feat(keycloak): add Keycloak with guest login extension
- Custom authenticator creates and logs in a guest user when the
  authorization request has guest=true; other requests get the login form
- Scheduled task removes guests with no remaining sessions
- Realm export with mancala-web PKCE client, guest role and guest-aware
  browser flow (verify profile disabled so guests are not asked for details)
- Dockerfile building the extension into a Keycloak 26.7.5 image
- Keycloak and Postgres in compose; nginx proxies auth.playmancala.app
  and rate limits the auth endpoint
- Integration tests run the extension and realm in a Keycloak container
2026-10-14 01:46:48 +03:00
323 changed files with 18923 additions and 5793 deletions

8
.dockerignore Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
The instructions for this repository are in AGENTS.md, shared by all coding agents:
@AGENTS.md

View File

@ -1,3 +1,43 @@
### Mancala
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
View 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
View File

@ -0,0 +1 @@
.env

View File

@ -1 +0,0 @@
node_modules

View File

@ -1,40 +1,9 @@
lib-cov
*.seed
*.log
*.csv
*.dat
*.out
*.pid
*.gz
*.swp
pids
logs
results
tmp
# Build
public/css/main.css
# Coverage reports
coverage
# API keys and secrets
.env
# Dependency directory
node_modules
bower_components
# Editors
target/
.idea
*.iml
.vscode
.factorypath
# OS metadata
.DS_Store
Thumbs.db
# Ignore built ts files
dist/**/*
crashes
# Written at runtime
access*.log
crashes/

View File

@ -0,0 +1,4 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce

View File

@ -1,25 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/src/index.ts",
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
},
{
"type": "node",
"request": "launch",
"name": "Jest Current File",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["${relativeFile}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"windows": {
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
}
}
]
}

View File

@ -1,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
# Create app directory
WORKDIR /usr/src/app
COPY package.json package.json
COPY yarn.lock yarn.lock
RUN yarn
COPY . .
RUN yarn build
# The app writes access.log and crashes/ to its working directory.
RUN useradd --system --create-home mancala && install -d -o mancala /home/mancala/app
WORKDIR /home/mancala/app
COPY --from=build --chown=mancala /build/target/quarkus-app/ ./
USER mancala
EXPOSE ${PORT}
CMD [ "yarn", "start"]
CMD ["java", "-jar", "quarkus-run.jar"]

View File

@ -1,28 +1,123 @@
# Mancala Backend
This is the backend code for the Mancala project, written in Express.js.
The game server: matchmaking, live games over a WebSocket, saved games and a small HTTP API. Java 25 and Quarkus 3.40,
built with Maven. The design and the reasons for it are in `docs/specs/quarkus-backend`.
## Getting Started
## Getting started
### Install Packages
You need a JDK 25 (e.g. Temurin); Maven comes with the wrapper. Postgres, Valkey and Keycloak run with Docker Compose,
see the README in the repo root.
```bash
yarn install
```sh
./mvnw quarkus:dev # http://localhost:5005; a debugger can attach on 5006
./mvnw test # needs Docker: Postgres and Valkey run with Quarkus Dev Services
./mvnw package # target/quarkus-app/quarkus-run.jar
java -jar target/quarkus-app/quarkus-run.jar
```
### Start in Development Mode
The Docker image is built from the repo root: `docker build -f apps/backend/Dockerfile .`. It listens on port 6000.
```bash
yarn dev
## Code
| Package | What |
|---|---|
| `engine` | The game rules, a port of `packages/mancala.js`; `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
yarn build
yarn start
Clients log in with Keycloak (see `apps/keycloak`), either as a guest or with an account, and send the access token
over the WebSocket right after connecting:
```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.

View File

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

View File

@ -1,6 +0,0 @@
module.exports = {
transform: {'^.+\\.ts?$': 'ts-jest'},
testEnvironment: 'node',
testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node']
};

295
apps/backend/mvnw vendored Executable file
View File

@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
apps/backend/mvnw.cmd vendored Normal file
View File

@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

View File

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

View File

@ -0,0 +1,61 @@
// Plays seeded random games with the TypeScript engine (packages/mancala.js) and writes every move and its outcome,
// so EngineParityTest can check that the Java engine plays exactly the same. Run it again after changing the rules:
//
// yarn --cwd ../../packages/mancala.js build && node scripts/record-engine-games.js
//
// Moves include illegal ones (the wrong player, an empty pit, a store, out of range) and moves after the end.
const fs = require("fs");
const path = require("path");
const { CommonMancalaGame } = require("../../../packages/mancala.js");
const GAME_COUNT = 15;
const MAX_MOVES = 400;
const OUTPUT = path.join(__dirname, "../src/test/resources/engine/recorded-games.json");
// mulberry32
function random(seed) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function recordGame(number) {
const next = random(number + 1);
const pick = (items) => items[Math.floor(next() * items.length)];
const game = new CommonMancalaGame(`game-${number}`, `kc:player1-${number}`, `kc:player2-${number}`);
game.turnPlayerId = next() < 0.5 ? game.player1Id : game.player2Id;
const recorded = {
id: game.id, player1Id: game.player1Id, player2Id: game.player2Id, turnPlayerId: game.turnPlayerId, moves: [],
};
let movesAfterEnd = 0;
for (let i = 0; i < MAX_MOVES && movesAfterEnd < 2; i++) {
const roll = next();
const opponent = game.turnPlayerId === game.player1Id ? game.player2Id : game.player1Id;
const playerId = roll < 0.93 ? game.turnPlayerId : opponent;
const pitsWithStones = [0, 1, 2, 3, 4, 5].filter((pit) =>
game.board.pits[game.getBoardIndexByPlayerId(playerId, pit)].stoneCount > 0);
const pitIndex = roll < 0.85 && pitsWithStones.length > 0 ? pick(pitsWithStones)
: roll < 0.97 ? Math.floor(next() * 6) : pick([-1, 6, 7, 13, 14]);
const move = { playerId, pitIndex };
try {
game.moveByPlayerPit(playerId, pitIndex);
} catch (err) {
move.error = err.message;
}
Object.assign(move, { board: game.board.getStoneArray(), turnPlayerId: game.turnPlayerId, state: game.state });
recorded.moves.push(move);
if (game.state === "ended") movesAfterEnd++;
}
recorded.wonPlayerId = game.getWonPlayerId() ?? null;
recorded.game = JSON.parse(JSON.stringify(game));
return recorded;
}
const games = Array.from({ length: GAME_COUNT }, (_, number) => recordGame(number));
fs.writeFileSync(OUTPUT, JSON.stringify({ games }) + "\n");
const moves = games.reduce((sum, game) => sum + game.moves.length, 0);
console.log(`Recorded ${games.length} games, ${moves} moves, to ${path.relative(process.cwd(), OUTPUT)}`);

View File

@ -1 +0,0 @@
export const RTMT_WS_PING_INTERVAL = 2000;

View File

@ -1,38 +0,0 @@
import fs from "fs";
import { Game } from "@mancala/core";
import { BackendGame } from "../models/Game";
const crashFolder = "./crashes";
export class GameCrashManager {
constructor() {}
static createCrashFolderIfNotExist(): void {
if (!fs.existsSync(crashFolder)) {
fs.mkdirSync(crashFolder);
}
}
static getTime(): string {
let date_ob = new Date();
let date = ("0" + date_ob.getDate()).slice(-2);
let month = ("0" + (date_ob.getMonth() + 1)).slice(-2);
let year = date_ob.getFullYear();
let hours = date_ob.getHours();
let minutes = date_ob.getMinutes();
let seconds = date_ob.getSeconds();
let miliSeconds = date_ob.getMilliseconds();
return `${year}-${month}-${date}-${hours}:${minutes}:${seconds}:${miliSeconds}`;
}
public static logGameCrash(error: Error, game: BackendGame): string {
this.createCrashFolderIfNotExist();
const crash = {
message: error.message,
stack: error.stack,
game,
};
const crashStr = JSON.stringify(crash);
const crashFileName = `${game.id}::${this.getTime()}`;
fs.writeFile(`${crashFolder}/${crashFileName}`, crashStr, () => {});
return crashFileName;
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,87 @@
package app.playmancala.auth;
import java.util.List;
import java.util.Map;
import org.jose4j.http.Get;
import org.jose4j.jwa.AlgorithmConstraints;
import org.jose4j.jwk.HttpsJwks;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver;
import org.jose4j.keys.resolvers.VerificationKeyResolver;
import app.playmancala.model.AuthUser;
// Verifies Keycloak access tokens offline against the realm's published signing keys. Player ids are the token
// subject prefixed with "kc:", so they can never collide with ids that legacy userKey clients choose for themselves.
public class KeycloakTokenVerifier implements TokenVerifier {
public static final String KEYCLOAK_USER_ID_PREFIX = "kc:";
public static final String GUEST_ROLE = "guest";
private static final int JWKS_TIMEOUT_MS = 5_000;
// Keys are cached this long, and kept this long while Keycloak cannot be reached.
private static final long JWKS_CACHE_SECONDS = 600;
// A token with an unknown key id refetches the keys at most this often.
private static final long JWKS_REFRESH_COOLDOWN_MS = 30_000;
private final List<String> allowedClientIds;
private final JwtConsumer consumer;
// `jwksUrl` defaults to the issuer's certs endpoint.
public static KeycloakTokenVerifier create(String issuer, List<String> allowedClientIds, String jwksUrl) {
HttpsJwks jwks = new HttpsJwks(jwksUrl != null ? jwksUrl : issuer + "/protocol/openid-connect/certs");
Get get = new Get();
get.setConnectTimeout(JWKS_TIMEOUT_MS);
get.setReadTimeout(JWKS_TIMEOUT_MS);
jwks.setSimpleHttpGet(get);
jwks.setDefaultCacheDuration(JWKS_CACHE_SECONDS);
jwks.setRetainCacheOnErrorDuration(JWKS_CACHE_SECONDS * 1000);
jwks.setRefreshReprieveThreshold(JWKS_REFRESH_COOLDOWN_MS);
return new KeycloakTokenVerifier(issuer, allowedClientIds, new HttpsJwksVerificationKeyResolver(jwks));
}
public KeycloakTokenVerifier(String issuer, List<String> allowedClientIds, VerificationKeyResolver keys) {
this.allowedClientIds = List.copyOf(allowedClientIds);
this.consumer = new JwtConsumerBuilder()
.setExpectedIssuer(issuer)
.setRequireSubject()
.setRequireExpirationTime()
// Keycloak access tokens are checked by azp below, not by audience.
.setSkipDefaultAudienceValidation()
.setJwsAlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.RSA_USING_SHA256)
.setVerificationKeyResolver(keys)
.build();
}
@Override
public AuthUser verify(String token) throws InvalidTokenException {
JwtClaims claims;
try {
claims = consumer.processToClaims(token);
} catch (InvalidJwtException e) {
throw new InvalidTokenException(e.getMessage());
}
// Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens.
if (!"Bearer".equals(claims.getClaimValue("typ"))) {
throw new InvalidTokenException("not an access token");
}
Object azp = claims.getClaimValue("azp");
if (!(azp instanceof String clientId) || !allowedClientIds.contains(clientId)) {
throw new InvalidTokenException("token issued to an unknown client");
}
Object username = claims.getClaimValue("preferred_username");
return new AuthUser(KEYCLOAK_USER_ID_PREFIX + claims.getClaimValue("sub"),
username instanceof String name ? name : null, hasGuestRole(claims.getClaimValue("realm_access")));
}
private static boolean hasGuestRole(Object realmAccess) {
return realmAccess instanceof Map<?, ?> access && access.get("roles") instanceof List<?> roles
&& roles.contains(GUEST_ROLE);
}
}

View File

@ -0,0 +1,8 @@
package app.playmancala.auth;
import app.playmancala.model.AuthUser;
public interface TokenVerifier {
// The token's user. May block (fetching signing keys); call it off the game loop.
AuthUser verify(String token) throws InvalidTokenException;
}

View File

@ -0,0 +1,23 @@
package app.playmancala.auth;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton;
public class TokenVerifierProducer {
@Produces
@Singleton
TokenVerifier tokenVerifier(
@ConfigProperty(name = "mancala.auth.issuer") String issuer,
@ConfigProperty(name = "mancala.auth.jwks-url") Optional<String> jwksUrl,
@ConfigProperty(name = "mancala.auth.client-ids") String clientIds) {
// Comma-separated; spaces and empty entries are ignored, as in the Node backend.
List<String> allowedClientIds = Arrays.stream(clientIds.split(",")).map(String::trim).filter(id -> !id.isEmpty()).toList();
return KeycloakTokenVerifier.create(issuer, allowedClientIds, jwksUrl.orElse(null));
}
}

View File

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

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

View File

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

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
package app.playmancala.engine;
import com.fasterxml.jackson.annotation.JsonIgnore;
public class Bank extends Pit {
public Bank(int index, int stoneCount) {
super(index, stoneCount);
}
@Override
@JsonIgnore
public boolean isBank() {
return true;
}
}

View File

@ -0,0 +1,221 @@
package app.playmancala.engine;
import java.util.ArrayList;
import java.util.List;
import java.util.function.IntConsumer;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// Port of packages/mancala.js/src/core/Board.ts. Pits are indexed counterclockwise: player 1's pits, player 1's bank,
// player 2's pits, player 2's bank.
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonPropertyOrder({ "pits", "playerPitCount", "initialStoneCountInPits" })
public class Board {
private static final int BANK_COUNT = 2;
private final List<Pit> pits;
private final int playerPitCount;
private final int initialStoneCountInPits;
@JsonIgnore
IntConsumer onGameMoveStart = index -> { };
@JsonIgnore
IntConsumer onGameMove = index -> { };
@JsonIgnore
IntConsumer onGameMoveEnd = index -> { };
public Board(int playerPitCount, int initialStoneCountInPits) {
this(playerPitCount, initialStoneCountInPits, null);
}
public Board(int playerPitCount, int initialStoneCountInPits, List<Pit> pits) {
this.playerPitCount = playerPitCount;
this.initialStoneCountInPits = initialStoneCountInPits;
this.pits = pits != null ? pits : createPits(initialStoneCountInPits);
}
private List<Pit> createPits(int initialStoneCountInPits) {
List<Pit> pitList = new ArrayList<>(playerPitCount * 2 + BANK_COUNT);
for (int index = 0; index < totalPitCount(); index++) {
PitType pitType = getPitTypeByIndex(index);
pitList.add(pitType.isBank() ? new Bank(index, 0) : new Pit(index, initialStoneCountInPits));
}
return pitList;
}
public List<Pit> getPits() {
return pits;
}
public int getPlayerPitCount() {
return playerPitCount;
}
public int getInitialStoneCountInPits() {
return initialStoneCountInPits;
}
public int player1PitStartIndex() {
return 0;
}
public int player1PitStopIndex() {
return player1PitStartIndex() + playerPitCount - 1;
}
public int player1BankIndex() {
return player1PitStopIndex() + 1;
}
public int player2PitStartIndex() {
return player1BankIndex() + 1;
}
public int player2PitStopIndex() {
return player2PitStartIndex() + playerPitCount - 1;
}
public int player2BankIndex() {
return player2PitStopIndex() + 1;
}
public int totalPitCount() {
return player2BankIndex() + 1;
}
public boolean checkIndex(int index) {
return index >= player1PitStartIndex() && index <= player2BankIndex();
}
void checkIndexAndMaybeThrowError(int index) {
if (!checkIndex(index)) {
throw new IllegalStateException("IndexOutOfRange => index : " + index + " [start, stop] = ["
+ player1PitStartIndex() + ", " + player2BankIndex() + "]");
}
}
public PitType getPitTypeByIndex(int index) {
checkIndexAndMaybeThrowError(index);
if (index >= player1PitStartIndex() && index <= player1PitStopIndex()) {
return PitType.PLAYER1_PIT;
} else if (index == player1BankIndex()) {
return PitType.PLAYER1_BANK;
} else if (index <= player2PitStopIndex()) {
return PitType.PLAYER2_PIT;
} else {
return PitType.PLAYER2_BANK;
}
}
public boolean checkPitTypeIsNormalPitByIndex(int index) {
return getPitTypeByIndex(index).isNormalPit();
}
public boolean checkPitTypeIsBankByIndex(int index) {
return getPitTypeByIndex(index).isBank();
}
public void move(int index) {
checkIndexAndMaybeThrowError(index);
PitType pitType = getPitTypeByIndex(index);
if (pitType.isBank()) {
throw new IllegalStateException("InCorrectPit => index : " + index + " pitType = " + pitTypeName(pitType));
}
Pit pit = pits.get(index);
if (pit.getStoneCount() <= 0) {
throw new IllegalStateException("StoneNotFound => index : " + index + " stoneCount = " + pit.getStoneCount());
}
List<Integer> path = getSowingPath(index);
pit.setStoneCount(0);
onGameMoveStart.accept(path.getFirst());
path.forEach(this::onGameMoveStep);
onGameMoveEnd.accept(path.getLast());
}
public List<Integer> getSowingPath(int index) {
return getSowingPath(index, pits.get(index).getStoneCount());
}
// Indexes the stones of the pit are dropped into, in order. A single stone goes to the next pit; otherwise the
// first stone goes back into the played pit. The player's own bank gets a stone, the opponent's bank is skipped.
// The last index is where the last stone lands. Empty for a bank or an empty pit.
public List<Integer> getSowingPath(int index, int stoneCount) {
checkIndexAndMaybeThrowError(index);
if (stoneCount <= 0 || checkPitTypeIsBankByIndex(index)) return List.of();
int opponentBankIndex = getPitTypeByIndex(index) == PitType.PLAYER1_PIT ? player2BankIndex() : player1BankIndex();
List<Integer> path = new ArrayList<>(stoneCount);
int current = stoneCount == 1 ? getNextPitIndexCircularly(index) : index;
while (path.size() < stoneCount) {
if (current != opponentBankIndex) path.add(current);
current = getNextPitIndexCircularly(current);
}
return path;
}
public void onGameMoveStep(int index) {
pits.get(getPitIndexCircularly(index)).increaseStone();
onGameMove.accept(index);
}
public int getNextPitIndexCircularly(int currentPitIndex) {
checkIndexAndMaybeThrowError(currentPitIndex);
return getPitIndexCircularly(currentPitIndex + 1);
}
public Pit getPitCircularly(int currentPitIndex) {
return pits.get(getPitIndexCircularly(currentPitIndex));
}
public int getPitIndexCircularly(int currentPitIndex) {
return currentPitIndex % totalPitCount();
}
public int[] getStoneArray() {
return pits.stream().mapToInt(Pit::getStoneCount).toArray();
}
public List<Pit> player1Pits() {
return pits.subList(player1PitStartIndex(), player1BankIndex());
}
public List<Pit> player2Pits() {
return pits.subList(player2PitStartIndex(), player2BankIndex());
}
public Pit player1Bank() {
return pits.get(player1BankIndex());
}
public Pit player2Bank() {
return pits.get(player2BankIndex());
}
public int getOppositePitIndex(int index) {
if (index > player1BankIndex()) {
return player2PitStopIndex() - index;
} else {
return player1BankIndex() + playerPitCount - index;
}
}
public void clearPlayer1Pits() {
player1Pits().forEach(pit -> pit.setStoneCount(0));
}
public void clearPlayer2Pits() {
player2Pits().forEach(pit -> pit.setStoneCount(0));
}
// The TypeScript name of a pit type, for error messages.
static String pitTypeName(PitType pitType) {
return switch (pitType) {
case PLAYER1_PIT -> "player1Pit";
case PLAYER1_BANK -> "player1Bank";
case PLAYER2_PIT -> "player2Pit";
case PLAYER2_BANK -> "player2Bank";
};
}
}

View File

@ -0,0 +1,10 @@
package app.playmancala.engine;
public class CommonBoard extends Board {
private static final int PIT_COUNT_BY_USER = 6;
private static final int INITIAL_STONE_COUNT_IN_PIT = 4;
public CommonBoard() {
super(PIT_COUNT_BY_USER, INITIAL_STONE_COUNT_IN_PIT);
}
}

View File

@ -0,0 +1,18 @@
package app.playmancala.engine;
import java.util.List;
import app.playmancala.engine.rules.GRClearBoardAtEnd;
import app.playmancala.engine.rules.GRDoubleStoneInPit;
import app.playmancala.engine.rules.GRLastStoneInBank;
import app.playmancala.engine.rules.GRLastStoneInEmptyPit;
// The Turkish Mangala rules. The order of the rules matters: each sees the board as the previous one left it.
public class CommonMancalaGame extends MancalaGame {
public CommonMancalaGame(String id, String player1Id, String player2Id) {
super(id, new CommonBoard(), player1Id, player2Id, player1Id,
List.of(new GRLastStoneInEmptyPit(), new GRDoubleStoneInPit(), new GRLastStoneInBank(),
new GRClearBoardAtEnd()),
List.of());
}
}

View File

@ -0,0 +1,12 @@
package app.playmancala.engine;
public interface GameRule {
default void onGameMoveStart(MancalaGame game, int index) {
}
default void onGameMove(MancalaGame game, int index) {
}
default void onGameMoveEnd(MancalaGame game, int index) {
}
}

View File

@ -0,0 +1,9 @@
package app.playmancala.engine;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum GameState {
@JsonProperty("initial") INITIAL,
@JsonProperty("playing") PLAYING,
@JsonProperty("ended") ENDED
}

View File

@ -0,0 +1,13 @@
package app.playmancala.engine;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// One thing that happened during a move, for clients to animate. `data` depends on the type and is null for most.
@JsonPropertyOrder({ "index", "type", "data" })
@JsonInclude(JsonInclude.Include.ALWAYS)
public record GameStep(int index, String type, Object data) {
public GameStep(int index, String type) {
this(index, type, null);
}
}

View File

@ -0,0 +1,213 @@
package app.playmancala.engine;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// Port of packages/mancala.js/src/core/MancalaGame.ts. Its JSON is what JSON.stringify makes of the TypeScript object,
// because clients rebuild the game from it with MancalaGame.createFromMancalaGame.
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonPropertyOrder({ "id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history",
"currentHistoryItem" })
@JsonInclude(JsonInclude.Include.ALWAYS)
public class MancalaGame {
public static final String GAME_STEP_GAME_MOVE = "GAME_STEP_GAME_MOVE";
private final String id;
private final Board board;
private final String player1Id;
private final String player2Id;
private String turnPlayerId;
private GameState state;
@JsonIgnore
private final List<GameRule> rules;
private final List<MoveHistoryItem> history;
private MoveHistoryItem currentHistoryItem;
public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId,
List<GameRule> gameRules, List<MoveHistoryItem> history) {
this(id, board, player1Id, player2Id, turnPlayerId, gameRules, history, GameState.INITIAL);
}
public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId,
List<GameRule> gameRules, List<MoveHistoryItem> history, GameState state) {
this.id = id;
this.board = board;
this.player1Id = player1Id;
this.player2Id = player2Id;
this.turnPlayerId = turnPlayerId;
this.state = state;
this.rules = List.copyOf(gameRules);
this.history = new ArrayList<>(history);
listenBoardMoveEvents();
}
private void listenBoardMoveEvents() {
board.onGameMoveStart = index -> rules.forEach(gameRule -> gameRule.onGameMoveStart(this, index));
board.onGameMove = index -> {
addGameStep(new GameStep(index, GAME_STEP_GAME_MOVE));
rules.forEach(gameRule -> gameRule.onGameMove(this, index));
};
board.onGameMoveEnd = index -> rules.forEach(gameRule -> gameRule.onGameMoveEnd(this, index));
}
public String getId() {
return id;
}
public Board getBoard() {
return board;
}
public String getPlayer1Id() {
return player1Id;
}
public String getPlayer2Id() {
return player2Id;
}
public String getTurnPlayerId() {
return turnPlayerId;
}
public void setTurnPlayerId(String turnPlayerId) {
this.turnPlayerId = turnPlayerId;
}
public GameState getState() {
return state;
}
public void setState(GameState state) {
this.state = state;
}
public List<MoveHistoryItem> getHistory() {
return history;
}
public MoveHistoryItem getCurrentHistoryItem() {
return currentHistoryItem;
}
// Rules have no state; the TypeScript objects serialise as {}.
@JsonProperty("gameRules")
private List<Map<String, Object>> gameRulesJson() {
return rules.stream().map(rule -> Map.<String, Object>of()).toList();
}
public void changePlayerTurn() {
turnPlayerId = turnPlayerId.equals(player1Id) ? player2Id : player1Id;
}
public boolean isTurnPlayer1() {
return player1Id.equals(turnPlayerId);
}
public boolean isTurnPlayer2() {
return player2Id.equals(turnPlayerId);
}
public String getPlayerIdByPitType(PitType pitType) {
return switch (pitType) {
case PLAYER1_PIT, PLAYER1_BANK -> player1Id;
case PLAYER2_PIT, PLAYER2_BANK -> player2Id;
};
}
public String getPlayerIdByIndex(int index) {
return getPlayerIdByPitType(board.getPitTypeByIndex(index));
}
public boolean checkIsPlayerTurnByIndex(int index) {
return checkIsPlayerTurn(getPlayerIdByIndex(index));
}
public int getBoardIndexByPlayerId(String playerId, int pitIndex) {
if (player1Id.equals(playerId)) {
return board.player1PitStartIndex() + pitIndex;
} else if (player2Id.equals(playerId)) {
return board.player2PitStartIndex() + pitIndex;
} else {
return -1;
}
}
public boolean checkIsPlayerTurn(String playerId) {
return playerId.equals(turnPlayerId);
}
public boolean checkPitIndexForPlayerId(String playerId, int pitIndex) {
return playerId.equals(getPlayerIdByIndex(getBoardIndexByPlayerId(playerId, pitIndex)));
}
public boolean checkIsPitIndexBank(String playerId, int pitIndex) {
return board.getPitTypeByIndex(getBoardIndexByPlayerId(playerId, pitIndex)).isBank();
}
public boolean canPlayerMove(String playerId, int pitIndex) {
boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex);
boolean isPitIndexBank = checkIsPitIndexBank(playerId, pitIndex);
boolean isPlayerTurn = checkIsPlayerTurn(playerId);
return isPitIndexCorrect && !isPitIndexBank && isPlayerTurn;
}
public void moveByPlayerPit(String playerId, int pitIndex) {
if (state == GameState.ENDED) return;
if (state == GameState.INITIAL) {
state = GameState.PLAYING;
}
if (canPlayerMove(playerId, pitIndex)) {
int moveIndex = getBoardIndexByPlayerId(playerId, pitIndex);
currentHistoryItem = new MoveHistoryItem(playerId, moveIndex, new int[0], List.of());
board.move(moveIndex);
currentHistoryItem.setBoardSnapshot(board.getStoneArray());
history.add(currentHistoryItem);
if (checkGameIsEnded()) {
state = GameState.ENDED;
}
} else {
boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex);
boolean isPlayerTurn = checkIsPlayerTurn(playerId);
throw new IllegalStateException("Player cannot move reason : isPitIndexCorrect = " + isPitIndexCorrect
+ " isPlayerTurn = " + isPlayerTurn);
}
}
public int getPlayer1StoneCountInPits() {
return getPlayerStoneCountInPits(board.player1PitStartIndex(), board.player1BankIndex());
}
public int getPlayer2StoneCountInPits() {
return getPlayerStoneCountInPits(board.player2PitStartIndex(), board.player2BankIndex());
}
private int getPlayerStoneCountInPits(int playerPitStartIndex, int playerBankIndex) {
return board.getPits().subList(playerPitStartIndex, playerBankIndex).stream()
.mapToInt(Pit::getStoneCount).sum();
}
public boolean checkGameIsEnded() {
return getPlayer1StoneCountInPits() == 0 || getPlayer2StoneCountInPits() == 0;
}
// The winner once the game has ended; null for a draw or a game still being played.
public String getWonPlayerId() {
int player1StoneCount = board.player1Bank().getStoneCount();
int player2StoneCount = board.player2Bank().getStoneCount();
if (!checkGameIsEnded() || player1StoneCount == player2StoneCount) return null;
return player1StoneCount > player2StoneCount ? player1Id : player2Id;
}
public void addGameStep(GameStep gameStep) {
if (currentHistoryItem != null) currentHistoryItem.getGameSteps().add(gameStep);
}
}

View File

@ -0,0 +1,49 @@
package app.playmancala.engine;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// A played move. The property order is that of the TypeScript MoveHistoryItem, whose base class sets the board first.
@JsonPropertyOrder({ "boardSnapshot", "gameSteps", "playerId", "moveIndex" })
public class MoveHistoryItem {
private int[] boardSnapshot;
private final List<GameStep> gameSteps;
private final String playerId;
private final int moveIndex;
@JsonCreator
public MoveHistoryItem(
@JsonProperty("playerId") String playerId,
@JsonProperty("moveIndex") int moveIndex,
@JsonProperty("boardSnapshot") int[] boardSnapshot,
@JsonProperty("gameSteps") List<GameStep> gameSteps) {
this.playerId = playerId;
this.moveIndex = moveIndex;
this.boardSnapshot = boardSnapshot == null ? new int[0] : boardSnapshot;
this.gameSteps = gameSteps == null ? new ArrayList<>() : new ArrayList<>(gameSteps);
}
public int[] getBoardSnapshot() {
return boardSnapshot;
}
void setBoardSnapshot(int[] boardSnapshot) {
this.boardSnapshot = boardSnapshot;
}
public List<GameStep> getGameSteps() {
return gameSteps;
}
public String getPlayerId() {
return playerId;
}
public int getMoveIndex() {
return moveIndex;
}
}

View File

@ -0,0 +1,40 @@
package app.playmancala.engine;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
@JsonPropertyOrder({ "index", "stoneCount" })
public class Pit {
private final int index;
private int stoneCount;
public Pit(int index, int stoneCount) {
this.index = index;
this.stoneCount = stoneCount;
}
public int getIndex() {
return index;
}
public int getStoneCount() {
return stoneCount;
}
public void setStoneCount(int stoneCount) {
this.stoneCount = stoneCount;
}
@JsonIgnore
public boolean isBank() {
return false;
}
public void increaseStone() {
stoneCount++;
}
public void addStones(int count) {
stoneCount += count;
}
}

View File

@ -0,0 +1,16 @@
package app.playmancala.engine;
public enum PitType {
PLAYER1_PIT,
PLAYER1_BANK,
PLAYER2_PIT,
PLAYER2_BANK;
public boolean isBank() {
return this == PLAYER1_BANK || this == PLAYER2_BANK;
}
public boolean isNormalPit() {
return this == PLAYER1_PIT || this == PLAYER2_PIT;
}
}

View File

@ -0,0 +1,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");
}
}

View File

@ -0,0 +1,44 @@
package app.playmancala.engine.rules;
import java.util.ArrayList;
import java.util.List;
import app.playmancala.engine.Board;
import app.playmancala.engine.GameRule;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
// When a move empties one side, the player whose side is empty gets the stones left on the other side.
public class GRClearBoardAtEnd implements GameRule {
public static final String GAME_STEP_BOARD_CLEARED = "GAME_STEP_BOARD_CLEARED";
public record ClearBoardAtEndData(List<Integer> pitIndexesThatHasStone) {
}
@Override
public void onGameMoveEnd(MancalaGame game, int index) {
Board board = game.getBoard();
if (game.getPlayer1StoneCountInPits() == 0) {
ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board));
board.player1Bank().addStones(game.getPlayer2StoneCountInPits());
board.clearPlayer2Pits();
game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data));
} else if (game.getPlayer2StoneCountInPits() == 0) {
ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board));
board.player2Bank().addStones(game.getPlayer1StoneCountInPits());
board.clearPlayer1Pits();
game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data));
}
}
private List<Integer> getPitIndexesThatHasStone(Board board) {
List<Integer> indexList = new ArrayList<>();
int[] stones = board.getStoneArray();
for (int index = 0; index < stones.length; index++) {
if (stones[index] > 0 && board.checkPitTypeIsNormalPitByIndex(index)) {
indexList.add(index);
}
}
return indexList;
}
}

View File

@ -0,0 +1,37 @@
package app.playmancala.engine.rules;
import app.playmancala.engine.Board;
import app.playmancala.engine.GameRule;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
import app.playmancala.engine.Pit;
import app.playmancala.engine.PitType;
// When the last stone makes the number of stones in an opponent's pit even, the mover takes them.
public class GRDoubleStoneInPit implements GameRule {
public static final String GAME_STEP_DOUBLE_STONE_IN_PIT = "GAME_STEP_DOUBLE_STONE_IN_PIT";
public record DoubleStoneInPitData(int pitIndex, int bankIndex) {
}
@Override
public void onGameMoveEnd(MancalaGame game, int index) {
Board board = game.getBoard();
index = board.getPitIndexCircularly(index);
Pit pit = board.getPits().get(index);
PitType pitType = board.getPitTypeByIndex(index);
if (pit.getStoneCount() % 2 != 0) return;
if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer2()) {
capture(game, index, board.player2Bank(), board.player2BankIndex());
} else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer1()) {
capture(game, index, board.player1Bank(), board.player1BankIndex());
}
}
private void capture(MancalaGame game, int pitIndex, Pit bank, int bankIndex) {
Pit pit = game.getBoard().getPitCircularly(pitIndex);
bank.addStones(pit.getStoneCount());
pit.setStoneCount(0);
game.addGameStep(new GameStep(pitIndex, GAME_STEP_DOUBLE_STONE_IN_PIT, new DoubleStoneInPitData(pitIndex, bankIndex)));
}
}

View File

@ -0,0 +1,23 @@
package app.playmancala.engine.rules;
import app.playmancala.engine.GameRule;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
import app.playmancala.engine.PitType;
// When the last stone lands in the mover's bank, the mover plays again; otherwise the turn passes.
public class GRLastStoneInBank implements GameRule {
public static final String GAME_STEP_LAST_STONE_IN_BANK = "GAME_STEP_LAST_STONE_IN_BANK";
@Override
public void onGameMoveEnd(MancalaGame game, int index) {
index = game.getBoard().getPitIndexCircularly(index);
PitType pitType = game.getBoard().getPitTypeByIndex(index);
if ((pitType == PitType.PLAYER1_BANK && game.isTurnPlayer1())
|| (pitType == PitType.PLAYER2_BANK && game.isTurnPlayer2())) {
game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_BANK));
} else {
game.changePlayerTurn();
}
}
}

View File

@ -0,0 +1,40 @@
package app.playmancala.engine.rules;
import app.playmancala.engine.Board;
import app.playmancala.engine.GameRule;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
import app.playmancala.engine.Pit;
import app.playmancala.engine.PitType;
// When the last stone lands in an empty pit of the mover's own, the mover takes it and the stones opposite.
public class GRLastStoneInEmptyPit implements GameRule {
public static final String GAME_STEP_LAST_STONE_IN_EMPTY_PIT = "GAME_STEP_LAST_STONE_IN_EMPTY_PIT";
public record LastStoneInEmptyPitData(int oppositeIndex) {
}
@Override
public void onGameMoveEnd(MancalaGame game, int index) {
Board board = game.getBoard();
index = board.getPitIndexCircularly(index);
Pit pit = board.getPits().get(index);
PitType pitType = board.getPitTypeByIndex(index);
if (pit.getStoneCount() != 1) return;
if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer1()) {
capture(game, index, pit, board.player1Bank());
} else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer2()) {
capture(game, index, pit, board.player2Bank());
}
}
private void capture(MancalaGame game, int index, Pit pit, Pit bank) {
int oppositeIndex = game.getBoard().getOppositePitIndex(index);
Pit oppositePit = game.getBoard().getPits().get(oppositeIndex);
if (oppositePit.getStoneCount() <= 0) return;
bank.addStones(1 + oppositePit.getStoneCount());
oppositePit.setStoneCount(0);
pit.setStoneCount(0);
game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_EMPTY_PIT, new LastStoneInEmptyPitData(oppositeIndex)));
}
}

View File

@ -0,0 +1,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();
}
}

View File

@ -0,0 +1,50 @@
package app.playmancala.game;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import org.jboss.logging.Logger;
import app.playmancala.model.Json;
// Writes a file to ./crashes (or the folder in the mancala.crash-dir system property) for each game that failed while a move was played, with the error and the game.
public final class CrashLog {
private static final Logger LOG = Logger.getLogger(CrashLog.class);
private static final Path CRASH_FOLDER = Path.of(System.getProperty("mancala.crash-dir", "crashes"));
private CrashLog() {
}
// The JSON to write; call it on the game loop, while the game cannot change.
public static String describe(Throwable error, BackendGame game) {
StringWriter stack = new StringWriter();
error.printStackTrace(new PrintWriter(stack));
Map<String, Object> crash = new LinkedHashMap<>();
crash.put("message", error.getMessage());
crash.put("stack", stack.toString());
crash.put("game", game);
return Json.write(crash);
}
// Returns the file name, e.g. "<game id>::2026-10-16-12:5:3:42" as the Node backend named them.
public static String fileName(String gameId) {
LocalDateTime now = LocalDateTime.now();
return String.format("%s::%d-%02d-%02d-%d:%d:%d:%d", gameId, now.getYear(), now.getMonthValue(),
now.getDayOfMonth(), now.getHour(), now.getMinute(), now.getSecond(), now.getNano() / 1_000_000);
}
public static void write(String fileName, String crash) {
try {
Files.createDirectories(CRASH_FOLDER);
Files.writeString(CRASH_FOLDER.resolve(fileName), crash);
} catch (IOException e) {
LOG.errorf("(CrashLog) Could not write %s: %s", fileName, e.getMessage());
}
}
}

View File

@ -0,0 +1,32 @@
package app.playmancala.game;
import app.playmancala.loop.Cancellable;
import app.playmancala.loop.Scheduler;
// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected
// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs
// only decides how soon the end is announced, never who wins.
public class GameClockScheduler {
public static final long CLOCK_CHECK_INTERVAL_MS = 1_000;
private final Runnable endGamesOutOfTime;
private final Scheduler scheduler;
private final long intervalMs;
private Cancellable timer;
public GameClockScheduler(Runnable endGamesOutOfTime, Scheduler scheduler, long intervalMs) {
this.endGamesOutOfTime = endGamesOutOfTime;
this.scheduler = scheduler;
this.intervalMs = intervalMs;
}
public void start() {
if (timer != null) return;
timer = scheduler.scheduleAtFixedRate(endGamesOutOfTime, intervalMs);
}
public void stop() {
if (timer != null) timer.cancel();
timer = null;
}
}

View File

@ -0,0 +1,61 @@
package app.playmancala.game;
import app.playmancala.model.GameClock;
import app.playmancala.model.Seat;
// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn
// plus when that turn started, so it only changes on moves; the time left at any moment is computed.
public final class GameClocks {
private GameClocks() {
}
public record ClockTimes(long player1Ms, long player2Ms) {
}
public record ChargedMove(GameClock clock, long remainingMs) {
}
// The seat of the player, or null if they do not play in the game.
public static Seat getSeat(String player1Id, String player2Id, String playerId) {
if (playerId.equals(player1Id)) return Seat.PLAYER1;
if (playerId.equals(player2Id)) return Seat.PLAYER2;
return null;
}
public static GameClock start(long initialMs, long now) {
return new GameClock(initialMs, initialMs, initialMs, now);
}
// Both players' time at `now`; never below zero. Port of getClockTimes in packages/core.
public static ClockTimes getClockTimes(GameClock clock, Seat runningSeat, long now) {
long elapsed = runningSeat != null && clock.turnStartedAt() != null ? Math.max(0, now - clock.turnStartedAt()) : 0;
return new ClockTimes(
Math.max(0, clock.player1Ms() - (runningSeat == Seat.PLAYER1 ? elapsed : 0)),
Math.max(0, clock.player2Ms() - (runningSeat == Seat.PLAYER2 ? elapsed : 0)));
}
public static long getRemainingMs(GameClock clock, Seat seat, Seat runningSeat, long now) {
ClockTimes times = getClockTimes(clock, runningSeat, now);
return seat == Seat.PLAYER1 ? times.player1Ms() : times.player2Ms();
}
// Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn.
public static ChargedMove chargeMove(GameClock clock, Seat moverSeat, long now) {
long remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now);
GameClock charged = moverSeat == Seat.PLAYER1
? new GameClock(clock.initialMs(), remainingMs, clock.player2Ms(), now)
: new GameClock(clock.initialMs(), clock.player1Ms(), remainingMs, now);
return new ChargedMove(charged, remainingMs);
}
// Deducts the running seat's time, if any, and stops the clock.
public static GameClock stop(GameClock clock, Seat runningSeat, long now) {
ClockTimes times = getClockTimes(clock, runningSeat, now);
return new GameClock(clock.initialMs(), times.player1Ms(), times.player2Ms(), null);
}
// Starts the running clock again from the time it had at the start of the turn, e.g. after a restart.
public static GameClock resume(GameClock clock, long now) {
return new GameClock(clock.initialMs(), clock.player1Ms(), clock.player2Ms(), now);
}
}

View File

@ -0,0 +1,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));
}
});
}
}

View File

@ -0,0 +1,109 @@
package app.playmancala.game;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import org.jboss.logging.Logger;
import app.playmancala.history.GameHistoryRepository;
import app.playmancala.live.LiveGameStore;
import app.playmancala.model.GameRecord;
// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is
// down too) and saved later, so a game is never lost because a dependency was briefly unavailable. Saves run on the
// given executor; the lists of unsaved games are read from any thread.
public class GameRecorder {
public static final long RETRY_INTERVAL_MS = 30_000;
private static final Logger LOG = Logger.getLogger(GameRecorder.class);
private final GameHistoryRepository repository;
private final LiveGameStore liveGameStore;
private final Executor io;
// Being saved right now.
private final Map<String, GameRecord> saving = new ConcurrentHashMap<>();
// Failed to save; retried later.
private final Map<String, GameRecord> pending = new ConcurrentHashMap<>();
private final List<Consumer<GameRecord>> savedListeners = new CopyOnWriteArrayList<>();
private final AtomicBoolean retrying = new AtomicBoolean();
public GameRecorder(GameHistoryRepository repository, LiveGameStore liveGameStore, Executor io) {
this.repository = repository;
this.liveGameStore = liveGameStore;
this.io = io;
}
public void onSaved(Consumer<GameRecord> listener) {
savedListeners.add(listener);
}
public void save(GameRecord record) {
saving.put(record.id(), record);
io.execute(() -> {
try {
repository.save(record);
saving.remove(record.id());
notifySaved(record);
} catch (RuntimeException e) {
LOG.errorf("(GameRecorder) Could not save game %s, will retry: %s", record.id(), e.getMessage());
pending.put(record.id(), record);
saving.remove(record.id());
try {
liveGameStore.savePendingRecord(record);
} catch (RuntimeException ignored) {
// Kept in memory.
}
}
});
}
// Finished games that are not in the database yet, so readers can include them.
public List<GameRecord> getUnsaved() {
List<GameRecord> unsaved = new ArrayList<>(saving.values());
unsaved.addAll(pending.values());
return unsaved;
}
// True if the game is saved or waiting to be saved. Throws if the database cannot be asked.
public boolean isRecorded(String gameId) {
return pending.containsKey(gameId) || repository.get(gameId) != null;
}
// Saves the records that failed before, including those a previous run left in Valkey. Blocks.
public void retryPending() {
if (!retrying.compareAndSet(false, true)) return;
try {
try {
liveGameStore.loadPendingRecords().forEach(record -> pending.put(record.id(), record));
} catch (RuntimeException ignored) {
// Valkey is down; retry what is in memory.
}
for (GameRecord record : List.copyOf(pending.values())) {
try {
repository.save(record);
} catch (RuntimeException e) {
return; // still unreachable; try again later
}
pending.remove(record.id());
try {
liveGameStore.removePendingRecord(record.id());
} catch (RuntimeException ignored) {
// Saving it again later is a no-op.
}
LOG.infof("(GameRecorder) Saved game %s after retrying", record.id());
notifySaved(record);
}
} finally {
retrying.set(false);
}
}
private void notifySaved(GameRecord record) {
savedListeners.forEach(listener -> listener.accept(record));
}
}

View File

@ -0,0 +1,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();
}
}

View File

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

View File

@ -0,0 +1,93 @@
package app.playmancala.game;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import app.playmancala.loop.Cancellable;
import app.playmancala.loop.Scheduler;
import app.playmancala.model.Channels;
import app.playmancala.model.Json;
import app.playmancala.model.LiveGameSummary;
import app.playmancala.rtmt.Rtmt;
// Live games for the home page: most spectated first, then most recently active. Subscribers get the top games
// when they subscribe and again whenever they change, at most once per second. Subscribing does not make anyone a
// spectator. Only touched on the game loop.
public class LiveGames {
public static final int LIVE_GAMES_PUSH_COUNT = 5;
private static final long PUSH_INTERVAL_MS = 1_000;
private final Rtmt rtmt;
private final GameStore gameStore;
private final Scheduler scheduler;
private final Set<String> subscribers = new LinkedHashSet<>();
private String lastPushed = "";
private long lastPushAt;
private Cancellable pushTimer;
public LiveGames(Rtmt rtmt, GameStore gameStore, GameManager gameManager, Scheduler scheduler) {
this.rtmt = rtmt;
this.gameStore = gameStore;
this.scheduler = scheduler;
rtmt.listenMessage(Channels.SUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribe(userId));
rtmt.listenMessage(Channels.UNSUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribers.remove(userId));
rtmt.listenOnClientConnectionChange((userId, isOnline) -> {
if (!isOnline) subscribers.remove(userId);
});
gameManager.onLiveGamesChanged(this::schedulePush);
}
public static LiveGameSummary toLiveGameSummary(BackendGame game) {
var mancalaGame = game.mancalaGame;
return new LiveGameSummary(game.id,
GameSerialization.publicSummary(game, mancalaGame.getPlayer1Id()),
GameSerialization.publicSummary(game, mancalaGame.getPlayer2Id()),
GameSerialization.boxed(mancalaGame.getBoard().getStoneArray()),
PublicIds.publicUserId(mancalaGame.getTurnPlayerId()), game.spectatorIds.size(),
mancalaGame.getHistory().size(), game.startedAt);
}
private static long lastActivity(BackendGame game) {
return game.moveTimes.isEmpty() ? game.startedAt : game.moveTimes.getLast();
}
public List<LiveGameSummary> getTop(int limit) {
return gameStore.getAll().stream()
.sorted(Comparator.<BackendGame>comparingInt(game -> game.spectatorIds.size())
.thenComparingLong(LiveGames::lastActivity).reversed())
.limit(limit)
.map(LiveGames::toLiveGameSummary)
.toList();
}
public void dispose() {
if (pushTimer != null) pushTimer.cancel();
}
private void subscribe(String userId) {
subscribers.add(userId);
rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, Map.of("games", getTop(LIVE_GAMES_PUSH_COUNT)));
}
private void schedulePush() {
if (pushTimer != null) return;
long wait = Math.max(0, lastPushAt + PUSH_INTERVAL_MS - scheduler.now());
pushTimer = scheduler.schedule(() -> {
pushTimer = null;
push();
}, wait);
}
private void push() {
List<LiveGameSummary> games = getTop(LIVE_GAMES_PUSH_COUNT);
String serialized = Json.write(games);
if (serialized.equals(lastPushed)) return;
lastPushed = serialized;
lastPushAt = scheduler.now();
Map<String, Object> message = Map.of("games", games);
List.copyOf(subscribers).forEach(userId -> rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, message));
}
}

View File

@ -0,0 +1,39 @@
package app.playmancala.game;
import java.util.function.BiConsumer;
import app.playmancala.rtmt.Rtmt;
// Pairs the next two players who ask for a game. Only touched on the game loop.
public class MatchMaker {
private final Rtmt rtmt;
private String waitingPlayerId;
private BiConsumer<String, String> onPlayersPaired = (player1Id, player2Id) -> { };
public MatchMaker(Rtmt rtmt) {
this.rtmt = rtmt;
}
public void join(String newcomerPlayerId) {
if (newcomerPlayerId.equals(waitingPlayerId)) return;
if (waitingPlayerId != null && rtmt.isClientOnline(waitingPlayerId)) {
String waiting = waitingPlayerId;
waitingPlayerId = null;
onPlayersPaired.accept(waiting, newcomerPlayerId);
} else {
waitingPlayerId = newcomerPlayerId;
}
}
public boolean cancel(String playerId) {
if (playerId.equals(waitingPlayerId)) {
waitingPlayerId = null;
return true;
}
return false;
}
public void listenOnPlayersPaired(BiConsumer<String, String> onPlayersPaired) {
this.onPlayersPaired = onPlayersPaired;
}
}

View File

@ -0,0 +1,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);
}
}
}

View File

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

View File

@ -0,0 +1,22 @@
package app.playmancala.history;
import java.util.List;
import app.playmancala.model.FinishedGameSummary;
import app.playmancala.model.GameRecord;
// Saved, finished games. Ids in records are public ids (see PublicIds). Methods block and throw when the database
// is unreachable.
public interface GameHistoryRepository {
// Saving the same game twice is a no-op, so failed saves can be retried.
void save(GameRecord record);
// Null if there is no such game.
GameRecord get(String id);
// The user's finished games, most recent first.
List<FinishedGameSummary> getRecentForUser(String userId, int limit);
// Finished games with the most spectators, most recent first among equals.
List<FinishedGameSummary> getPopular(int limit);
}

View File

@ -0,0 +1,91 @@
package app.playmancala.history;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;
import com.fasterxml.jackson.core.type.TypeReference;
import app.playmancala.live.Cache;
import app.playmancala.model.FinishedGameSummary;
import app.playmancala.model.GameRecord;
// Reads finished games for the API, including ones not saved yet. Lists are cached briefly and come back without the
// saved games while the database is unreachable, so the home page still loads. Blocks; call it off the game loop.
public class GameHistoryService {
public static final int MAX_LIST_LIMIT = 20;
private static final Logger LOG = Logger.getLogger(GameHistoryService.class);
private static final long CACHE_TTL_SECONDS = 10;
private static final String POPULAR_KEY = "popular-finished";
private static final TypeReference<List<FinishedGameSummary>> SUMMARIES = new TypeReference<>() { };
private static final Comparator<FinishedGameSummary> BY_NEWEST =
Comparator.comparingLong(FinishedGameSummary::endedAt).reversed();
private static final Comparator<FinishedGameSummary> BY_POPULARITY =
Comparator.comparingInt(FinishedGameSummary::peakSpectators).reversed().thenComparing(BY_NEWEST);
private final GameHistoryRepository repository;
private final Cache cache;
// Games that ended but are not in the database yet (being saved, or waiting for a retry).
private final Supplier<List<GameRecord>> unsaved;
public GameHistoryService(GameHistoryRepository repository, Cache cache, Supplier<List<GameRecord>> unsaved) {
this.repository = repository;
this.cache = cache;
this.unsaved = unsaved;
}
private static String recentKey(String userId) {
return "recent:" + userId;
}
// Null if there is no such game. Throws if the database cannot be asked.
public GameRecord get(String id) {
return unsaved.get().stream().filter(record -> record.id().equals(id)).findFirst()
.orElseGet(() -> repository.get(id));
}
public List<FinishedGameSummary> getRecentForUser(String userId, int limit) {
List<FinishedGameSummary> saved = cached(recentKey(userId), () -> repository.getRecentForUser(userId, MAX_LIST_LIMIT));
List<GameRecord> unsavedGames = unsaved.get().stream().filter(record -> record.hasPlayer(userId)).toList();
return merge(saved, unsavedGames, BY_NEWEST, limit);
}
public List<FinishedGameSummary> getPopular(int limit) {
List<FinishedGameSummary> saved = cached(POPULAR_KEY, () -> repository.getPopular(MAX_LIST_LIMIT));
return merge(saved, unsaved.get(), BY_POPULARITY, limit);
}
// Call when a game has been saved, so lists show it right away.
public void onGameSaved(GameRecord record) {
cache.delete(POPULAR_KEY, recentKey(record.player1().userId()), recentKey(record.player2().userId()));
}
private List<FinishedGameSummary> cached(String key, Supplier<List<FinishedGameSummary>> load) {
Optional<List<FinishedGameSummary>> hit = cache.get(key, SUMMARIES);
if (hit.isPresent()) return hit.get();
try {
List<FinishedGameSummary> games = load.get();
cache.set(key, games, CACHE_TTL_SECONDS);
return games;
} catch (RuntimeException e) {
LOG.errorf("(History) Could not load games: %s", e.getMessage());
return List.of();
}
}
private static List<FinishedGameSummary> merge(List<FinishedGameSummary> saved, List<GameRecord> unsaved,
Comparator<FinishedGameSummary> order, int limit) {
Set<String> savedIds = saved.stream().map(FinishedGameSummary::id).collect(Collectors.toSet());
List<FinishedGameSummary> all = new ArrayList<>(saved);
unsaved.stream().filter(record -> !savedIds.contains(record.id())).map(GameRecord::toSummary).forEach(all::add);
all.sort(order);
return all.stream().limit(limit).toList();
}
}

View File

@ -0,0 +1,100 @@
package app.playmancala.history;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.sql.DataSource;
import org.jboss.logging.Logger;
// Applies the migrations in src/main/resources/db/migrations that have not run yet, each in its own transaction.
// Same table and lock as the Node backend's runner, so databases it migrated are recognised.
public final class Migrations {
private static final Logger LOG = Logger.getLogger(Migrations.class);
private static final long MIGRATION_LOCK_ID = 727274;
// Applied in this order; never change a migration that has been released, add a new one instead.
public static final List<String> VERSIONS = List.of("001_games", "002_clock", "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);
}
}
}

View File

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

View File

@ -0,0 +1,35 @@
package app.playmancala.http;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jboss.logging.Logger;
import app.playmancala.auth.InvalidTokenException;
import app.playmancala.auth.TokenVerifier;
import app.playmancala.model.AuthUser;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
// Requires an "Authorization: Bearer <access token>" header with the same Keycloak token the WebSocket uses.
final class BearerAuth {
private static final Logger LOG = Logger.getLogger(BearerAuth.class);
private static final Pattern BEARER = Pattern.compile("^Bearer (.+)$");
private BearerAuth() {
}
// The user, or a 401 response.
static AuthUser requireUser(TokenVerifier tokenVerifier, String authorization) {
Matcher match = BEARER.matcher(authorization == null ? "" : authorization);
if (!match.matches()) throw new WebApplicationException(Response.Status.UNAUTHORIZED);
try {
return tokenVerifier.verify(match.group(1));
} catch (InvalidTokenException e) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
} catch (RuntimeException e) {
LOG.error("(Auth) Token verification failed", e);
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
}
}

View File

@ -0,0 +1,20 @@
package app.playmancala.http;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
import io.vertx.core.http.HttpServerRequest;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
// A failing dependency (the database, mostly) answers 503, as the Express error handler did.
public class ErrorMapper {
private static final Logger LOG = Logger.getLogger(ErrorMapper.class);
@ServerExceptionMapper
public Response map(RuntimeException error, HttpServerRequest request) {
if (error instanceof WebApplicationException webError) return webError.getResponse();
LOG.errorf("(HTTP) %s %s failed: %s", request.method(), request.path(), error.getMessage());
return Response.status(Response.Status.SERVICE_UNAVAILABLE).build();
}
}

View File

@ -0,0 +1,125 @@
package app.playmancala.http;
import java.math.BigInteger;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import app.playmancala.Backend;
import app.playmancala.game.BackendGame;
import app.playmancala.game.LiveGames;
import app.playmancala.history.GameHistoryService;
import app.playmancala.model.AuthUser;
import app.playmancala.model.GameRecord;
import app.playmancala.model.Json;
import app.playmancala.model.LiveGameSummary;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
// The HTTP API. Blocking: runs on worker threads, reads live games on the game loop and saved games itself.
// Bodies are written with the shared mapper, so they are the same JSON as the WebSocket messages.
@Path("/")
public class GameResource {
private static final Pattern LEADING_INTEGER = Pattern.compile("^\\s*([+-]?\\d+)");
@Inject
Backend backend;
@GET
@Produces(MediaType.TEXT_PLAIN)
public String root() {
return "Server up and running! ci test1";
}
// Issues userKeys for legacy clients; token clients get their identity from Keycloak.
@GET
@Path("register{slash: /?}")
@Produces(MediaType.TEXT_PLAIN)
public String register() {
if (!backend.legacyUserKeyAuth()) throw new NotFoundException();
return UUID.randomUUID().toString();
}
// A live game as players receive it, or the saved record of a finished game.
@GET
@Path("game/{id}")
public Response game(@PathParam("id") String id) {
// Serialised on the loop, because the client game refers to the live engine objects.
String live = backend.loop().call(() -> {
BackendGame game = backend.gameStore().get(id);
return game == null ? null : Json.write(backend.gameManager().toClientGame(game));
});
if (live != null) return json(live);
GameRecord record = backend.history().get(id);
if (record == null) throw new NotFoundException();
Map<String, Object> response = new LinkedHashMap<>();
response.put("status", "finished");
response.put("record", record);
return json(response);
}
// Live games if any are being played, otherwise the most spectated finished games. With kind=finished always the
// finished games, for clients that get live games over the WebSocket.
@GET
@Path("games/popular")
public Response popular(@QueryParam("limit") String limitParam, @QueryParam("kind") String kind) {
int limit = parseLimit(limitParam, 5);
List<LiveGameSummary> live = "finished".equals(kind) ? List.of()
: backend.loop().call(() -> backend.liveGames().getTop(limit));
Map<String, Object> response = new LinkedHashMap<>();
if (!live.isEmpty()) {
response.put("kind", "live");
response.put("games", live);
} else {
response.put("kind", "finished");
response.put("games", backend.history().getPopular(limit));
}
return json(response);
}
@GET
@Path("games/me/active")
public Response activeGame(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) {
AuthUser user = BearerAuth.requireUser(backend.tokenVerifier(), authorization);
LiveGameSummary game = backend.loop().call(() -> {
BackendGame active = backend.gameStore().getGameByUser(user.id());
return active == null ? null : LiveGames.toLiveGameSummary(active);
});
Map<String, Object> response = new HashMap<>();
response.put("game", game);
return json(response);
}
@GET
@Path("games/me")
public Response myGames(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization,
@QueryParam("limit") String limit) {
AuthUser user = BearerAuth.requireUser(backend.tokenVerifier(), authorization);
return json(Map.of("games", backend.history().getRecentForUser(user.id(), parseLimit(limit, 3))));
}
private static Response json(Object body) {
return Response.ok(body instanceof String text ? text : Json.write(body), MediaType.APPLICATION_JSON).build();
}
// Like parseInt in the Node backend: the leading integer, clamped to 1..MAX_LIST_LIMIT.
static int parseLimit(String value, int fallback) {
Matcher match = LEADING_INTEGER.matcher(value == null ? "" : value);
if (!match.find()) return fallback;
BigInteger limit = new BigInteger(match.group(1));
return limit.max(BigInteger.ONE).min(BigInteger.valueOf(GameHistoryService.MAX_LIST_LIMIT)).intValue();
}
}

View File

@ -0,0 +1,14 @@
package app.playmancala.live;
import java.util.Optional;
import com.fasterxml.jackson.core.type.TypeReference;
// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value.
public interface Cache {
<T> Optional<T> get(String key, TypeReference<T> type);
void set(String key, Object value, long ttlSeconds);
void delete(String... keys);
}

View File

@ -0,0 +1,24 @@
package app.playmancala.live;
import java.util.List;
import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.model.GameRecord;
// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished
// games that could not be saved to the database yet. Methods block and throw when Valkey is unreachable.
public interface LiveGameStore {
void saveGame(String gameId, Object snapshot);
void removeGame(String gameId);
// As stored; the caller checks what it can use.
List<JsonNode> loadGames();
void savePendingRecord(GameRecord record);
void removePendingRecord(String gameId);
List<GameRecord> loadPendingRecords();
}

View File

@ -0,0 +1,52 @@
package app.playmancala.live;
import java.util.Arrays;
import java.util.Optional;
import com.fasterxml.jackson.core.type.TypeReference;
import app.playmancala.model.Json;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.value.SetArgs;
import io.quarkus.redis.datasource.value.ValueCommands;
public class ValkeyCache implements Cache {
private static final String PREFIX = "mancala:cache:";
private final RedisDataSource redis;
private final ValueCommands<String, String> values;
public ValkeyCache(RedisDataSource redis) {
this.redis = redis;
this.values = redis.value(String.class);
}
@Override
public <T> Optional<T> get(String key, TypeReference<T> type) {
try {
String value = values.get(PREFIX + key);
return value == null ? Optional.empty() : Optional.of(Json.MAPPER.readValue(value, type));
} catch (Exception e) {
return Optional.empty();
}
}
@Override
public void set(String key, Object value, long ttlSeconds) {
try {
values.set(PREFIX + key, Json.write(value), new SetArgs().ex(ttlSeconds));
} catch (RuntimeException e) {
// A missing cache entry only costs a database query.
}
}
@Override
public void delete(String... keys) {
if (keys.length == 0) return;
try {
redis.key().del(Arrays.stream(keys).map(key -> PREFIX + key).toArray(String[]::new));
} catch (RuntimeException e) {
// Entries expire on their own.
}
}
}

View File

@ -0,0 +1,87 @@
package app.playmancala.live;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.jboss.logging.Logger;
import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.model.GameRecord;
import app.playmancala.model.Json;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.keys.KeyScanArgs;
import io.quarkus.redis.datasource.value.ValueCommands;
// Keys and values are the Node backend's, so either backend reads what the other wrote.
public class ValkeyLiveGameStore implements LiveGameStore {
private static final Logger LOG = Logger.getLogger(ValkeyLiveGameStore.class);
private static final String LIVE_PREFIX = "mancala:live:";
private static final String PENDING_SAVE_PREFIX = "mancala:pending-save:";
private final RedisDataSource redis;
private final ValueCommands<String, String> values;
public ValkeyLiveGameStore(RedisDataSource redis) {
this.redis = redis;
this.values = redis.value(String.class);
}
@Override
public void saveGame(String gameId, Object snapshot) {
values.set(LIVE_PREFIX + gameId, Json.write(snapshot));
}
@Override
public void removeGame(String gameId) {
redis.key().del(LIVE_PREFIX + gameId);
}
@Override
public List<JsonNode> loadGames() {
return loadAll(LIVE_PREFIX);
}
@Override
public void savePendingRecord(GameRecord record) {
values.set(PENDING_SAVE_PREFIX + record.id(), Json.write(record));
}
@Override
public void removePendingRecord(String gameId) {
redis.key().del(PENDING_SAVE_PREFIX + gameId);
}
@Override
public List<GameRecord> loadPendingRecords() {
List<GameRecord> records = new ArrayList<>();
for (JsonNode json : loadAll(PENDING_SAVE_PREFIX)) {
try {
records.add(Json.MAPPER.treeToValue(json, GameRecord.class));
} catch (Exception e) {
LOG.errorf("(Valkey) Ignoring unreadable pending record %s", json.path("id").asText());
}
}
return records;
}
private List<JsonNode> loadAll(String prefix) {
// SCAN rather than KEYS, which would block Valkey.
List<String> keys = new ArrayList<>();
redis.key().scan(new KeyScanArgs().match(prefix + "*").count(100)).toIterable().forEach(keys::add);
if (keys.isEmpty()) return List.of();
Map<String, String> stored = values.mget(keys.toArray(String[]::new));
List<JsonNode> parsed = new ArrayList<>();
for (String key : keys) {
String value = stored.get(key);
if (value == null) continue;
try {
parsed.add(Json.parse(value));
} catch (Exception e) {
LOG.errorf("(Valkey) Ignoring unreadable value in %s", key);
}
}
return parsed;
}
}

View File

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

View File

@ -0,0 +1,88 @@
package app.playmancala.loop;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.jboss.logging.Logger;
// The one thread that owns all live game state: games, matchmaking, previews and the WebSocket registry. Like the
// Node backend's event loop, it lets that state go without locks. Never block it on I/O.
public final class GameLoop implements Scheduler, AutoCloseable {
private static final Logger LOG = Logger.getLogger(GameLoop.class);
private final ScheduledExecutorService executor;
private volatile Thread thread;
public GameLoop() {
executor = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread loopThread = new Thread(runnable, "game-loop");
loopThread.setDaemon(true);
thread = loopThread;
return loopThread;
});
}
@Override
public long now() {
return System.currentTimeMillis();
}
@Override
public void execute(Runnable task) {
executor.execute(guarded(task));
}
@Override
public Cancellable schedule(Runnable task, long delayMs) {
Future<?> future = executor.schedule(guarded(task), delayMs, TimeUnit.MILLISECONDS);
return () -> future.cancel(false);
}
@Override
public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) {
Future<?> future = executor.scheduleAtFixedRate(guarded(task), periodMs, periodMs, TimeUnit.MILLISECONDS);
return () -> future.cancel(false);
}
// Runs the task on the loop and waits for its result; for threads that need to read live state.
public <T> T call(Callable<T> task) {
if (Thread.currentThread() == thread) {
try {
return task.call();
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
try {
return executor.submit(task).get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for the game loop", e);
} catch (ExecutionException e) {
if (e.getCause() instanceof RuntimeException runtime) throw runtime;
throw new IllegalStateException(e.getCause());
}
}
@Override
public void close() {
executor.shutdownNow();
}
// An exception must not stop a repeating task or reach the executor, which would swallow it.
private static Runnable guarded(Runnable task) {
return () -> {
try {
task.run();
} catch (Throwable e) {
LOG.error("(GameLoop) Task failed", e);
}
};
}
}

View File

@ -0,0 +1,15 @@
package app.playmancala.loop;
// What the game logic needs from the game loop: running tasks and timers on the thread that owns the game state, and
// the time. Tests use ManualScheduler, whose clock only moves when the test advances it.
public interface Scheduler {
// Epoch milliseconds.
long now();
void execute(Runnable task);
Cancellable schedule(Runnable task, long delayMs);
// First run after one period.
Cancellable scheduleAtFixedRate(Runnable task, long periodMs);
}

View File

@ -0,0 +1,11 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
// A logged in user. `id` is also the player id in games; `name` is null for legacy userKey clients.
public record AuthUser(
String id,
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
@JsonProperty("isGuest") boolean isGuest) {
}

View File

@ -0,0 +1,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() {
}
}

View File

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

View File

@ -0,0 +1,20 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum EndReason {
@JsonProperty("completed") COMPLETED,
@JsonProperty("left") LEFT,
@JsonProperty("timeout") TIMEOUT,
// Only in games saved before the game clock.
@JsonProperty("inactive") INACTIVE;
// The value stored in the games.end_reason column.
public String wireName() {
return name().toLowerCase();
}
public static EndReason fromWireName(String value) {
return valueOf(value.toUpperCase());
}
}

View File

@ -0,0 +1,24 @@
package app.playmancala.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// A finished game in lists. `board` is the final board; `result` is null (and sent as null) without a winner.
@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
"moveCount", "peakSpectators", "clockInitialMs" })
public record FinishedGameSummary(
String id,
PlayerSummary player1,
PlayerSummary player2,
List<Integer> board,
GameResult result,
EndReason endReason,
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
long startedAt,
long endedAt,
int moveCount,
int peakSpectators,
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs) {
}

View File

@ -0,0 +1,12 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonInclude;
// The players' clocks (docs/specs/game-clock): each player's time left at the start of the current turn, and when
// that turn started. `turnStartedAt` is null when no clock runs, i.e. the game has ended.
public record GameClock(
long initialMs,
long player1Ms,
long player2Ms,
@JsonInclude(JsonInclude.Include.NON_NULL) Long turnStartedAt) {
}

View File

@ -0,0 +1,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);
}
}

View File

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

View File

@ -0,0 +1,18 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum GameResult {
@JsonProperty("player1") PLAYER1,
@JsonProperty("player2") PLAYER2,
@JsonProperty("draw") DRAW;
// The value stored in the games.result column.
public String wireName() {
return name().toLowerCase();
}
public static GameResult fromWireName(String value) {
return value == null ? null : valueOf(value.toUpperCase());
}
}

View File

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

View File

@ -0,0 +1,33 @@
package app.playmancala.model;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
// The JSON mapper for messages, snapshots, records and the cache. Unknown properties are ignored, so data written by
// a newer or older backend can still be read.
public final class Json {
public static final ObjectMapper MAPPER = new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
private Json() {
}
public static String write(Object value) {
try {
return MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot serialise " + value.getClass().getSimpleName(), e);
}
}
public static JsonNode tree(Object value) {
return MAPPER.valueToTree(value);
}
// Throws JsonProcessingException for text that is not JSON.
public static JsonNode parse(String text) throws JsonProcessingException {
return MAPPER.readTree(text);
}
}

View File

@ -0,0 +1,15 @@
package app.playmancala.model;
import java.util.List;
// A live game for previews. Ids are public ids.
public record LiveGameSummary(
String id,
PlayerSummary player1,
PlayerSummary player2,
List<Integer> board,
String turnPlayerId,
int spectatorCount,
int moveCount,
long startedAt) {
}

View File

@ -0,0 +1,17 @@
package app.playmancala.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import app.playmancala.engine.GameStep;
// A move of a saved game. `remainingMs` is the mover's clock after the move; null for games before the game clock.
public record MoveRecord(
String playerId,
int moveIndex,
List<Integer> boardSnapshot,
List<GameStep> gameSteps,
long at,
@JsonInclude(JsonInclude.Include.NON_NULL) Long remainingMs) {
}

View File

@ -0,0 +1,15 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
// A player as captured when a game is created. In saved games and lists, `userId` is a public id (PublicIds).
public record PlayerSummary(
String userId,
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
@JsonProperty("isGuest") boolean isGuest) {
public static PlayerSummary unknown(String userId) {
return new PlayerSummary(userId, null, true);
}
}

View File

@ -0,0 +1,8 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum Seat {
@JsonProperty("player1") PLAYER1,
@JsonProperty("player2") PLAYER2
}

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

View File

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

View File

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

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

View File

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

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

View File

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