Merge branch 'feature/rematch' into develop
This commit is contained in:
commit
e763682483
11
AGENTS.md
11
AGENTS.md
@ -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),
|
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,
|
`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
|
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
|
## 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
|
`dist`. After changing a package, run `yarn build` in it (or `yarn dev` to rebuild on change), or the apps use stale
|
||||||
code.
|
code.
|
||||||
Running dev servers do not notice a rebuilt package: restart Parcel after deleting `apps/frontend/.parcel-cache`.
|
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.
|
The backend does not use the packages.
|
||||||
- Backend and frontend Docker images are built from the repo root: `docker build -f apps/backend/Dockerfile .`.
|
- 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
|
- 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,
|
- 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
|
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.
|
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.
|
- `-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
|
- 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
|
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 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 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 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
|
- 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,
|
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.
|
not by URL, or the matchmaking message goes out before the WebSocket is open.
|
||||||
|
|||||||
12
TODO.md
12
TODO.md
@ -80,7 +80,17 @@ See `docs/specs/tutorial`.
|
|||||||
See `docs/specs/web-redesign`.
|
See `docs/specs/web-redesign`.
|
||||||
|
|
||||||
- [ ] Bring the new design to the mobile app (themes, board, game screen)
|
- [ ] 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
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import app.playmancala.game.GameRecorder;
|
|||||||
import app.playmancala.game.GameStore;
|
import app.playmancala.game.GameStore;
|
||||||
import app.playmancala.game.LiveGames;
|
import app.playmancala.game.LiveGames;
|
||||||
import app.playmancala.game.MatchMaker;
|
import app.playmancala.game.MatchMaker;
|
||||||
|
import app.playmancala.game.RematchOffers;
|
||||||
import app.playmancala.history.GameHistoryService;
|
import app.playmancala.history.GameHistoryService;
|
||||||
import app.playmancala.history.Migrations;
|
import app.playmancala.history.Migrations;
|
||||||
import app.playmancala.history.PostgresGameHistoryRepository;
|
import app.playmancala.history.PostgresGameHistoryRepository;
|
||||||
@ -103,6 +104,7 @@ public class Backend {
|
|||||||
loop::now, gameClockMs);
|
loop::now, gameClockMs);
|
||||||
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
||||||
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
|
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
|
||||||
|
new RematchOffers(gameManager, gameStore, rtmt, loop, new SplittableRandom());
|
||||||
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
||||||
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,12 @@ import java.util.Map;
|
|||||||
|
|
||||||
import app.playmancala.bot.BotSettings;
|
import app.playmancala.bot.BotSettings;
|
||||||
import app.playmancala.engine.MancalaGame;
|
import app.playmancala.engine.MancalaGame;
|
||||||
|
import app.playmancala.engine.MoveHistoryItem;
|
||||||
import app.playmancala.model.GameClock;
|
import app.playmancala.model.GameClock;
|
||||||
import app.playmancala.model.GameUsersConnectionInfo;
|
import app.playmancala.model.GameUsersConnectionInfo;
|
||||||
import app.playmancala.model.PlayerSummary;
|
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.
|
// A live game and the server-side state around it. Only touched on the game loop.
|
||||||
public class BackendGame {
|
public class BackendGame {
|
||||||
@ -30,10 +33,12 @@ public class BackendGame {
|
|||||||
public final List<Long> moveRemainingMs;
|
public final List<Long> moveRemainingMs;
|
||||||
// How the bot plays in a game against the bot; null in games between people.
|
// How the bot plays in a game against the bot; null in games between people.
|
||||||
public final BotSettings bot;
|
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,
|
public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo,
|
||||||
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
|
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.id = mancalaGame.getId();
|
||||||
this.mancalaGame = mancalaGame;
|
this.mancalaGame = mancalaGame;
|
||||||
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
|
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
|
||||||
@ -45,6 +50,14 @@ public class BackendGame {
|
|||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
|
this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
|
||||||
this.bot = bot;
|
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() {
|
public boolean isBotTurn() {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.IntSupplier;
|
import java.util.function.IntSupplier;
|
||||||
import java.util.function.LongSupplier;
|
import java.util.function.LongSupplier;
|
||||||
@ -17,6 +18,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
|
|
||||||
import app.playmancala.bot.BotLevel;
|
import app.playmancala.bot.BotLevel;
|
||||||
import app.playmancala.bot.BotSettings;
|
import app.playmancala.bot.BotSettings;
|
||||||
|
import app.playmancala.engine.Board;
|
||||||
import app.playmancala.engine.CommonMancalaGame;
|
import app.playmancala.engine.CommonMancalaGame;
|
||||||
import app.playmancala.engine.GameState;
|
import app.playmancala.engine.GameState;
|
||||||
import app.playmancala.engine.MancalaGame;
|
import app.playmancala.engine.MancalaGame;
|
||||||
@ -29,9 +31,12 @@ import app.playmancala.model.ClientGame;
|
|||||||
import app.playmancala.model.EndReason;
|
import app.playmancala.model.EndReason;
|
||||||
import app.playmancala.model.GameEndMessage;
|
import app.playmancala.model.GameEndMessage;
|
||||||
import app.playmancala.model.GameRecord;
|
import app.playmancala.model.GameRecord;
|
||||||
|
import app.playmancala.model.GameResult;
|
||||||
import app.playmancala.model.GameUsersConnectionInfo;
|
import app.playmancala.model.GameUsersConnectionInfo;
|
||||||
import app.playmancala.model.PlayerSummary;
|
import app.playmancala.model.PlayerSummary;
|
||||||
import app.playmancala.model.Seat;
|
import app.playmancala.model.Seat;
|
||||||
|
import app.playmancala.model.Series;
|
||||||
|
import app.playmancala.model.SeriesGame;
|
||||||
import app.playmancala.model.UserConnectionInfo;
|
import app.playmancala.model.UserConnectionInfo;
|
||||||
import app.playmancala.rtmt.Rtmt;
|
import app.playmancala.rtmt.Rtmt;
|
||||||
|
|
||||||
@ -53,6 +58,8 @@ public class GameManager {
|
|||||||
private final long gameClockMs;
|
private final long gameClockMs;
|
||||||
private final List<Runnable> liveGamesListeners = new ArrayList<>();
|
private final List<Runnable> liveGamesListeners = new ArrayList<>();
|
||||||
private final List<Consumer<BackendGame>> turnListeners = 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,
|
public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore,
|
||||||
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
|
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
|
||||||
@ -80,8 +87,19 @@ public class GameManager {
|
|||||||
turnListeners.add(listener);
|
turnListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called when a game has ended, for any reason but a crash, with what the players were sent.
|
||||||
|
public void onGameEnded(BiConsumer<BackendGame, GameEndMessage> listener) {
|
||||||
|
gameEndedListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called with a user who asks for a new game or a bot game, before it is handled.
|
||||||
|
public void onPlayerSearching(Consumer<String> listener) {
|
||||||
|
searchingListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
private void listenRtmtMessages() {
|
private void listenRtmtMessages() {
|
||||||
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
||||||
|
searchingListeners.forEach(listener -> listener.accept(userId));
|
||||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||||
if (activeGame != null) {
|
if (activeGame != null) {
|
||||||
rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id));
|
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);
|
LOG.debugf("(GameManager) Unknown bot level: %s", message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
searchingListeners.forEach(listener -> listener.accept(userId));
|
||||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||||
if (activeGame != null) {
|
if (activeGame != null) {
|
||||||
rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id));
|
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;
|
if (gameStore.get(game.id) != game) return;
|
||||||
deleteGame(game);
|
deleteGame(game);
|
||||||
game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong());
|
game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong());
|
||||||
GameEndMessage endMessage = new GameEndMessage(game.id, GameSerialization.getResult(game, ending),
|
GameResult result = GameSerialization.getResult(game, ending);
|
||||||
ending.endReason(), game.clock, ending.winnerId(), ending.leftUserId());
|
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) {
|
if (ending.leftUserId() != null) {
|
||||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId());
|
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId());
|
||||||
}
|
}
|
||||||
@ -205,6 +230,7 @@ public class GameManager {
|
|||||||
inStore(() -> liveGameStore.removeGame(game.id));
|
inStore(() -> liveGameStore.removeGame(game.id));
|
||||||
recorder.save(record);
|
recorder.save(record);
|
||||||
notifyLiveGamesChanged();
|
notifyLiveGamesChanged();
|
||||||
|
gameEndedListeners.forEach(listener -> listener.accept(game, endMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The player whose turn it is ran out of time: the opponent wins.
|
// 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) {
|
public ClientGame toClientGame(BackendGame game) {
|
||||||
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
|
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) {
|
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.
|
// `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) {
|
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 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();
|
long now = clock.getAsLong();
|
||||||
PlayerSummary player1 = getPlayerInfo(userId1);
|
PlayerSummary player1 = getPlayerInfo(userId1);
|
||||||
PlayerSummary player2 = getPlayerInfo(userId2);
|
PlayerSummary player2 = getPlayerInfo(userId2);
|
||||||
@ -331,7 +364,7 @@ public class GameManager {
|
|||||||
players.put(userId1, player1);
|
players.put(userId1, player1);
|
||||||
players.put(userId2, player2);
|
players.put(userId2, player2);
|
||||||
BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0,
|
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.set(game.id, game);
|
||||||
gameStore.setGameByUser(userId1, game);
|
gameStore.setGameByUser(userId1, game);
|
||||||
gameStore.setGameByUser(userId2, game);
|
gameStore.setGameByUser(userId2, game);
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import app.playmancala.model.GameUsersConnectionInfo;
|
|||||||
import app.playmancala.model.Json;
|
import app.playmancala.model.Json;
|
||||||
import app.playmancala.model.MoveRecord;
|
import app.playmancala.model.MoveRecord;
|
||||||
import app.playmancala.model.PlayerSummary;
|
import app.playmancala.model.PlayerSummary;
|
||||||
|
import app.playmancala.model.Series;
|
||||||
import app.playmancala.model.UserConnectionInfo;
|
import app.playmancala.model.UserConnectionInfo;
|
||||||
|
|
||||||
// Live game snapshots (kept in Valkey) and saved records. Snapshots have the Node backend's format, so either backend
|
// 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,
|
GameClock clock,
|
||||||
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs,
|
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs,
|
||||||
// Games against the bot only.
|
// 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) {
|
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()),
|
mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||||
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
|
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
|
||||||
List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock,
|
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.
|
// 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,
|
return new BackendGame(mancalaGame,
|
||||||
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
|
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
|
||||||
orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(),
|
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
|
// 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()),
|
publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||||
getResult(game, ending), ending.endReason(),
|
getResult(game, ending), ending.endReason(),
|
||||||
ending.leftUserId() == null ? null : PublicIds.publicUserId(ending.leftUserId()), game.startedAt, endedAt,
|
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.
|
// The player with a public id, for anything saved or listed.
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ public final class Migrations {
|
|||||||
private static final long MIGRATION_LOCK_ID = 727274;
|
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.
|
// 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() {
|
private Migrations() {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,9 +59,9 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
|||||||
int inserted;
|
int inserted;
|
||||||
try (PreparedStatement statement = connection.prepareStatement("""
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
|
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),
|
VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0),
|
||||||
?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb)
|
?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb, ?::uuid)
|
||||||
ON CONFLICT (id) DO NOTHING""")) {
|
ON CONFLICT (id) DO NOTHING""")) {
|
||||||
statement.setString(1, record.id());
|
statement.setString(1, record.id());
|
||||||
statement.setString(2, record.player1().userId());
|
statement.setString(2, record.player1().userId());
|
||||||
@ -79,6 +79,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
|||||||
if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER);
|
if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER);
|
||||||
else statement.setInt(14, Math.toIntExact(record.clockInitialMs()));
|
else statement.setInt(14, Math.toIntExact(record.clockInitialMs()));
|
||||||
statement.setString(15, record.bot() == null ? null : Json.write(record.bot()));
|
statement.setString(15, record.bot() == null ? null : Json.write(record.bot()));
|
||||||
|
statement.setString(16, record.seriesId());
|
||||||
inserted = statement.executeUpdate();
|
inserted = statement.executeUpdate();
|
||||||
}
|
}
|
||||||
if (inserted == 0) return null;
|
if (inserted == 0) return null;
|
||||||
@ -105,7 +106,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
|||||||
if (!UUID_PATTERN.matcher(id).matches()) return null;
|
if (!UUID_PATTERN.matcher(id).matches()) return null;
|
||||||
return query(connection -> {
|
return query(connection -> {
|
||||||
try (PreparedStatement statement = connection.prepareStatement(
|
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);
|
statement.setString(1, id);
|
||||||
try (ResultSet row = statement.executeQuery()) {
|
try (ResultSet row = statement.executeQuery()) {
|
||||||
if (!row.next()) return null;
|
if (!row.next()) return null;
|
||||||
@ -114,7 +115,8 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
|||||||
summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(),
|
summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(),
|
||||||
summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(),
|
summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(),
|
||||||
Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"),
|
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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -32,6 +32,14 @@ public final class Channels {
|
|||||||
public static final String ON_LIVE_GAMES = "on_live_games";
|
public static final String ON_LIVE_GAMES = "on_live_games";
|
||||||
// server -> client: { gameId }, when new_game is sent while the user has a game in progress.
|
// 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";
|
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() {
|
private Channels() {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,12 @@ package app.playmancala.model;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
import app.playmancala.engine.MancalaGame;
|
import app.playmancala.engine.MancalaGame;
|
||||||
|
|
||||||
// A live game as players and spectators receive it; leaves out spectator ids and other server-side state.
|
// 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(
|
public record ClientGame(
|
||||||
String id,
|
String id,
|
||||||
MancalaGame mancalaGame,
|
MancalaGame mancalaGame,
|
||||||
@ -14,5 +16,7 @@ public record ClientGame(
|
|||||||
List<Long> moveTimes,
|
List<Long> moveTimes,
|
||||||
int spectatorCount,
|
int spectatorCount,
|
||||||
GameClock clock,
|
GameClock clock,
|
||||||
long serverTime) {
|
long serverTime,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) Series series,
|
||||||
|
Seat firstSeat) {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,26 @@
|
|||||||
package app.playmancala.model;
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
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
|
// 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.
|
// sent as null) when the game ended without a winner. `series` includes the game that ended; `rematch` is "available"
|
||||||
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" })
|
// 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(
|
public record GameEndMessage(
|
||||||
String gameId,
|
String gameId,
|
||||||
GameResult result,
|
GameResult result,
|
||||||
EndReason endReason,
|
EndReason endReason,
|
||||||
GameClock clock,
|
GameClock clock,
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL) String winnerId,
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,9 +8,10 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
|||||||
import app.playmancala.bot.BotSettings;
|
import app.playmancala.bot.BotSettings;
|
||||||
|
|
||||||
// A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it
|
// 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",
|
@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(
|
public record GameRecord(
|
||||||
String id,
|
String id,
|
||||||
PlayerSummary player1,
|
PlayerSummary player1,
|
||||||
@ -26,14 +27,15 @@ public record GameRecord(
|
|||||||
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs,
|
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs,
|
||||||
List<MoveRecord> moves,
|
List<MoveRecord> moves,
|
||||||
int totalSpectators,
|
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.
|
// A game between people.
|
||||||
public GameRecord(String id, PlayerSummary player1, PlayerSummary player2, List<Integer> board, GameResult result,
|
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,
|
EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators,
|
||||||
Long clockInitialMs, List<MoveRecord> moves, int totalSpectators) {
|
Long clockInitialMs, List<MoveRecord> moves, int totalSpectators) {
|
||||||
this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators,
|
this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators,
|
||||||
clockInitialMs, moves, totalSpectators, null);
|
clockInitialMs, moves, totalSpectators, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FinishedGameSummary toSummary() {
|
public FinishedGameSummary toSummary() {
|
||||||
|
|||||||
24
apps/backend/src/main/java/app/playmancala/model/Series.java
Normal file
24
apps/backend/src/main/java/app/playmancala/model/Series.java
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
// The series a live game belongs to: its id and the games played before this one, oldest first. A game that is not a
|
||||||
|
// rematch starts a series of its own (docs/specs/rematch).
|
||||||
|
public record Series(String id, List<SeriesGame> games) {
|
||||||
|
public Series {
|
||||||
|
games = List.copyOf(games);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Series start() {
|
||||||
|
return new Series(UUID.randomUUID().toString(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same series with `finished` played.
|
||||||
|
public Series next(SeriesGame finished) {
|
||||||
|
List<SeriesGame> played = new ArrayList<>(games);
|
||||||
|
played.add(finished);
|
||||||
|
return new Series(id, played);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
// One finished game of a series, as the series strip shows it (docs/specs/rematch). Seats rather than user ids: the
|
||||||
|
// players keep their seats through a series, and no id can leak. `result` is null when the game ended without a winner.
|
||||||
|
public record SeriesGame(
|
||||||
|
String gameId,
|
||||||
|
Seat firstSeat,
|
||||||
|
GameResult result,
|
||||||
|
EndReason endReason,
|
||||||
|
int player1Store,
|
||||||
|
int player2Store) {
|
||||||
|
}
|
||||||
@ -0,0 +1,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);
|
||||||
@ -32,6 +32,9 @@ import app.playmancala.model.GameRecord;
|
|||||||
import app.playmancala.model.GameResult;
|
import app.playmancala.model.GameResult;
|
||||||
import app.playmancala.model.Json;
|
import app.playmancala.model.Json;
|
||||||
import app.playmancala.model.MoveRecord;
|
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.
|
// Ported from the Node backend's tests/game/GameManager.test.ts.
|
||||||
class GameManagerTest {
|
class GameManagerTest {
|
||||||
@ -199,11 +202,11 @@ class GameManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sendsTheGameAsTheNodeBackendDid() {
|
void sendsTheGameAsTheNodeBackendDidPlusTheSeries() {
|
||||||
BackendGame game = startGame();
|
BackendGame game = startGame();
|
||||||
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
|
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
|
||||||
assertEquals(List.of("id", "mancalaGame", "gameUsersConnectionInfo", "startedAt", "moveTimes",
|
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",
|
assertEquals(List.of("id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history",
|
||||||
"currentHistoryItem"), fieldNames(start.get("mancalaGame")));
|
"currentHistoryItem"), fieldNames(start.get("mancalaGame")));
|
||||||
assertEquals("initial", start.at("/mancalaGame/state").asText());
|
assertEquals("initial", start.at("/mancalaGame/state").asText());
|
||||||
@ -392,8 +395,9 @@ class GameManagerTest {
|
|||||||
assertEquals(1, ends.size());
|
assertEquals(1, ends.size());
|
||||||
ObjectNode end = ends.getFirst().deepCopy();
|
ObjectNode end = ends.getFirst().deepCopy();
|
||||||
assertTrue(end.remove("clock").isObject());
|
assertTrue(end.remove("clock").isObject());
|
||||||
|
assertTrue(end.remove("series").isObject());
|
||||||
assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed",
|
assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed",
|
||||||
"winnerId", ALICE.id())), end);
|
"winnerId", ALICE.id(), "rematch", "available")), end);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -413,6 +417,7 @@ class GameManagerTest {
|
|||||||
assertEquals(1, ends.size());
|
assertEquals(1, ends.size());
|
||||||
ObjectNode end = ends.getFirst().deepCopy();
|
ObjectNode end = ends.getFirst().deepCopy();
|
||||||
assertTrue(end.remove("clock").isObject());
|
assertTrue(end.remove("clock").isObject());
|
||||||
|
assertTrue(end.remove("series").isObject());
|
||||||
assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left",
|
assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left",
|
||||||
"winnerId", BOB.id(), "leftUserId", ALICE.id())), end);
|
"winnerId", BOB.id(), "leftUserId", ALICE.id())), end);
|
||||||
}
|
}
|
||||||
@ -595,8 +600,108 @@ class GameManagerTest {
|
|||||||
JsonNode written = liveGameStore.games.get(restored.id);
|
JsonNode written = liveGameStore.games.get(restored.id);
|
||||||
assertEquals(Json.tree(restored.mancalaGame.getHistory()), written.get("history"));
|
assertEquals(Json.tree(restored.mancalaGame.getHistory()), written.get("history"));
|
||||||
assertEquals(List.of("version", "id", "player1Id", "player2Id", "turnPlayerId", "state", "board", "history",
|
assertEquals(List.of("version", "id", "player1Id", "player2Id", "turnPlayerId", "state", "board", "history",
|
||||||
"players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs"),
|
"players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs",
|
||||||
fieldNames(written));
|
"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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -90,7 +90,7 @@ class PostgresGameHistoryRepositoryTest {
|
|||||||
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) {
|
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) {
|
||||||
while (rows.next()) versions.add(rows.getString(1));
|
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
|
@Test
|
||||||
@ -122,7 +122,7 @@ class PostgresGameHistoryRepositoryTest {
|
|||||||
GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false));
|
GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false));
|
||||||
GameRecord botGame = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(),
|
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.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);
|
repository.save(botGame);
|
||||||
assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id())));
|
assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id())));
|
||||||
assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId());
|
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"));
|
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
|
@Test
|
||||||
void keepsLeftUserIdForGamesAPlayerLeft() {
|
void keepsLeftUserIdForGamesAPlayerLeft() {
|
||||||
GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null);
|
GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import FloatingPanel from './components/FloatingPanel';
|
import FloatingPanel from './components/FloatingPanel';
|
||||||
import GamePage from './routes/GamePage';
|
import GameRoute from './routes/GamePage';
|
||||||
import Home from './routes/Home';
|
import Home from './routes/Home';
|
||||||
|
|
||||||
import { initContext } from './context/context';
|
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 index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
|
||||||
<Route path="/" >
|
<Route path="/" >
|
||||||
<Route path="game" >
|
<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>
|
||||||
<Route path="loby" element={<LobyPage context={context} theme={theme} userKey={userKey} />}>
|
<Route path="loby" element={<LobyPage context={context} theme={theme} userKey={userKey} />}>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@ -1,32 +1,57 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { CloseIcon } from "../Icons";
|
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<{
|
const GameOverCard: FunctionComponent<{
|
||||||
title: string,
|
title: string,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
score: string,
|
// The bottom player's first.
|
||||||
// "New Game" unless given, e.g. "Play again" after a game against the bot.
|
scores: [FinalScore, FinalScore],
|
||||||
newGameLabel?: string,
|
seriesScore?: string,
|
||||||
onNewGame: () => void,
|
status?: string,
|
||||||
|
// The status is something being waited for.
|
||||||
|
waiting?: boolean,
|
||||||
|
children: React.ReactNode,
|
||||||
onClose: () => void,
|
onClose: () => void,
|
||||||
}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => {
|
}> = ({ title, reason, scores, seriesScore, status, waiting, children, onClose }) => {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="over">
|
<div className="over">
|
||||||
<div className="ocard" role="dialog" aria-label={title}>
|
<div className="ocard" role="dialog" aria-label={title}>
|
||||||
<button className="btn icon ghost close" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
|
<button className="btn icon ghost close" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
|
||||||
<div className="otitle">{title}</div>
|
<div className="otitle">{title}</div>
|
||||||
{reason && <div className="oreason">{reason}</div>}
|
{reason && <div className="oreason">{reason}</div>}
|
||||||
<div className="oscore">{score}</div>
|
<div className="oscore" aria-label={`${t("Score")}: ${scores.map((s) => `${s.name} ${s.stones}`).join(", ")}`}>
|
||||||
<div className="row">
|
{scoreColumn(scores[0])}
|
||||||
<button className="btn pri" onClick={onNewGame}>{newGameLabel ?? t("NewGame")}</button>
|
<span className="odash" aria-hidden="true">–</span>
|
||||||
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
|
{scoreColumn(scores[1])}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
78
apps/frontend/src/components/game/SeriesStrip.tsx
Normal file
78
apps/frontend/src/components/game/SeriesStrip.tsx
Normal 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;
|
||||||
@ -10,6 +10,7 @@ import PageContainer from '../components/PageContainer';
|
|||||||
import AppHeader from '../components/headerbar/AppHeader';
|
import AppHeader from '../components/headerbar/AppHeader';
|
||||||
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
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 { 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 { Context } from '../context/context';
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
import { Theme, getBotLevel, getPlayerDisplayName } 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 useGameClock from '../hooks/useGameClock';
|
||||||
import PlayerCard from '../components/game/PlayerCard';
|
import PlayerCard from '../components/game/PlayerCard';
|
||||||
import GameOverCard from '../components/game/GameOverCard';
|
import GameOverCard from '../components/game/GameOverCard';
|
||||||
|
import SeriesStrip from '../components/game/SeriesStrip';
|
||||||
import RulesPanel from '../components/rules/RulesPanel';
|
import RulesPanel from '../components/rules/RulesPanel';
|
||||||
import '../styles/game.css';
|
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<{
|
const GamePage: FunctionComponent<{
|
||||||
context: Context,
|
context: Context,
|
||||||
userKey?: string,
|
userKey?: string,
|
||||||
@ -70,6 +94,8 @@ const GamePage: FunctionComponent<{
|
|||||||
// Set when the player closes the game over card to look at the board.
|
// Set when the player closes the game over card to look at the board.
|
||||||
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [rematch, setRematch] = useState<RematchState>({ status: "none" });
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -123,6 +149,7 @@ const GamePage: FunctionComponent<{
|
|||||||
const onGameEnd = (message: GameEndMessage) => {
|
const onGameEnd = (message: GameEndMessage) => {
|
||||||
setGameEnd(message);
|
setGameEnd(message);
|
||||||
setHasOngoingAction(false);
|
setHasOngoingAction(false);
|
||||||
|
setRematch({ status: message.rematch === "available" ? "available" : "over" });
|
||||||
};
|
};
|
||||||
const onUserConnectionChange = (message: any) => {
|
const onUserConnectionChange = (message: any) => {
|
||||||
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
||||||
@ -131,12 +158,35 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => {
|
const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => {
|
||||||
const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message);
|
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_update, _onGameUpdate);
|
||||||
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
|
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
|
||||||
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
||||||
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
||||||
context.rtmt.addMessageListener(channel_on_game_end, onGameEnd);
|
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 () => {
|
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_end, onGameEnd);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
|
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
|
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.
|
// In a game against the bot, the game over card starts another one at the same level.
|
||||||
const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId());
|
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 = () => {
|
const onPlayAgainClick = () => {
|
||||||
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
||||||
navigate(`/loby?bot=${opponentBotLevel}`)
|
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) => {
|
const onPitSelect = (index: number, pit: Pit) => {
|
||||||
if (!game || isSpectator || !userKey) {
|
if (!game || isSpectator || !userKey) {
|
||||||
return;
|
return;
|
||||||
@ -373,6 +438,58 @@ const GamePage: FunctionComponent<{
|
|||||||
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
|
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 = () => {
|
const getTurnText = () => {
|
||||||
if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded");
|
if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded");
|
||||||
if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`;
|
if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`;
|
||||||
@ -397,7 +514,17 @@ const GamePage: FunctionComponent<{
|
|||||||
alignRight={alignRight} />
|
alignRight={alignRight} />
|
||||||
);
|
);
|
||||||
return (
|
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-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div>
|
||||||
<div className="a-turn">
|
<div className="a-turn">
|
||||||
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
|
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
|
||||||
@ -434,10 +561,16 @@ const GamePage: FunctionComponent<{
|
|||||||
<GameOverCard
|
<GameOverCard
|
||||||
title={resultTitle ?? t("GameEnded")}
|
title={resultTitle ?? t("GameEnded")}
|
||||||
reason={resultReason}
|
reason={resultReason}
|
||||||
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
|
scores={[
|
||||||
newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined}
|
{ name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true },
|
||||||
onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick}
|
{ name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false },
|
||||||
onClose={() => setGameOverClosed(true)} />
|
]}
|
||||||
|
seriesScore={getSeriesScoreText()}
|
||||||
|
status={getRematchStatusText()}
|
||||||
|
waiting={rematch.status === "waiting"}
|
||||||
|
onClose={() => setGameOverClosed(true)}>
|
||||||
|
{renderGameOverButtons()}
|
||||||
|
</GameOverCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="a-moves">
|
<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;
|
||||||
|
|||||||
@ -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-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-me { grid-area: me; justify-self: end; min-width: 0; }
|
||||||
.a-board { grid-area: board; position: relative; 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; }
|
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
/* Players */
|
/* Players */
|
||||||
@ -156,8 +158,43 @@
|
|||||||
.ocard .close { position: absolute; top: 8px; right: 8px; }
|
.ocard .close { position: absolute; top: 8px; right: 8px; }
|
||||||
.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; }
|
.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; }
|
||||||
.oreason { font-size: 15px; font-weight: 700; color: var(--muted); }
|
.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; }
|
/* The score: each player's name above their stones, the bottom player's on the left. */
|
||||||
.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
.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 */
|
/* Tags of a move: extra turn, capture, stones to the store */
|
||||||
|
|
||||||
@ -168,6 +205,9 @@
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@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 { 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; }
|
.a-me { justify-self: start; }
|
||||||
.pl.r { flex-direction: row; text-align: left; }
|
.pl.r { flex-direction: row; text-align: left; }
|
||||||
.pl { gap: 8px; }
|
.pl { gap: 8px; }
|
||||||
|
|||||||
143
docs/specs/rematch/design.md
Normal file
143
docs/specs/rematch/design.md
Normal 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.
|
||||||
77
docs/specs/rematch/requirements.md
Normal file
77
docs/specs/rematch/requirements.md
Normal 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).
|
||||||
18
docs/specs/rematch/tasks.md
Normal file
18
docs/specs/rematch/tasks.md
Normal 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.
|
||||||
@ -38,5 +38,5 @@ at once. Without the parameter the endpoint behaves as before, which the mobile
|
|||||||
|
|
||||||
## Not done
|
## 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.
|
- The mobile app.
|
||||||
|
|||||||
@ -176,4 +176,22 @@ export const en = {
|
|||||||
LessonDone: "Done",
|
LessonDone: "Done",
|
||||||
AllLessons: "All lessons",
|
AllLessons: "All lessons",
|
||||||
LessonNumber: "Lesson {{number}} of {{count}}",
|
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",
|
||||||
}
|
}
|
||||||
@ -176,4 +176,22 @@ export const tr = {
|
|||||||
LessonDone: "Tamamlandı",
|
LessonDone: "Tamamlandı",
|
||||||
AllLessons: "Tüm dersler",
|
AllLessons: "Tüm dersler",
|
||||||
LessonNumber: "Ders {{number}} / {{count}}",
|
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ı",
|
||||||
};
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { MancalaGame } from "mancala.js";
|
import { MancalaGame } from "mancala.js";
|
||||||
import { GameClock } from "./GameClock";
|
import { GameClock, Seat } from "./GameClock";
|
||||||
|
import { Series } from "./Series";
|
||||||
import { UserConnectionInfo } from "./UserConnectionInfo";
|
import { UserConnectionInfo } from "./UserConnectionInfo";
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
@ -14,6 +15,10 @@ export interface Game {
|
|||||||
clock?: GameClock;
|
clock?: GameClock;
|
||||||
// Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock.
|
// Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock.
|
||||||
serverTime?: number;
|
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 {
|
export interface GameUsersConnectionInfo {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { GameStep } from "mancala.js";
|
import { GameStep } from "mancala.js";
|
||||||
import { GameClock } from "./GameClock";
|
import { GameClock } from "./GameClock";
|
||||||
|
import { Series } from "./Series";
|
||||||
|
|
||||||
export interface PlayerSummary {
|
export interface PlayerSummary {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -57,6 +58,8 @@ export interface GameRecord extends FinishedGameSummary {
|
|||||||
moves: MoveRecord[];
|
moves: MoveRecord[];
|
||||||
// Distinct users who watched the game.
|
// Distinct users who watched the game.
|
||||||
totalSpectators: number;
|
totalSpectators: number;
|
||||||
|
// Undefined for games saved before rematches.
|
||||||
|
seriesId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /game/:id for a game that has ended.
|
// GET /game/:id for a game that has ended.
|
||||||
@ -74,4 +77,8 @@ export interface GameEndMessage {
|
|||||||
leftUserId?: string;
|
leftUserId?: string;
|
||||||
// The stopped clocks.
|
// The stopped clocks.
|
||||||
clock?: GameClock;
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
53
packages/core/src/models/Series.ts
Normal file
53
packages/core/src/models/Series.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -7,3 +7,4 @@ export * from './AuthUser'
|
|||||||
export * from './GameHistory'
|
export * from './GameHistory'
|
||||||
export * from './GameClock'
|
export * from './GameClock'
|
||||||
export * from './Bot'
|
export * from './Bot'
|
||||||
|
export * from './Series'
|
||||||
|
|||||||
@ -29,3 +29,12 @@ export const channel_unsubscribe_live_games = "unsubscribe_live_games"
|
|||||||
export const channel_on_live_games = "on_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.
|
// 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"
|
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"
|
||||||
|
|||||||
30
packages/core/tests/models/Series.test.ts
Normal file
30
packages/core/tests/models/Series.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user