Compare commits

...

10 Commits

Author SHA1 Message Date
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
36 changed files with 1440 additions and 59 deletions

View File

@ -24,7 +24,8 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app
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) and `tutorial` (rules page, lessons, hints in bot games).
at four levels), `tutorial` (rules page, lessons, hints in bot games) and `rematch` (rematches, alternating first
moves, the series strip).
## Running locally
@ -73,6 +74,8 @@ which helps to check what the server holds.
`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
@ -98,6 +101,10 @@ which helps to check what the server holds.
- 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
@ -119,6 +126,8 @@ which helps to check what the server holds.
- 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.

16
TODO.md
View File

@ -80,7 +80,17 @@ See `docs/specs/tutorial`.
See `docs/specs/web-redesign`.
- [ ] Bring the new design to the mobile app (themes, board, game screen)
- [ ] A rematch button on the game-over card (the design has one; there is no rematch yet)
## 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
@ -133,5 +143,5 @@ See `docs/specs/web-redesign`.
- [ ] CI that runs the tests (the backend's need a JDK 25 and Docker)
- [ ] The rules exist twice, in `packages/mancala.js` and in the backend's Java port; a parity test keeps them alike,
but every rule change has to be made in both
- [ ] Pre-existing type errors: 4 in the frontend, 11 in mobile; the frontend has no `tsconfig.json`, so nothing
type-checks it
- [ ] 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

View File

@ -20,6 +20,7 @@ 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;
@ -103,6 +104,7 @@ public class Backend {
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);

View File

@ -6,9 +6,12 @@ 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 {
@ -30,10 +33,12 @@ public class BackendGame {
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) {
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs, BotSettings bot, Series series) {
this.id = mancalaGame.getId();
this.mancalaGame = mancalaGame;
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
@ -45,6 +50,14 @@ public class BackendGame {
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() {

View File

@ -7,6 +7,7 @@ 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;
@ -17,6 +18,7 @@ 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;
@ -29,9 +31,12 @@ 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;
@ -53,6 +58,8 @@ public class GameManager {
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) {
@ -80,8 +87,19 @@ public class GameManager {
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));
@ -107,6 +125,7 @@ public class GameManager {
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));
@ -193,8 +212,14 @@ public class GameManager {
if (gameStore.get(game.id) != game) return;
deleteGame(game);
game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong());
GameEndMessage endMessage = new GameEndMessage(game.id, GameSerialization.getResult(game, ending),
ending.endReason(), game.clock, ending.winnerId(), ending.leftUserId());
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());
}
@ -205,6 +230,7 @@ public class GameManager {
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.
@ -313,7 +339,8 @@ public class GameManager {
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());
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong(), game.series,
game.firstSeat());
}
public BackendGame createMancalaGame(String userId1, String userId2) {
@ -322,8 +349,14 @@ public class GameManager {
// `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);
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? 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);
@ -331,7 +364,7 @@ public class GameManager {
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);
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);

View File

@ -27,6 +27,7 @@ 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
@ -58,7 +59,9 @@ public final class GameSerialization {
GameClock clock,
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs,
// Games against the bot only.
BotSettings bot) {
BotSettings bot,
// Missing in snapshots from before rematches.
Series series) {
}
public record GameEnding(EndReason endReason, String winnerId, String leftUserId) {
@ -74,7 +77,7 @@ public final class GameSerialization {
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);
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.
@ -117,7 +120,8 @@ public final class GameSerialization {
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));
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
@ -152,7 +156,8 @@ public final class GameSerialization {
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);
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.

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

