Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -6,9 +6,12 @@ import java.util.Map;
|
||||
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.engine.MoveHistoryItem;
|
||||
import app.playmancala.model.GameClock;
|
||||
import app.playmancala.model.GameUsersConnectionInfo;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
import app.playmancala.model.Seat;
|
||||
import app.playmancala.model.Series;
|
||||
|
||||
// A live game and the server-side state around it. Only touched on the game loop.
|
||||
public class BackendGame {
|
||||
@ -30,10 +33,12 @@ public class BackendGame {
|
||||
public final List<Long> moveRemainingMs;
|
||||
// How the bot plays in a game against the bot; null in games between people.
|
||||
public final BotSettings bot;
|
||||
// The games the same players played before this one (docs/specs/rematch).
|
||||
public final Series series;
|
||||
|
||||
public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo,
|
||||
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
|
||||
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs, BotSettings bot) {
|
||||
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs, BotSettings bot, Series series) {
|
||||
this.id = mancalaGame.getId();
|
||||
this.mancalaGame = mancalaGame;
|
||||
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
|
||||
@ -45,6 +50,14 @@ public class BackendGame {
|
||||
this.clock = clock;
|
||||
this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
|
||||
this.bot = bot;
|
||||
this.series = series;
|
||||
}
|
||||
|
||||
// The seat that moved first: the first move's player, or the player to move before any move.
|
||||
public Seat firstSeat() {
|
||||
List<MoveHistoryItem> history = mancalaGame.getHistory();
|
||||
String firstPlayerId = history.isEmpty() ? mancalaGame.getTurnPlayerId() : history.getFirst().getPlayerId();
|
||||
return mancalaGame.getPlayer1Id().equals(firstPlayerId) ? Seat.PLAYER1 : Seat.PLAYER2;
|
||||
}
|
||||
|
||||
public boolean isBotTurn() {
|
||||
|
||||
@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.bot.BotLevel;
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.engine.Board;
|
||||
import app.playmancala.engine.CommonMancalaGame;
|
||||
import app.playmancala.engine.GameState;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
@ -29,9 +30,12 @@ import app.playmancala.model.ClientGame;
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.GameEndMessage;
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.GameResult;
|
||||
import app.playmancala.model.GameUsersConnectionInfo;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
import app.playmancala.model.Seat;
|
||||
import app.playmancala.model.Series;
|
||||
import app.playmancala.model.SeriesGame;
|
||||
import app.playmancala.model.UserConnectionInfo;
|
||||
import app.playmancala.rtmt.Rtmt;
|
||||
|
||||
@ -193,8 +197,13 @@ public class GameManager {
|
||||
if (gameStore.get(game.id) != game) return;
|
||||
deleteGame(game);
|
||||
game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong());
|
||||
GameEndMessage endMessage = new GameEndMessage(game.id, GameSerialization.getResult(game, ending),
|
||||
ending.endReason(), game.clock, ending.winnerId(), ending.leftUserId());
|
||||
GameResult result = GameSerialization.getResult(game, ending);
|
||||
Board board = game.mancalaGame.getBoard();
|
||||
int[] stones = board.getStoneArray();
|
||||
Series series = game.series.next(new SeriesGame(game.id, game.firstSeat(), result, ending.endReason(),
|
||||
stones[board.player1BankIndex()], stones[board.player2BankIndex()]));
|
||||
GameEndMessage endMessage = new GameEndMessage(game.id, result, ending.endReason(), game.clock,
|
||||
ending.winnerId(), ending.leftUserId(), series);
|
||||
if (ending.leftUserId() != null) {
|
||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId());
|
||||
}
|
||||
@ -313,7 +322,8 @@ public class GameManager {
|
||||
|
||||
public ClientGame toClientGame(BackendGame game) {
|
||||
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
|
||||
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong());
|
||||
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong(), game.series,
|
||||
game.firstSeat());
|
||||
}
|
||||
|
||||
public BackendGame createMancalaGame(String userId1, String userId2) {
|
||||
@ -322,8 +332,14 @@ public class GameManager {
|
||||
|
||||
// `bot` is set for a game against the bot, which plays as one of the two users.
|
||||
public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) {
|
||||
return createMancalaGame(userId1, userId2, bot, Series.start(), null);
|
||||
}
|
||||
|
||||
// A game in `series`; `firstSeat` moves first, or a player drawn at random when it is null.
|
||||
public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot, Series series, Seat firstSeat) {
|
||||
MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2);
|
||||
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2);
|
||||
boolean player1First = firstSeat != null ? firstSeat == Seat.PLAYER1 : ThreadLocalRandom.current().nextBoolean();
|
||||
mancalaGame.setTurnPlayerId(player1First ? userId1 : userId2);
|
||||
long now = clock.getAsLong();
|
||||
PlayerSummary player1 = getPlayerInfo(userId1);
|
||||
PlayerSummary player2 = getPlayerInfo(userId2);
|
||||
@ -331,7 +347,7 @@ public class GameManager {
|
||||
players.put(userId1, player1);
|
||||
players.put(userId2, player2);
|
||||
BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0,
|
||||
players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot);
|
||||
players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot, series);
|
||||
gameStore.set(game.id, game);
|
||||
gameStore.setGameByUser(userId1, game);
|
||||
gameStore.setGameByUser(userId2, game);
|
||||
|
||||
@ -27,6 +27,7 @@ import app.playmancala.model.GameUsersConnectionInfo;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.MoveRecord;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
import app.playmancala.model.Series;
|
||||
import app.playmancala.model.UserConnectionInfo;
|
||||
|
||||
// Live game snapshots (kept in Valkey) and saved records. Snapshots have the Node backend's format, so either backend
|
||||
@ -58,7 +59,9 @@ public final class GameSerialization {
|
||||
GameClock clock,
|
||||
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs,
|
||||
// Games against the bot only.
|
||||
BotSettings bot) {
|
||||
BotSettings bot,
|
||||
// Missing in snapshots from before rematches.
|
||||
Series series) {
|
||||
}
|
||||
|
||||
public record GameEnding(EndReason endReason, String winnerId, String leftUserId) {
|
||||
@ -74,7 +77,7 @@ public final class GameSerialization {
|
||||
mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
|
||||
List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock,
|
||||
Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot);
|
||||
Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot, game.series);
|
||||
}
|
||||
|
||||
// The snapshot in the JSON, or null if it is not one this backend can restore.
|
||||
@ -117,7 +120,8 @@ public final class GameSerialization {
|
||||
return new BackendGame(mancalaGame,
|
||||
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
|
||||
orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(),
|
||||
orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot));
|
||||
orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot),
|
||||
snapshot.series() != null ? snapshot.series() : Series.start());
|
||||
}
|
||||
|
||||
// The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at
|
||||
|
||||
@ -2,10 +2,12 @@ package app.playmancala.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
|
||||
// A live game as players and spectators receive it; leaves out spectator ids and other server-side state.
|
||||
// It refers to the live MancalaGame, so serialise it on the game loop.
|
||||
// It refers to the live MancalaGame, so serialise it on the game loop. `series` holds the games before this one.
|
||||
public record ClientGame(
|
||||
String id,
|
||||
MancalaGame mancalaGame,
|
||||
@ -14,5 +16,7 @@ public record ClientGame(
|
||||
List<Long> moveTimes,
|
||||
int spectatorCount,
|
||||
GameClock clock,
|
||||
long serverTime) {
|
||||
long serverTime,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Series series,
|
||||
Seat firstSeat) {
|
||||
}
|
||||
|
||||
@ -4,13 +4,14 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// on_game_end, sent to the players and spectators of a live game when it ends for any reason. `result` is null (and
|
||||
// sent as null) when the game ended without a winner.
|
||||
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" })
|
||||
// sent as null) when the game ended without a winner. `series` includes the game that ended.
|
||||
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId", "series" })
|
||||
public record GameEndMessage(
|
||||
String gameId,
|
||||
GameResult result,
|
||||
EndReason endReason,
|
||||
GameClock clock,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String winnerId,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId) {
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Series series) {
|
||||
}
|
||||
|
||||
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) {
|
||||
}
|
||||
@ -32,6 +32,9 @@ import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.GameResult;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.MoveRecord;
|
||||
import app.playmancala.model.Seat;
|
||||
import app.playmancala.model.Series;
|
||||
import app.playmancala.model.SeriesGame;
|
||||
|
||||
// Ported from the Node backend's tests/game/GameManager.test.ts.
|
||||
class GameManagerTest {
|
||||
@ -199,11 +202,11 @@ class GameManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsTheGameAsTheNodeBackendDid() {
|
||||
void sendsTheGameAsTheNodeBackendDidPlusTheSeries() {
|
||||
BackendGame game = startGame();
|
||||
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
|
||||
assertEquals(List.of("id", "mancalaGame", "gameUsersConnectionInfo", "startedAt", "moveTimes",
|
||||
"spectatorCount", "clock", "serverTime"), fieldNames(start));
|
||||
"spectatorCount", "clock", "serverTime", "series", "firstSeat"), fieldNames(start));
|
||||
assertEquals(List.of("id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history",
|
||||
"currentHistoryItem"), fieldNames(start.get("mancalaGame")));
|
||||
assertEquals("initial", start.at("/mancalaGame/state").asText());
|
||||
@ -392,6 +395,7 @@ class GameManagerTest {
|
||||
assertEquals(1, ends.size());
|
||||
ObjectNode end = ends.getFirst().deepCopy();
|
||||
assertTrue(end.remove("clock").isObject());
|
||||
assertTrue(end.remove("series").isObject());
|
||||
assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed",
|
||||
"winnerId", ALICE.id())), end);
|
||||
}
|
||||
@ -413,6 +417,7 @@ class GameManagerTest {
|
||||
assertEquals(1, ends.size());
|
||||
ObjectNode end = ends.getFirst().deepCopy();
|
||||
assertTrue(end.remove("clock").isObject());
|
||||
assertTrue(end.remove("series").isObject());
|
||||
assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left",
|
||||
"winnerId", BOB.id(), "leftUserId", ALICE.id())), end);
|
||||
}
|
||||
@ -595,8 +600,101 @@ class GameManagerTest {
|
||||
JsonNode written = liveGameStore.games.get(restored.id);
|
||||
assertEquals(Json.tree(restored.mancalaGame.getHistory()), written.get("history"));
|
||||
assertEquals(List.of("version", "id", "player1Id", "player2Id", "turnPlayerId", "state", "board", "history",
|
||||
"players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs"),
|
||||
fieldNames(written));
|
||||
"players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs",
|
||||
"series"), fieldNames(written));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class InASeries {
|
||||
@Test
|
||||
void everyNewGameStartsASeriesOfItsOwn() {
|
||||
BackendGame first = startGame(ALICE, BOB);
|
||||
BackendGame second = startGame(CAROL, DAVE);
|
||||
assertTrue(first.series.games().isEmpty());
|
||||
assertNotNull(first.series.id());
|
||||
assertFalse(first.series.id().equals(second.series.id()));
|
||||
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
|
||||
assertEquals(Json.tree(Map.of("id", first.series.id(), "games", List.of())), start.get("series"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFirstSeatIsTheSeatThatMovedFirst() {
|
||||
BackendGame game = startGame();
|
||||
Seat firstSeat = isPlayer1(game, game.mancalaGame.getTurnPlayerId()) ? Seat.PLAYER1 : Seat.PLAYER2;
|
||||
assertEquals(firstSeat, game.firstSeat());
|
||||
assertEquals(Json.tree(firstSeat), rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_START).get("firstSeat"));
|
||||
playAnyMove(game);
|
||||
playAnyMove(game);
|
||||
assertEquals(firstSeat, game.firstSeat());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGameCanBeCreatedInASeriesWithTheSeatToMoveFirst() {
|
||||
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, GameResult.PLAYER1,
|
||||
EndReason.COMPLETED, 30, 18)));
|
||||
for (Seat seat : Seat.values()) {
|
||||
BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, seat);
|
||||
assertEquals(ALICE.id(), game.mancalaGame.getPlayer1Id());
|
||||
assertEquals(seat == Seat.PLAYER1 ? ALICE.id() : BOB.id(), game.mancalaGame.getTurnPlayerId());
|
||||
assertSame(series, game.series);
|
||||
assertEquals(Json.tree(series), Json.tree(gameManager.toClientGame(game)).get("series"));
|
||||
gameManager.deleteGame(game);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theEndMessageHasTheSeriesWithTheGameThatEnded() {
|
||||
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, GameResult.DRAW,
|
||||
EndReason.COMPLETED, 24, 24)));
|
||||
BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, Seat.PLAYER1);
|
||||
gameManager.startGame(game);
|
||||
playAnyMove(game);
|
||||
setUpLastMove(game, ALICE.id());
|
||||
rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 5));
|
||||
|
||||
JsonNode end = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END);
|
||||
assertEquals(Json.tree(series.next(new SeriesGame(game.id, Seat.PLAYER1, GameResult.PLAYER1,
|
||||
EndReason.COMPLETED, 2, 0))), end.get("series"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGameThatEndsWithoutAWinnerIsInTheSeriesToo() {
|
||||
BackendGame game = startGame();
|
||||
rtmt.receive(ALICE.id(), Channels.LEAVE_GAME);
|
||||
JsonNode games = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END).at("/series/games");
|
||||
assertEquals(1, games.size());
|
||||
assertEquals("left", games.get(0).get("endReason").asText());
|
||||
assertEquals(game.id, games.get(0).get("gameId").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
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.
|
||||
- [ ] 2. **Series in live games**: `Series`, `SeriesGame`, `BackendGame.series` and `firstSeat()`, the first seat in
|
||||
- [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.
|
||||
- [ ] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on
|
||||
|
||||
Loading…
Reference in New Issue
Block a user