@ -23,7 +23,7 @@ public final class Migrations {
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");
public static final List<String> VERSIONS = List.of("001_games", "002_clock", "003_bot", "004_series");
private Migrations() {
}

View File

@ -59,9 +59,9 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
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)
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)
?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb, ?::uuid)
ON CONFLICT (id) DO NOTHING""")) {
statement.setString(1, record.id());
statement.setString(2, record.player1().userId());
@ -79,6 +79,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
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;
@ -105,7 +106,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
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 " + FROM_GAMES + " WHERE g.id = ?::uuid")) {
"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;
@ -114,7 +115,8 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
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("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class),
row.getString("series_id"));
}
}
});

View File

@ -32,6 +32,14 @@ public final class Channels {
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

@ -2,10 +2,12 @@ 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.
// 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,
@ -14,5 +16,7 @@ public record ClientGame(
List<Long> moveTimes,
int spectatorCount,
GameClock clock,
long serverTime) {
long serverTime,
@JsonInclude(JsonInclude.Include.NON_NULL) Series series,
Seat firstSeat) {
}

View File

@ -1,16 +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.
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" })
// 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) 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

@ -8,9 +8,10 @@ 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.
// 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" })
"moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot", "seriesId" })
public record GameRecord(
String id,
PlayerSummary player1,
@ -26,14 +27,15 @@ public record GameRecord(
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs,
List<MoveRecord> moves,
int totalSpectators,
@JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot) {
@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);
clockInitialMs, moves, totalSpectators, null, null);
}
public FinishedGameSummary toSummary() {

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,3 @@
-- The series a game belongs to: a game and the rematches after it (docs/specs/rematch). NULL for games saved before.
ALTER TABLE games ADD COLUMN series_id uuid;
CREATE INDEX games_series_id ON games (series_id);

View File

@ -32,6 +32,9 @@ import app.playmancala.model.GameRecord;
import app.playmancala.model.GameResult;
import app.playmancala.model.Json;
import app.playmancala.model.MoveRecord;
import app.playmancala.model.Seat;
import app.playmancala.model.Series;
import app.playmancala.model.SeriesGame;
// Ported from the Node backend's tests/game/GameManager.test.ts.
class GameManagerTest {
@ -199,11 +202,11 @@ class GameManagerTest {
}
@Test
void sendsTheGameAsTheNodeBackendDid() {
void sendsTheGameAsTheNodeBackendDidPlusTheSeries() {
BackendGame game = startGame();
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
assertEquals(List.of("id", "mancalaGame", "gameUsersConnectionInfo", "startedAt", "moveTimes",
"spectatorCount", "clock", "serverTime"), fieldNames(start));
"spectatorCount", "clock", "serverTime", "series", "firstSeat"), fieldNames(start));
assertEquals(List.of("id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history",
"currentHistoryItem"), fieldNames(start.get("mancalaGame")));
assertEquals("initial", start.at("/mancalaGame/state").asText());
@ -392,8 +395,9 @@ class GameManagerTest {
assertEquals(1, ends.size());
ObjectNode end = ends.getFirst().deepCopy();
assertTrue(end.remove("clock").isObject());
assertTrue(end.remove("series").isObject());
assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed",
"winnerId", ALICE.id())), end);
"winnerId", ALICE.id(), "rematch", "available")), end);
}
@Test
@ -413,6 +417,7 @@ class GameManagerTest {
assertEquals(1, ends.size());
ObjectNode end = ends.getFirst().deepCopy();
assertTrue(end.remove("clock").isObject());
assertTrue(end.remove("series").isObject());
assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left",
"winnerId", BOB.id(), "leftUserId", ALICE.id())), end);
}
@ -595,8 +600,108 @@ class GameManagerTest {
JsonNode written = liveGameStore.games.get(restored.id);
assertEquals(Json.tree(restored.mancalaGame.getHistory()), written.get("history"));
assertEquals(List.of("version", "id", "player1Id", "player2Id", "turnPlayerId", "state", "board", "history",
"players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs"),
fieldNames(written));
"players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs",
"series"), fieldNames(written));
}
}
@Nested
class InASeries {
@Test
void everyNewGameStartsASeriesOfItsOwn() {
BackendGame first = startGame(ALICE, BOB);
BackendGame second = startGame(CAROL, DAVE);
assertTrue(first.series.games().isEmpty());
assertNotNull(first.series.id());
assertFalse(first.series.id().equals(second.series.id()));
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
assertEquals(Json.tree(Map.of("id", first.series.id(), "games", List.of())), start.get("series"));
}
@Test
void theFirstSeatIsTheSeatThatMovedFirst() {
BackendGame game = startGame();
Seat firstSeat = isPlayer1(game, game.mancalaGame.getTurnPlayerId()) ? Seat.PLAYER1 : Seat.PLAYER2;
assertEquals(firstSeat, game.firstSeat());
assertEquals(Json.tree(firstSeat), rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_START).get("firstSeat"));
playAnyMove(game);
playAnyMove(game);
assertEquals(firstSeat, game.firstSeat());
}
@Test
void aGameCanBeCreatedInASeriesWithTheSeatToMoveFirst() {
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, GameResult.PLAYER1,
EndReason.COMPLETED, 30, 18)));
for (Seat seat : Seat.values()) {
BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, seat);
assertEquals(ALICE.id(), game.mancalaGame.getPlayer1Id());
assertEquals(seat == Seat.PLAYER1 ? ALICE.id() : BOB.id(), game.mancalaGame.getTurnPlayerId());
assertSame(series, game.series);
assertEquals(Json.tree(series), Json.tree(gameManager.toClientGame(game)).get("series"));
gameManager.deleteGame(game);
}
}
@Test
void theEndMessageHasTheSeriesWithTheGameThatEnded() {
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, GameResult.DRAW,
EndReason.COMPLETED, 24, 24)));
BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, Seat.PLAYER1);
gameManager.startGame(game);
playAnyMove(game);
setUpLastMove(game, ALICE.id());
rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 5));
JsonNode end = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END);
assertEquals(Json.tree(series.next(new SeriesGame(game.id, Seat.PLAYER1, GameResult.PLAYER1,
EndReason.COMPLETED, 2, 0))), end.get("series"));
}
@Test
void aGameThatEndsWithoutAWinnerIsInTheSeriesToo() {
BackendGame game = startGame();
rtmt.receive(ALICE.id(), Channels.LEAVE_GAME);
JsonNode games = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END).at("/series/games");
assertEquals(1, games.size());
assertEquals("left", games.get(0).get("endReason").asText());
assertEquals(game.id, games.get(0).get("gameId").asText());
}
@Test
void savesTheSeriesWithTheGame() {
BackendGame game = startGame();
rtmt.receive(ALICE.id(), Channels.LEAVE_GAME);
assertEquals(game.series.id(), repository.records.get(game.id).seriesId());
}
@Test
void restoresTheSeriesOfALiveGame() {
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, null,
EndReason.TIMEOUT, 10, 12)));
BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, Seat.PLAYER2);
gameManager.startGame(game);
playAnyMove(game);
createManager();
gameManager.restoreLiveGames();
BackendGame restored = gameStore.getGameByUser(ALICE.id());
assertEquals(series, restored.series);
assertEquals(Seat.PLAYER2, restored.firstSeat());
}
@Test
void aSnapshotFromBeforeRematchesStartsANewSeries() {
BackendGame game = startGame();
ObjectNode snapshot = liveGameStore.games.get(game.id).deepCopy();
snapshot.remove("series");
liveGameStore.games.put(game.id, snapshot);
createManager();
gameManager.restoreLiveGames();
BackendGame restored = gameStore.getGameByUser(ALICE.id());
assertNotNull(restored.series.id());
assertTrue(restored.series.games().isEmpty());
}
}

View File

@ -0,0 +1,268 @@
package app.playmancala.game;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.SplittableRandom;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.bot.BotLevel;
import app.playmancala.bot.BotSettings;
import app.playmancala.engine.Pit;
import app.playmancala.fakes.FakeRtmt;
import app.playmancala.fakes.InMemoryGameHistoryRepository;
import app.playmancala.fakes.InMemoryLiveGameStore;
import app.playmancala.loop.ManualScheduler;
import app.playmancala.model.AuthUser;
import app.playmancala.model.Channels;
import app.playmancala.model.Json;
import app.playmancala.model.Seat;
// Rematch requests with the real game logic and a manual clock (docs/specs/rematch).
class RematchOffersTest {
static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false);
static final AuthUser BOB = new AuthUser("kc:bob", "bob", false);
static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false);
static final long CLOCK_MS = 5 * 60_000;
ManualScheduler loop;
FakeRtmt rtmt;
GameStore gameStore;
GameManager gameManager;
@BeforeEach
void setUp() {
loop = new ManualScheduler(Instant.parse("2026-10-17T12:00:00Z").toEpochMilli());
rtmt = new FakeRtmt();
gameStore = new GameStore();
InMemoryLiveGameStore liveGameStore = new InMemoryLiveGameStore();
GameRecorder recorder = new GameRecorder(new InMemoryGameHistoryRepository(), liveGameStore, Runnable::run);
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run,
loop::now, CLOCK_MS);
new RematchOffers(gameManager, gameStore, rtmt, loop, new SplittableRandom(1));
List.of(ALICE, BOB, CAROL).forEach(rtmt::connect);
}
BackendGame startGame() {
rtmt.receive(ALICE.id(), Channels.NEW_GAME);
rtmt.receive(BOB.id(), Channels.NEW_GAME);
return gameStore.getGameByUser(ALICE.id());
}
// The player to move plays a last stone that empties their side, which ends the game by the rules.
void finish(BackendGame game) {
String moverId = game.mancalaGame.getTurnPlayerId();
boolean isPlayer1 = game.mancalaGame.getPlayer1Id().equals(moverId);
List<Pit> pits = game.mancalaGame.getBoard().getPits();
pits.forEach(pit -> pit.setStoneCount(0));
pits.get(isPlayer1 ? 5 : 12).setStoneCount(1);
pits.get(isPlayer1 ? 7 : 0).setStoneCount(1);
gameManager.playMove(game, moverId, 5);
assertNull(gameStore.get(game.id), "game did not end");
}
void ask(AuthUser user, BackendGame game) {
rtmt.receive(user.id(), Channels.REMATCH, Map.of("gameId", game.id));
}
void decline(AuthUser user, BackendGame game) {
rtmt.receive(user.id(), Channels.DECLINE_REMATCH, Map.of("gameId", game.id));
}
List<JsonNode> requestsTo(AuthUser user) {
return rtmt.messagesTo(user.id(), Channels.ON_REMATCH_REQUEST);
}
List<String> endedReasonsTo(AuthUser user) {
return rtmt.messagesTo(user.id(), Channels.ON_REMATCH_ENDED).stream().map(m -> m.get("reason").asText()).toList();
}
static Seat other(Seat seat) {
return seat == Seat.PLAYER1 ? Seat.PLAYER2 : Seat.PLAYER1;
}
@Test
void whenBothAskTheNextGameStartsWithTheSameSeatsTheOtherFirstSeatAndTheSeries() {
BackendGame game = startGame();
Seat firstSeat = game.firstSeat();
finish(game);
ask(ALICE, game);
assertEquals(List.of(Json.tree(Map.of("gameId", game.id))), requestsTo(BOB));
assertNull(gameStore.getGameByUser(ALICE.id()));
ask(BOB, game);
BackendGame next = gameStore.getGameByUser(ALICE.id());
assertNotNull(next);
assertEquals(game.mancalaGame.getPlayer1Id(), next.mancalaGame.getPlayer1Id());
assertEquals(game.mancalaGame.getPlayer2Id(), next.mancalaGame.getPlayer2Id());
assertEquals(other(firstSeat), next.firstSeat());
assertEquals(game.series.id(), next.series.id());
assertEquals(List.of(game.id), next.series.games().stream().map(g -> g.gameId()).toList());
for (AuthUser user : List.of(ALICE, BOB)) {
assertEquals(next.id, rtmt.lastMessageTo(user.id(), Channels.ON_GAME_START).get("id").asText());
}
assertEquals(0, loop.pendingTimers());
}
@Test
void theFirstSeatAlternatesThroughTheSeries() {
BackendGame game = startGame();
Seat firstSeat = game.firstSeat();
for (int number = 2; number <= 4; number++) {
finish(game);
ask(BOB, game);
ask(ALICE, game);
game = gameStore.getGameByUser(ALICE.id());
assertEquals(number % 2 == 0 ? other(firstSeat) : firstSeat, game.firstSeat());
assertEquals(number - 1, game.series.games().size());
}
}
@Test
void thereIsNoRematchAfterAPlayerLeft() {
BackendGame game = startGame();
rtmt.receive(ALICE.id(), Channels.LEAVE_GAME);
assertFalse(rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END).has("rematch"));
ask(BOB, game);
assertEquals(List.of("closed"), endedReasonsTo(BOB));
assertTrue(requestsTo(ALICE).isEmpty());
}
@Test
void sayingNoEndsTheSeries() {
BackendGame game = startGame();
finish(game);
ask(ALICE, game);
decline(BOB, game);
assertEquals(List.of("declined"), endedReasonsTo(ALICE));
assertEquals(List.of("declined"), endedReasonsTo(BOB));
ask(ALICE, game);
assertEquals(List.of("declined", "closed"), endedReasonsTo(ALICE));
assertEquals(1, requestsTo(BOB).size());
assertEquals(0, loop.pendingTimers());
}
@Test
void aWithdrawnRequestLeavesTheOfferOpen() {
BackendGame game = startGame();
finish(game);
ask(ALICE, game);
decline(ALICE, game);
assertEquals(List.of("withdrawn"), endedReasonsTo(BOB));
ask(BOB, game);
assertEquals(1, requestsTo(ALICE).size());
ask(ALICE, game);
assertNotNull(gameStore.getGameByUser(BOB.id()));
}
@Test
void aRequestNotAnsweredWithinAMinuteExpiresAndCanBeMadeAgain() {
BackendGame game = startGame();
finish(game);
ask(ALICE, game);
loop.advance(RematchOffers.ANSWER_MS - 1);
assertTrue(endedReasonsTo(ALICE).isEmpty());
loop.advance(1);
assertEquals(List.of("expired"), endedReasonsTo(ALICE));
assertEquals(List.of("expired"), endedReasonsTo(BOB));
ask(ALICE, game);
assertEquals(2, requestsTo(BOB).size());
}
@Test
void aPlayerGoingOfflineEndsTheRequest() {
BackendGame game = startGame();
finish(game);
ask(ALICE, game);
rtmt.disconnect(BOB.id());
assertEquals(List.of("offline"), endedReasonsTo(ALICE));
}
@Test
void aRequestToAnOpponentWhoIsOfflineEndsAtOnce() {
BackendGame game = startGame();
finish(game);
rtmt.disconnect(BOB.id());
ask(ALICE, game);
assertEquals(List.of("offline"), endedReasonsTo(ALICE));
assertTrue(requestsTo(BOB).isEmpty());
rtmt.connect(BOB);
ask(ALICE, game);
assertEquals(1, requestsTo(BOB).size());
}
@ParameterizedTest
@ValueSource(strings = { Channels.NEW_GAME, Channels.NEW_BOT_GAME })
void lookingForAnotherGameEndsTheSeries(String channel) {
BackendGame game = startGame();
finish(game);
ask(ALICE, game);
rtmt.receive(BOB.id(), channel, Map.of("level", "easy"));
assertEquals(List.of("left"), endedReasonsTo(ALICE));
ask(ALICE, game);
assertEquals(List.of("left", "closed"), endedReasonsTo(ALICE));
}
@Test
void aRematchCanBeAskedForUpToTenMinutesAfterTheGame() {
BackendGame game = startGame();
finish(game);
loop.advance(RematchOffers.OPEN_MS);
assertEquals(List.of("closed"), endedReasonsTo(ALICE));
assertEquals(List.of("closed"), endedReasonsTo(BOB));
ask(ALICE, game);
assertTrue(requestsTo(BOB).isEmpty());
}
@Test
void theBotAcceptsAtOnceAtTheSameLevel() {
BotSettings bot = BotSettings.draw(BotLevel.HARD, new SplittableRandom(2));
BackendGame game = gameManager.createMancalaGame(ALICE.id(), bot.botId(), bot);
gameManager.startGame(game);
Seat firstSeat = game.firstSeat();
finish(game);
ask(ALICE, game);
BackendGame next = gameStore.getGameByUser(ALICE.id());
assertNotNull(next);
assertEquals(bot.botId(), next.mancalaGame.getPlayer2Id());
assertEquals(BotLevel.HARD, next.bot.level());
assertEquals(other(firstSeat), next.firstSeat());
assertEquals(game.series.id(), next.series.id());
assertEquals(1, next.series.games().size());
}
@Test
void aPlayerAlreadyInAnotherGameIsNotPulledIntoARematch() {
BackendGame game = startGame();
finish(game);
ask(ALICE, game);
// As after a restore: Bob has a game without having looked for one.
BackendGame other = gameManager.createMancalaGame(BOB.id(), CAROL.id());
ask(BOB, game);
assertNull(gameStore.getGameByUser(ALICE.id()));
assertEquals(other, gameStore.getGameByUser(BOB.id()));
assertEquals(List.of("closed"), endedReasonsTo(ALICE));
}
}

View File

@ -90,7 +90,7 @@ class PostgresGameHistoryRepositoryTest {
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) {
while (rows.next()) versions.add(rows.getString(1));
}
assertEquals(List.of("001_games", "002_clock", "003_bot"), versions);
assertEquals(List.of("001_games", "002_clock", "003_bot", "004_series"), versions);
}
@Test
@ -122,7 +122,7 @@ class PostgresGameHistoryRepositoryTest {
GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false));
GameRecord botGame = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(),
game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null,
game.moves(), game.totalSpectators(), new BotSettings(BotLevel.HARD, 72, -5_123_456_789_012L));
game.moves(), game.totalSpectators(), new BotSettings(BotLevel.HARD, 72, -5_123_456_789_012L), null);
repository.save(botGame);
assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id())));
assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId());
@ -131,6 +131,19 @@ class PostgresGameHistoryRepositoryTest {
assertFalse(Json.tree(repository.get(betweenPeople.id())).has("bot"));
}
@Test
void keepsTheSeriesAGameBelongsTo() {
GameRecord game = record();
GameRecord inSeries = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(),
game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null,
game.moves(), game.totalSpectators(), null, UUID.randomUUID().toString());
repository.save(inSeries);
assertEquals(Json.tree(inSeries), Json.tree(repository.get(game.id())));
GameRecord before = record();
repository.save(before);
assertFalse(Json.tree(repository.get(before.id())).has("seriesId"));
}
@Test
void keepsLeftUserIdForGamesAPlayerLeft() {
GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null);

View File

@ -6,7 +6,7 @@ import {
Route,
} from "react-router-dom";
import FloatingPanel from './components/FloatingPanel';
import GamePage from './routes/GamePage';
import GameRoute from './routes/GamePage';
import Home from './routes/Home';
import { initContext } from './context/context';
@ -127,7 +127,7 @@ const MancalaApp: FunctionComponent = () => {
<Route index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
<Route path="/" >
<Route path="game" >
<Route path=":gameId" element={<GamePage context={context} theme={theme} userKey={userKey} />} ></Route>
<Route path=":gameId" element={<GameRoute context={context} theme={theme} userKey={userKey} />} ></Route>
</Route>
<Route path="loby" element={<LobyPage context={context} theme={theme} userKey={userKey} />}>
</Route>

View File

@ -1,32 +1,57 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { CloseIcon } from "../Icons";
// Over the board when a game ends: the result, why it ended, the score, and where to go next.
// A player's stones at the end, for the score columns.
export interface FinalScore {
name: string;
stones: number;
isWinner: boolean;
// The bottom player, whose wins are shown in the accent colour.
isBottom: boolean;
}
// Over the board when a game ends: the result, why it ended, the score as two columns (name above stones), the
// series score, a line about a rematch, and the page's buttons.
const GameOverCard: FunctionComponent<{
title: string,
reason?: string,
score: string,
// "New Game" unless given, e.g. "Play again" after a game against the bot.
newGameLabel?: string,
onNewGame: () => void,
// The bottom player's first.
scores: [FinalScore, FinalScore],
seriesScore?: string,
status?: string,
// The status is something being waited for.
waiting?: boolean,
children: React.ReactNode,
onClose: () => void,
}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => {
}> = ({ title, reason, scores, seriesScore, status, waiting, children, onClose }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const scoreColumn = (score: FinalScore) => (
<div className={"osc" + (score.isWinner ? " win" : "") + (score.isBottom ? " mine" : "")}>
<span className="oname" title={score.name}>{score.name}</span>
<span className="onum">{score.stones}</span>
</div>
);
return (
<div className="over">
<div className="ocard" role="dialog" aria-label={title}>
<button className="btn icon ghost close" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
<div className="otitle">{title}</div>
{reason && <div className="oreason">{reason}</div>}
<div className="oscore">{score}</div>
<div className="row">
<button className="btn pri" onClick={onNewGame}>{newGameLabel ?? t("NewGame")}</button>
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
<div className="oscore" aria-label={`${t("Score")}: ${scores.map((s) => `${s.name} ${s.stones}`).join(", ")}`}>
{scoreColumn(scores[0])}
<span className="odash" aria-hidden="true">–</span>
{scoreColumn(scores[1])}
</div>
{seriesScore && <div className="oseries">{seriesScore}</div>}
{status && (
<div className={"ostatus" + (waiting ? " waiting" : "")} role="status">
{waiting && <span className="dot" />}
{status}
</div>
)}
<div className="row">{children}</div>
</div>
</div>
);

View File

@ -0,0 +1,78 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { useTranslation } from "react-i18next";
import { getSeriesScore, Seat, SeriesGame } from "@mancala/core";
// The most boxes the strip shows; earlier games collapse into a "+N" box.
const MAX_BOXES = 8;
// Above the board from the second game of a series (docs/specs/rematch, R3): the series score and one box per game,
// each with its winner's colour, the stores from the bottom player's side, a dot for who moved first and how it
// ended. Finished games open their replay in a new tab; the game being played is marked.
const SeriesStrip: FunctionComponent<{
// Finished games, oldest first.
games: SeriesGame[],
// A game of the series is being played after them.
hasCurrentGame: boolean,
bottomSeat: Seat,
bottomName: string,
topName: string,
}> = ({ games, hasCurrentGame, bottomSeat, bottomName, topName }) => {
const { t } = useTranslation();
const score = getSeriesScore(games, bottomSeat);
const finishedBoxes = MAX_BOXES - (hasCurrentGame ? 1 : 0);
const hiddenCount = games.length > finishedBoxes ? games.length - finishedBoxes + 1 : 0;
const shown = games.slice(hiddenCount);
const renderGame = (game: SeriesGame, index: number) => {
const number = hiddenCount + index + 1;
const bottomStore = bottomSeat === "player1" ? game.player1Store : game.player2Store;
const topStore = bottomSeat === "player1" ? game.player2Store : game.player1Store;
const winner = game.result === "player1" || game.result === "player2"
? (game.result === bottomSeat ? "mine" : "theirs") : "draw";
const ending = game.endReason === "timeout" ? t("EndedOnTime") : game.endReason === "left" ? t("EndedByLeaving") : undefined;
const bottomFirst = game.firstSeat === bottomSeat;
const firstMark = <span className="sfirst" title={t("MovedFirst")} />;
const label = [
t("SeriesGameNumber", { number }),
`${bottomName} ${bottomStore} – ${topStore} ${topName}`,
`${t("MovedFirst")}: ${bottomFirst ? bottomName : topName}`,
ending,
].filter(Boolean).join(", ");
return (
<li key={game.gameId}>
<a className={"sg " + winner} href={`/game/${game.gameId}`} target="_blank" rel="noopener" aria-label={label}>
<span className="sn">{ending ? `${number} · ${ending}` : number}</span>
<span className="ss">{bottomFirst && firstMark}{bottomStore}–{topStore}{!bottomFirst && firstMark}</span>
</a>
</li>
);
};
return (
<nav className="series" aria-label={t("Series")}>
<div className="sscore">
<span className="slabel">{t("Series")}</span>
<span className="sname">{bottomName}</span>
<b>{score.wins} – {score.losses}</b>
<span className="sname">{topName}</span>
</div>
<ol className="sgames">
{hiddenCount > 0 && (
<li><span className="sg more" title={t("SeriesEarlierGames", { count: hiddenCount })}>+{hiddenCount}</span></li>
)}
{shown.map(renderGame)}
{hasCurrentGame && (
<li aria-current="true">
<span className="sg now">
<span className="sn">{games.length + 1}</span>
<span className="ss">{t("SeriesCurrentGame")}</span>
</span>
</li>
)}
</ol>
</nav>
);
};
export default SeriesStrip;

View File

@ -10,6 +10,7 @@ import PageContainer from '../components/PageContainer';
import AppHeader from '../components/headerbar/AppHeader';
import { LeaveIcon, PlusIcon } from '../components/Icons';
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core';
import { channel_on_game_start, channel_rematch, channel_decline_rematch, channel_on_rematch_request, channel_on_rematch_ended, RematchRequest, RematchEnded, RematchEndReason, isSeriesOver, getSeriesScore } from '@mancala/core';
import { Context } from '../context/context';
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
@ -28,9 +29,32 @@ import GameClockView from '../components/GameClockView';
import useGameClock from '../hooks/useGameClock';
import PlayerCard from '../components/game/PlayerCard';
import GameOverCard from '../components/game/GameOverCard';
import SeriesStrip from '../components/game/SeriesStrip';
import RulesPanel from '../components/rules/RulesPanel';
import '../styles/game.css';
// Where a rematch of the ended game stands (docs/specs/rematch): none before the end, available to ask, waiting for
// the opponent, requested by the opponent, or over (a player left, said no or looked for another game).
type RematchStatus = "none" | "available" | "waiting" | "requested" | "over";
interface RematchState {
status: RematchStatus;
// Translation key of what happened to the last request; worded with the opponent's name.
note?: string;
}
// What the player is told when a request ends; nothing when they ended it themselves.
function getRematchNoteKey(status: RematchStatus, reason: RematchEndReason): string | undefined {
switch (reason) {
case "declined": return status === "waiting" ? "RematchDeclined" : undefined;
case "withdrawn": return status === "requested" ? "RematchWithdrawn" : undefined;
case "expired": return status === "waiting" ? "RematchNoAnswer" : status === "requested" ? "RematchRequestExpired" : undefined;
case "offline":
case "left": return status === "over" ? undefined : "RematchOpponentGone";
case "closed": return status === "over" ? undefined : "RematchClosed";
}
}
const GamePage: FunctionComponent<{
context: Context,
userKey?: string,
@ -70,6 +94,8 @@ const GamePage: FunctionComponent<{
// Set when the player closes the game over card to look at the board.
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
const [rematch, setRematch] = useState<RematchState>({ status: "none" });
const navigate = useNavigate();
const { t } = useTranslation();
@ -123,6 +149,7 @@ const GamePage: FunctionComponent<{
const onGameEnd = (message: GameEndMessage) => {
setGameEnd(message);
setHasOngoingAction(false);
setRematch({ status: message.rematch === "available" ? "available" : "over" });
};
const onUserConnectionChange = (message: any) => {
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
@ -131,12 +158,35 @@ const GamePage: FunctionComponent<{
const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => {
const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message);
// The opponent asked for a rematch; the card comes back if it was closed.
const onRematchRequest = (message: RematchRequest) => {
if (message.gameId !== game.id) return;
setRematch({ status: "requested" });
setGameOverClosed(false);
};
const onRematchEnded = (message: RematchEnded) => {
if (message.gameId !== game.id) return;
setRematch((rematch) => ({
status: isSeriesOver(message.reason) ? "over" : "available",
note: getRematchNoteKey(rematch.status, message.reason),
}));
};
// The server sends a player's new game only to them: here, the rematch both have asked for.
const onGameStart = (message: Game) => {
if (message.id !== game.id) navigate(`/game/${message.id}`);
};
context.rtmt.addMessageListener(channel_on_game_update, _onGameUpdate);
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
context.rtmt.addMessageListener(channel_on_game_end, onGameEnd);
context.rtmt.addMessageListener(channel_on_rematch_request, onRematchRequest);
context.rtmt.addMessageListener(channel_on_rematch_ended, onRematchEnded);
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
return () => {
context.rtmt.removeMessageListener(channel_on_rematch_request, onRematchRequest);
context.rtmt.removeMessageListener(channel_on_rematch_ended, onRematchEnded);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd);
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
@ -199,11 +249,26 @@ const GamePage: FunctionComponent<{
// In a game against the bot, the game over card starts another one at the same level.
const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId());
// Once a rematch of a bot game is no longer possible, "Play again" starts a new game at the same level.
const onPlayAgainClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
navigate(`/loby?bot=${opponentBotLevel}`)
};
// Asks for a rematch, or accepts the opponent's request; the bot accepts at once.
const onRematchClick = () => {
if (!game || Util.checkConnectionAndMaybeAlert(context, t)) return;
context.rtmt.sendMessage(channel_rematch, { gameId: game.id });
setRematch({ status: "waiting" });
};
// Withdraws the player's request, or says no to the opponent's, which ends the series.
const onDeclineRematchClick = () => {
if (!game || Util.checkConnectionAndMaybeAlert(context, t)) return;
context.rtmt.sendMessage(channel_decline_rematch, { gameId: game.id });
setRematch({ status: rematch.status === "waiting" ? "available" : "over" });
};
const onPitSelect = (index: number, pit: Pit) => {
if (!game || isSpectator || !userKey) {
return;
@ -373,6 +438,58 @@ const GamePage: FunctionComponent<{
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
}
const winnerSeat = endSummary?.result === "player1" || endSummary?.result === "player2" ? endSummary.result : undefined;
// The series' finished games, with this one once it has ended (R3).
const seriesGames = gameEnd?.series?.games ?? game?.series?.games ?? [];
const hasCurrentSeriesGame = !isGameOver;
const showSeries = seriesGames.length + (hasCurrentSeriesGame ? 1 : 0) > 1;
const bottomSeat = (mancalaGame && getSeat(mancalaGame, bottomLocatedUserId)) ?? "player1";
// "You" for a player, the names for spectators (R3.7).
const seriesBottomName = isSpectator ? bottomLocatedUser.name : t("You");
const getSeriesScoreText = (): string | undefined => {
if (!showSeries) return undefined;
const score = getSeriesScore(seriesGames, bottomSeat);
return t("SeriesScoreText", { first: seriesBottomName, firstWins: score.wins, secondWins: score.losses, second: topLocatedUser.name });
};
const getRematchStatusText = (): string | undefined => {
if (isSpectator) return undefined;
const name = topLocatedUser.name;
if (rematch.status === "waiting" && !opponentBotLevel) return t("RematchWaiting", { name });
if (rematch.status === "requested") return t("RematchRequested", { name });
return rematch.note && t(rematch.note, { name });
};
const renderGameOverButtons = () => {
const newGame = (primary: boolean) => (
<button key="new" className={"btn " + (primary ? "pri" : "ghost")} onClick={onNewGameClick}>{t("NewGame")}</button>
);
const home = <button key="home" className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>;
if (isSpectator) return [newGame(true), home];
if (opponentBotLevel) {
const canRematch = rematch.status === "available" || rematch.status === "waiting";
return [
<button key="again" className="btn pri" disabled={rematch.status === "waiting"}
onClick={canRematch ? onRematchClick : onPlayAgainClick}>{t("PlayAgain")}</button>,
home,
];
}
switch (rematch.status) {
case "available":
return [<button key="rematch" className="btn pri" onClick={onRematchClick}>{t("Rematch")}</button>, newGame(false), home];
case "waiting":
return [<button key="cancel" className="btn ghost" onClick={onDeclineRematchClick}>{t("Cancel")}</button>, home];
case "requested":
return [
<button key="rematch" className="btn pri" onClick={onRematchClick}>{t("Rematch")}</button>,
<button key="no" className="btn ghost" onClick={onDeclineRematchClick}>{t("No")}</button>,
];
default:
return [newGame(true), home];
}
};
const getTurnText = () => {
if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded");
if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`;
@ -397,7 +514,17 @@ const GamePage: FunctionComponent<{
alignRight={alignRight} />
);
return (
<div className="gl">
<div className={"gl" + (showSeries ? " has-series" : "")}>
{showSeries && (
<div className="a-series">
<SeriesStrip
games={seriesGames}
hasCurrentGame={hasCurrentSeriesGame}
bottomSeat={bottomSeat}
bottomName={seriesBottomName}
topName={topLocatedUser.name} />
</div>
)}
<div className="a-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div>
<div className="a-turn">
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
@ -434,10 +561,16 @@ const GamePage: FunctionComponent<{
<GameOverCard
title={resultTitle ?? t("GameEnded")}
reason={resultReason}
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined}
onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick}
onClose={() => setGameOverClosed(true)} />
scores={[
{ name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true },
{ name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false },
]}
seriesScore={getSeriesScoreText()}
status={getRematchStatusText()}
waiting={rematch.status === "waiting"}
onClose={() => setGameOverClosed(true)}>
{renderGameOverButtons()}
</GameOverCard>
)}
</div>
<div className="a-moves">
@ -477,4 +610,10 @@ const GamePage: FunctionComponent<{
}
}
export default GamePage;
// A page of its own for every game, so following a rematch to the new game's id starts the page afresh.
const GameRoute: FunctionComponent<React.ComponentProps<typeof GamePage>> = (props) => {
const { gameId } = useParams<{ gameId: string }>();
return <GamePage key={gameId} {...props} />;
};
export default GameRoute;

View File

@ -13,6 +13,8 @@
.a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
.a-me { grid-area: me; justify-self: end; min-width: 0; }
.a-board { grid-area: board; position: relative; min-width: 0; }
.gl.has-series { grid-template-areas: "series series series" "opp turn me" "board board board" "moves moves moves"; }
.a-series { grid-area: series; min-width: 0; }
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
/* Players */
@ -156,8 +158,43 @@
.ocard .close { position: absolute; top: 8px; right: 8px; }
.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; }
.oreason { font-size: 15px; font-weight: 700; color: var(--muted); }
.oscore { font-size: 18px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 8px; }
.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
/* The score: each player's name above their stones, the bottom player's on the left. */
.oscore { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: end; gap: 0 14px; width: 100%; min-width: 220px; margin: 2px 0 4px; }
.osc { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; }
.oname { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 700; color: var(--muted); }
.onum { font-size: 38px; font-weight: 800; line-height: 1; letter-spacing: -.02em; color: var(--muted); font-variant-numeric: tabular-nums; }
.osc:not(.win) .onum { opacity: .55; }
.osc.win .onum { color: var(--ink); }
.osc.win.mine .onum { color: var(--accent); }
.odash { padding-bottom: 6px; font-size: 22px; font-weight: 700; color: var(--muted); }
.oseries { font-size: 14px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
.ostatus { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
.ostatus.waiting { color: var(--muted); }
.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 6px; }
/* Series strip: the score, then a box per game in the winner's colour, the game being played outlined. */
.series { display: flex; align-items: center; justify-content: center; gap: 10px 16px; flex-wrap: wrap; min-width: 0; }
.sscore { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: 15px; white-space: nowrap; }
.sscore b { font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; }
.slabel { font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.sname { max-width: 140px; overflow: hidden; text-overflow: ellipsis; font-weight: 700; }
.sgames { display: flex; gap: 8px; max-width: 100%; margin: 0; padding: 2px; list-style: none; overflow-x: auto; scrollbar-width: none; }
.sgames::-webkit-scrollbar { display: none; }
.sg {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
min-width: 62px; height: 52px; padding: 0 8px; box-sizing: border-box; border-radius: 14px;
background: var(--surface2); color: var(--ink); text-decoration: none; font-variant-numeric: tabular-nums;
}
a.sg:hover { filter: brightness(1.08); }
a.sg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.sg.mine { background: var(--accent); color: var(--onAccent); }
.sg.theirs { background: var(--ink); color: var(--bg); }
.sg.now { background: transparent; box-shadow: inset 0 0 0 2px var(--accent); }
.sg.more { color: var(--muted); font-weight: 800; }
.sn { font-size: 11px; font-weight: 700; line-height: 1.2; opacity: .75; white-space: nowrap; }
.ss { display: flex; align-items: center; gap: 3px; font-size: 15px; font-weight: 800; white-space: nowrap; }
.sfirst { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
/* Tags of a move: extra turn, capture, stones to the store */
@ -168,6 +205,9 @@
@media (max-width: 640px) {
.gl { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "opp turn" "board board" "me me" "moves moves"; gap: 16px 10px; }
.gl.has-series { grid-template-areas: "series series" "opp turn" "board board" "me me" "moves moves"; }
.series { flex-direction: column; gap: 8px; }
.sgames { justify-content: flex-start; }
.a-me { justify-self: start; }
.pl.r { flex-direction: row; text-align: left; }
.pl { gap: 8px; }

View File

@ -0,0 +1,143 @@
# Rematch and series: design
See `requirements.md`; requirement ids (R1.1, …) are referenced below. Builds on `game-clock` (the end of a game),
`game-history` (saved games, replays), `game-bot` ("Play again") and `web-redesign` (the game over card, whose design
already has a rematch button).
## Decisions
| Decision | Reason |
|---|---|
| The server owns series and rematch requests; clients only ask, answer and show | Both players must see the same series, and the server already decides who moves first (R2). |
| A series is carried by its live game (`BackendGame.series`): its id and the results of the games before | No new store to keep in step: the series lives and is snapshotted with the game, so it survives restarts (R5.2), and is dropped with it. |
| Rematch requests are kept in memory only, keyed by the finished game's id | They last at most 10 minutes; losing them in a restart costs a click (R5.2). Valkey would add writes for little gain. |
| Series entries name seats (`player1`/`player2`), not user ids | The strip needs no ids, and nothing new can leak a legacy user's secret id (`PublicIds`). Seats stay the same through a series (R2.3). |
| A rematch starts with the usual `on_game_start`; the game page follows it to the new game | Clients already handle a game start; a new route per game keeps reloads, links and replays as they are. |
| "Play again" against the bot becomes a rematch the bot accepts at once | One flow for both kinds of games; bot series get the strip and alternating first moves (R4). |
| Every new game gets a series id, the first game too; rematches keep it | A game alone is a series of one, so grouping later needs no special case (R5.1). Games saved before have none. |
| No fixed series length | The user chose open series; "best of N" can be added on top (out of scope). |
## Backend
### Model
```java
// One finished game of a series, as the strip shows it.
record SeriesGame(String gameId, Seat firstSeat, GameResult result, EndReason endReason,
int player1Store, int player2Store) {}
// The series a live game belongs to: its id and the games played before this one, oldest first.
record Series(String id, List<SeriesGame> games) {
Series next(SeriesGame finished); // with `finished` appended
}
```
- `BackendGame` gains `series` (final; never null) and `firstSeat()`, the seat that moved first: the seat of the first
move's player, or of the player to move before any move. Derived rather than stored, so restored games and tests that
set the player to move need nothing more.
- `ClientGame` gains `series` (`@JsonInclude(NON_NULL)`, the games before this one) and `firstSeat`.
- `GameEndMessage` gains `series` (`NON_NULL`) with the ended game appended, and `rematch` (`NON_NULL`): `"available"`
when a rematch can be asked for, left out after a game a player left (R1.1).
- `LiveGameSnapshot` gains `series`, optional: snapshots without it restore as a new series of one game.
- `GameRecord` gains `seriesId` (`NON_NULL`); migration `004_series` adds `games.series_id uuid` (NULL for older
games) and an index on it.
### Who moves first (R2)
`createMancalaGame(userId1, userId2, bot, series, firstSeat)`: `firstSeat` null draws at random (R2.1), as now. A
rematch passes the seat opposite to the previous game's first seat (R2.2) and the same user order, so the seats stay
(R2.3).
### Rematch requests (`RematchOffers`, on the game loop)
`RematchOffers` is built next to `GameManager`, like `BotPlayer`, and hooks into it: `onGameEnded` (every ending
except a crash, with the end message) and `onPlayerSearching` (`new_game` and `new_bot_game`, before they are handled).
It listens to its own channels and to connection changes, and starts the next game with `createMancalaGame` and
`startGame`. When a game ends, it opens an entry unless a player left (R1.1). An entry keeps what the next game needs: the players in seat order, the bot settings' level, the
series with the ended game appended and the next first seat; plus who has asked, and two timers.
```
on_game_end ─► entry (open, 10 min) ── 10 min ──► gone (R1.7)
rematch from A ─► A asked; send on_rematch_request to B; 60 s timer
├─ rematch from B ─► start the next game; entry gone (R1.4)
├─ decline_rematch from B ─► on_rematch_ended(declined); entry gone (R1.6)
├─ decline_rematch from A ─► on_rematch_ended(withdrawn); entry stays open
├─ 60 s ─► on_rematch_ended(expired); entry stays open
├─ A or B offline ─► on_rematch_ended(offline); entry stays open
└─ A or B sends new_game / new_bot_game ─► on_rematch_ended(left); entry gone (R1.6)
```
- `on_rematch_ended` goes to both players: each client knows whether it asked and words the reason from that.
- A request to an opponent who is offline ends at once with `offline` (R1.5).
- `rematch` for a game without an entry (over 10 minutes, or the series ended) answers `on_rematch_ended(closed)`.
- Against the bot, `rematch` starts the next game at once (R4.1, R4.2); the bot takes a new strength as in
`onNewBotGame`.
- The players' user ids map to the entry (`entryByUser`), so `new_game` and going offline find it; a player is in at
most one entry, the one of their last game.
- Before starting the next game, the manager checks that neither player has a game in progress (they cannot, since
matchmaking removes the entry, but a restored or crashed state should not give a player two games).
### Channels (`Channels`, `channel_names.ts`)
| Channel | Direction | Message |
|---|---|---|
| `rematch` | client → server | `{ gameId }`: ask for a rematch of that ended game, or accept the opponent's request |
| `decline_rematch` | client → server | `{ gameId }`: say no, or withdraw one's own request |
| `on_rematch_request` | server → client | `{ gameId }`: the opponent asked; the client knows who that is |
| `on_rematch_ended` | server → client | `{ gameId, reason }`, reason one of `declined`, `withdrawn`, `expired`, `offline`, `left`, `closed` (10 minutes, or a new game) |
The next game reaches both players as `on_game_start`, like a game from the lobby.
## Core (`packages/core`)
- Channel names; types `Series`, `SeriesGame`, `RematchRequest`, `RematchEnded`; `Game.series`, `Game.firstSeat`,
`GameEndMessage.series` and `rematch`.
- `getSeriesScore(series, seat)`: wins of the seat and of the other seat, draws apart.
- Texts: `Rematch`, `RematchWaiting` ("Waiting for {{name}}…"), `RematchRequested` ("{{name}} wants a rematch"),
`RematchDeclined`, `RematchExpired`, `RematchOpponentGone`, `No`, `Series`, `SeriesScore`, `MovedFirst`,
`GamesNotShown` ("+{{count}}"), in `en.ts` and `tr.ts`.
## Web app
- **Following a rematch**: `GamePage` listens to `on_game_start` while a game of the user is over and navigates to
`/game/<new id>`. The route renders `GamePage` with `key={gameId}`, so the page starts afresh for the new game (its
load effect runs once per mount).
- **Game over card** (`GameOverCard`) gets a `rematch` state from the page:
- `available`: "Rematch" (primary), "New Game", "Home".
- `waiting`: "Waiting for Ahmet…" with "Cancel" (`decline_rematch`).
- `requested`: "Ahmet wants a rematch" with "Rematch" (primary) and "No". A request reopens a closed card (R1.3).
- `ended`: the reason as a short line, then "New Game" (and "Rematch" again unless the series is over, R1.6).
- Bot games: "Play again" sends `rematch` (R4.1); the lobby's `?bot=` stays for the home page and the lobby offer.
- The final score as two columns, the bottom player's first: the name (small, muted) above the store count
(large), the winner's count in their colour (R3.8).
- The series score line once the series has more than one game (R3.6).
- **Series strip** (`SeriesStrip`, R3): the first row of the game grid, above the players and the board, shown from
the second game of a series. The
score, then one box per game: the winner's colour (`--accent` for the bottom player, the opponent's colour for the
top one, neutral for a draw), "30–18" from the bottom player's side, a small dot for the player who moved first, a
"time" or "left" after the game's number. The current game's box is outlined and says "now". Finished boxes are links to
`/game/<id>` with `target="_blank"` (R3.4). At most 8 boxes; earlier games collapse into a "+N" box (R3.5). Below
600 px the strip scrolls sideways inside itself, never the page.
- Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons.
## Mobile app
Unchanged (R6). New fields in `on_game_start`, `on_game_update` and `on_game_end` are ignored by the mobile app; it
has no listeners for the new channels.
## Testing
- Backend unit tests (fakes, `ManualScheduler`): both ask → a game with the same seats, the other first seat and the
series appended; the third game alternates again; no rematch after a player left; decline, withdraw, 60 s expiry,
offline, `new_game` and `new_bot_game` each end the request with the right reason and to the right player; asking
again after withdraw and expiry; the 10 minute limit; the bot accepts at once at the same level; a player in a new
game cannot be pulled into a rematch.
- Serialisation: `series` and `firstSeat` round-trip through snapshots; old snapshots without them restore; the JSON of
`ClientGame` and `GameEndMessage` without a series is unchanged.
- Storage (`@QuarkusTest`): `series_id` saved and read; migration on a database with games from before.
- Core: `getSeriesScore`, text keys in both languages.
- Web: build and the temporary type check.
- End to end in Chrome, two browsers: a game ends, A asks, B sees the request and accepts, both land on the new game
with the other player moving first and the strip showing game 1; three games in a row alternate; B declines and A
is told; A asks and B does not answer within 60 s; a player who left gets no rematch; "Play again" against the bot
continues the series; a strip box opens the replay in a new tab; the strip at phone width.

View File

@ -0,0 +1,77 @@
# Rematch and series: requirements
Status: implemented on branch `feature/rematch` (based on `develop`); checked in Chrome against the local stack.
Scope: backend, `packages/core`, web app. The mobile app comes later (see R6).
## Goals
Two players who enjoyed a game can play again at once, without going back to the lobby, and see how their games
went: one box per game and the score of the series. Who moves first alternates, so neither player keeps the
advantage of the first move.
## Terms
- **Rematch**: a new game between the same two players, started from the game over card when both ask for it.
- **Series**: a game and the rematches that follow it, one after another. A series has no fixed length; it ends when
a rematch is not played.
- **Series score**: games won by each player in the series; draws count for neither.
- **Rematch request**: one player's "Rematch" waiting for the other's answer.
## Requirements
### R1. Asking for a rematch
- R1.1 When a game between two people ends, the game over card offers "Rematch" (Turkish: "Rövanş") to both
players, unless a player left the game: then the series is over and the card offers "New Game" as now.
- R1.2 The player who asks sees that they are waiting for the opponent and can withdraw the request.
- R1.3 The opponent sees the request on their game over card ("Ahmet wants a rematch") with "Rematch" and "No",
also when they had closed the card.
- R1.4 When both have asked, the new game starts for both at once, and their game pages show it.
- R1.5 A request ends without a game when: the opponent says no, the asking player withdraws, either player starts
looking for another game or starts a bot game, either player goes offline, or the opponent does not answer within
60 seconds. The asking player is told why ("Ahmet declined", "Ahmet is no longer here").
- R1.6 "No", or either player starting another game or a search, ends the series: the card offers only "New Game".
After a withdrawn or unanswered request, either player can ask again.
- R1.7 A rematch is possible for 10 minutes after the game ended; after that the card only offers "New Game".
### R2. Who moves first
- R2.1 The first game of a series starts with a player chosen at random, as now.
- R2.2 In every rematch, the player who did not move first in the previous game of the series moves first.
- R2.3 The players keep their sides of the board in a rematch.
### R3. Series score
- R3.1 From the second game of a series on, the game page shows the series above the board: the series score
("You 2 – 1 Ahmet") and one box per game, in order.
- R3.2 A finished game's box shows who won (in that player's colour, a draw neutral), the stones in both stores, a
mark for who moved first, and whether it ended on time or because someone left.
- R3.3 The box of the game being played is marked as the current game.
- R3.4 A finished game's box opens its replay in a new tab, so the live game is not left.
- R3.5 With more games than fit, the strip shows the latest games and how many earlier ones are not shown; the
score always counts all games.
- R3.6 The game over card shows the series score once the series has more than one game.
- R3.7 Spectators see the series strip too, with the players' names instead of "You".
- R3.8 The game over card shows the final score as two columns, each player's name above their stones, so names
and numbers do not run together ("Guest 6894 18 – 30 Bot · Hard" was hard to read).
### R4. Games against the bot
- R4.1 "Play again" after a bot game is a rematch: it starts at once, at the same level (the bot takes a new strength
as in any new game), and continues the series, with the series strip and alternating first moves.
- R4.2 The bot never declines a rematch.
### R5. Saving and restarts
- R5.1 Every saved game records which series it belongs to, so that the history can group series later.
- R5.2 A live game keeps its series across a backend restart (the strip shows the same games). Rematch requests
waiting at the time of the restart are dropped; the players can ask again.
### R6. Mobile app
- R6.1 The mobile app gets rematch and the series strip with its new design (`TODO.md`, Web redesign). Until then it
is unchanged: its players never see a rematch request, so a request from a web player to a mobile player ends
after 60 seconds (R1.5).
### R7. Languages
- R7.1 All texts exist in English and Turkish.
## Out of scope (TODO)
- Series of a fixed length ("best of 3") and declaring a series winner.
- Grouping series in the game history and on the home page.
- Taking spectators along to the next game of a series.
- Rematch and the series strip in the mobile app (R6).

View File

@ -0,0 +1,18 @@
# Rematch and series: tasks
Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed.
- [x] 1. **Spec**: requirements, design and tasks.
- [x] 2. **Series in live games**: `Series`, `SeriesGame`, `BackendGame.series` and `firstSeat()`, the first seat in
`createMancalaGame`; `series` and `firstSeat` in `ClientGame` and `GameEndMessage`, `series` in snapshots (with
the restore of old snapshots); unit and serialisation tests.
- [x] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on
`new_game`, `new_bot_game` and going offline, the bot accepting at once; unit tests.
- [x] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests.
- [x] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests.
- [x] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch
states, "Play again" as a rematch, the score in two columns.
- [x] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators.
- [x] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (the specs list, rematch notes),
`TODO.md` (the out of scope items; remove "A rematch button" from Web redesign), the web redesign design's note
on the rematch button.

View File

@ -38,5 +38,5 @@ at once. Without the parameter the endpoint behaves as before, which the mobile
## Not done
- The design's "Rematch" button: there is no rematch; the game-over card offers "New Game".
- The design's "Rematch" button: added later with rematches and series (`docs/specs/rematch`).
- The mobile app.

View File

@ -176,4 +176,22 @@ export const en = {
LessonDone: "Done",
AllLessons: "All lessons",
LessonNumber: "Lesson {{number}} of {{count}}",
Rematch: "Rematch",
No: "No",
RematchWaiting: "Waiting for {{name}}…",
RematchRequested: "{{name}} wants a rematch",
RematchDeclined: "{{name}} declined the rematch",
RematchWithdrawn: "{{name}} withdrew the rematch",
RematchNoAnswer: "{{name}} did not answer",
RematchRequestExpired: "The rematch request expired",
RematchOpponentGone: "{{name}} is no longer here",
RematchClosed: "A rematch is no longer possible",
Series: "Series",
SeriesScoreText: "Series: {{first}} {{firstWins}} – {{secondWins}} {{second}}",
SeriesGameNumber: "Game {{number}}",
SeriesCurrentGame: "Now",
SeriesEarlierGames: "{{count}} earlier games",
MovedFirst: "Moved first",
EndedOnTime: "time",
EndedByLeaving: "left",
}

View File

@ -176,4 +176,22 @@ export const tr = {
LessonDone: "Tamamlandı",
AllLessons: "Tüm dersler",
LessonNumber: "Ders {{number}} / {{count}}",
Rematch: "Rövanş",
No: "Hayır",
RematchWaiting: "{{name}} bekleniyor…",
RematchRequested: "{{name}} rövanş istiyor",
RematchDeclined: "{{name}} rövanşı kabul etmedi",
RematchWithdrawn: "{{name}} rövanştan vazgeçti",
RematchNoAnswer: "{{name}} cevap vermedi",
RematchRequestExpired: "Rövanş isteğinin süresi doldu",
RematchOpponentGone: "{{name}} artık burada değil",
RematchClosed: "Rövanş artık mümkün değil",
Series: "Seri",
SeriesScoreText: "Seri: {{first}} {{firstWins}} – {{secondWins}} {{second}}",
SeriesGameNumber: "{{number}}. oyun",
SeriesCurrentGame: "Şimdi",
SeriesEarlierGames: "{{count}} önceki oyun",
MovedFirst: "İlk hamle",
EndedOnTime: "süre",
EndedByLeaving: "ayrıldı",
};

View File

@ -1,5 +1,6 @@
import { MancalaGame } from "mancala.js";
import { GameClock } from "./GameClock";
import { GameClock, Seat } from "./GameClock";
import { Series } from "./Series";
import { UserConnectionInfo } from "./UserConnectionInfo";
export interface Game {
@ -14,6 +15,10 @@ export interface Game {
clock?: GameClock;
// Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock.
serverTime?: number;
// The series and the games before this one (docs/specs/rematch); sent by servers with rematches.
series?: Series;
// The seat that moved first.
firstSeat?: Seat;
}
export interface GameUsersConnectionInfo {

View File

@ -1,5 +1,6 @@
import { GameStep } from "mancala.js";
import { GameClock } from "./GameClock";
import { Series } from "./Series";
export interface PlayerSummary {
userId: string;
@ -57,6 +58,8 @@ export interface GameRecord extends FinishedGameSummary {
moves: MoveRecord[];
// Distinct users who watched the game.
totalSpectators: number;
// Undefined for games saved before rematches.
seriesId?: string;
}
// GET /game/:id for a game that has ended.
@ -74,4 +77,8 @@ export interface GameEndMessage {
leftUserId?: string;
// The stopped clocks.
clock?: GameClock;
// The series with the game that ended.
series?: Series;
// "available" when the players can ask for a rematch, i.e. unless a player left.
rematch?: "available";
}

View File

@ -0,0 +1,53 @@
import { EndReason, GameResult } from "./GameHistory";
import { Seat } from "./GameClock";
// Rematches and series (docs/specs/rematch).
// One finished game of a series. Seats, not user ids: the players keep their seats through a series.
export interface SeriesGame {
gameId: string;
firstSeat: Seat;
// Null when the game ended without a winner.
result: GameResult | null;
endReason: EndReason;
player1Store: number;
player2Store: number;
}
// A game and the rematches after it; `games` are the finished ones, oldest first.
export interface Series {
id: string;
games: SeriesGame[];
}
// on_rematch_request: the opponent asks for a rematch of the game that ended.
export interface RematchRequest {
gameId: string;
}
// declined: the opponent said no; withdrawn: the player who asked took it back; expired: no answer within a minute;
// offline: a player went offline; left: a player looked for another game; closed: a rematch is no longer possible.
export type RematchEndReason = "declined" | "withdrawn" | "expired" | "offline" | "left" | "closed";
// on_rematch_ended, to both players.
export interface RematchEnded {
gameId: string;
reason: RematchEndReason;
}
// After these, the series is over and only a new game is offered.
export function isSeriesOver(reason: RematchEndReason): boolean {
return reason === "declined" || reason === "left" || reason === "closed";
}
export interface SeriesScore {
// Games won by the seat and by the other seat; draws and games without a winner count for neither.
wins: number;
losses: number;
}
export function getSeriesScore(games: SeriesGame[], seat: Seat): SeriesScore {
const wins = games.filter((game) => game.result === seat).length;
const losses = games.filter((game) => game.result !== null && game.result !== "draw" && game.result !== seat).length;
return { wins, losses };
}

View File

@ -7,3 +7,4 @@ export * from './AuthUser'
export * from './GameHistory'
export * from './GameClock'
export * from './Bot'
export * from './Series'

View File

@ -29,3 +29,12 @@ export const channel_unsubscribe_live_games = "unsubscribe_live_games"
export const channel_on_live_games = "on_live_games"
// server -> client: { gameId }, when new_game is sent while the user has a game in progress.
export const channel_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 (RematchRequest) when the opponent asks; on_rematch_ended (RematchEnded) to both
// players when a request or the chance of a rematch ends. The next game arrives as on_game_start.
export const channel_rematch = "rematch"
export const channel_decline_rematch = "decline_rematch"
export const channel_on_rematch_request = "on_rematch_request"
export const channel_on_rematch_ended = "on_rematch_ended"

View File

@ -0,0 +1,30 @@
import { getSeriesScore, isSeriesOver, SeriesGame } from "../../src/models";
import { en, tr } from "../../src/localization";
const game = (result: SeriesGame["result"]): SeriesGame =>
({ gameId: "g", firstSeat: "player1", result, endReason: "completed", player1Store: 24, player2Store: 24 });
describe("series", () => {
it("counts the wins of each seat, leaving out draws and games without a winner", () => {
const games = [game("player1"), game("player2"), game("draw"), game("player1"), game(null)];
expect(getSeriesScore(games, "player1")).toEqual({ wins: 2, losses: 1 });
expect(getSeriesScore(games, "player2")).toEqual({ wins: 1, losses: 2 });
expect(getSeriesScore([], "player1")).toEqual({ wins: 0, losses: 0 });
});
it("knows which endings of a request end the series", () => {
expect(["declined", "left", "closed"].every((reason) => isSeriesOver(reason as any))).toBe(true);
expect(["withdrawn", "expired", "offline"].some((reason) => isSeriesOver(reason as any))).toBe(false);
});
it("has every rematch text in English and Turkish", () => {
const keys = ["Rematch", "No", "RematchWaiting", "RematchRequested", "RematchDeclined", "RematchWithdrawn",
"RematchNoAnswer", "RematchRequestExpired", "RematchOpponentGone", "RematchClosed", "Series",
"SeriesScoreText", "SeriesGameNumber", "SeriesCurrentGame", "SeriesEarlierGames", "MovedFirst", "EndedOnTime",
"EndedByLeaving"];
for (const key of keys) {
expect(en).toHaveProperty(key);
expect(tr).toHaveProperty(key);
}
});
});