Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -4,7 +4,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
|
|
||||||
- [x] 1. **Infrastructure**: `postgres` service with init script for the `keycloak` and `mancala` databases
|
- [x] 1. **Infrastructure**: `postgres` service with init script for the `keycloak` and `mancala` databases
|
||||||
(replacing `keycloak-db`), `valkey` service, backend `DATABASE_URL` / `VALKEY_URL`, `.env` variables, README.
|
(replacing `keycloak-db`), `valkey` service, backend `DATABASE_URL` / `VALKEY_URL`, `.env` variables, README.
|
||||||
- [ ] 2. **Core types and history helpers**: `PlayerSummary`, `MoveRecord`, summaries, `GameRecord`,
|
- [x] 2. **Core types and history helpers**: `PlayerSummary`, `MoveRecord`, summaries, `GameRecord`,
|
||||||
`TURN_TIMEOUT_MS`, new channels, `offlineSince`; `getInitialBoard`, `getBoardAfterMove`, `describeMove`,
|
`TURN_TIMEOUT_MS`, new channels, `offlineSince`; `getInitialBoard`, `getBoardAfterMove`, `describeMove`,
|
||||||
`createGameAtMove` with unit tests; localized strings.
|
`createGameAtMove` with unit tests; localized strings.
|
||||||
- [ ] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection
|
- [ ] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection
|
||||||
|
|||||||
87
packages/core/src/history/MoveHistory.ts
Normal file
87
packages/core/src/history/MoveHistory.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Bank,
|
||||||
|
Board,
|
||||||
|
CommonBoard,
|
||||||
|
GameStep,
|
||||||
|
GAME_STEP_BOARD_CLEARED,
|
||||||
|
GAME_STEP_DOUBLE_STONE_IN_PIT,
|
||||||
|
GAME_STEP_LAST_STONE_IN_BANK,
|
||||||
|
GAME_STEP_LAST_STONE_IN_EMPTY_PIT,
|
||||||
|
MancalaGame,
|
||||||
|
Pit,
|
||||||
|
} from "mancala.js";
|
||||||
|
import { Game } from "../models/Game";
|
||||||
|
|
||||||
|
// A played move: an entry of MancalaGame.history in a live game, or a MoveRecord of a saved game.
|
||||||
|
export interface PlayedMove {
|
||||||
|
playerId: string;
|
||||||
|
moveIndex: number;
|
||||||
|
boardSnapshot: number[];
|
||||||
|
gameSteps: GameStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveDescription {
|
||||||
|
// 1-based.
|
||||||
|
number: number;
|
||||||
|
playerId: string;
|
||||||
|
// 1-6, counted from the player's own side.
|
||||||
|
pit: number;
|
||||||
|
extraTurn: boolean;
|
||||||
|
capture: boolean;
|
||||||
|
// Stones the mover's store gained with this move.
|
||||||
|
storeGain: number;
|
||||||
|
gameOver: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceBoard = new CommonBoard();
|
||||||
|
|
||||||
|
export function getInitialBoard(): number[] {
|
||||||
|
return new CommonBoard().getStoneArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stones in every pit after the given move; move 0 is the start position.
|
||||||
|
export function getBoardAfterMove(moves: PlayedMove[], moveNumber: number): number[] {
|
||||||
|
const n = Math.min(Math.max(moveNumber, 0), moves.length);
|
||||||
|
return n === 0 ? getInitialBoard() : [...moves[n - 1].boardSnapshot];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeMove(moves: PlayedMove[], moveNumber: number): MoveDescription {
|
||||||
|
const move = moves[moveNumber - 1];
|
||||||
|
if (!move) throw new Error(`No move ${moveNumber}, the game has ${moves.length} moves`);
|
||||||
|
const before = getBoardAfterMove(moves, moveNumber - 1);
|
||||||
|
const isPlayer1Move = move.moveIndex <= referenceBoard.player1PitStopIndex();
|
||||||
|
const firstPitIndex = isPlayer1Move ? referenceBoard.player1PitStartIndex() : referenceBoard.player2PitStartIndex();
|
||||||
|
const storeIndex = isPlayer1Move ? referenceBoard.player1BankIndex() : referenceBoard.player2BankIndex();
|
||||||
|
const stepTypes = move.gameSteps.map((step) => step.type);
|
||||||
|
return {
|
||||||
|
number: moveNumber,
|
||||||
|
playerId: move.playerId,
|
||||||
|
pit: move.moveIndex - firstPitIndex + 1,
|
||||||
|
extraTurn: stepTypes.includes(GAME_STEP_LAST_STONE_IN_BANK),
|
||||||
|
capture: stepTypes.includes(GAME_STEP_LAST_STONE_IN_EMPTY_PIT) || stepTypes.includes(GAME_STEP_DOUBLE_STONE_IN_PIT),
|
||||||
|
storeGain: move.boardSnapshot[storeIndex] - before[storeIndex],
|
||||||
|
gameOver: stepTypes.includes(GAME_STEP_BOARD_CLEARED),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The game as it was after the given move, for showing or animating that position (e.g. with PitAnimator,
|
||||||
|
// whose last history entry is the move it animates). Only for display: the rebuilt game has no rules.
|
||||||
|
export function createGameAtMove(game: Game, moves: PlayedMove[], moveNumber: number): Game {
|
||||||
|
const n = Math.min(Math.max(moveNumber, 0), moves.length);
|
||||||
|
const stones = getBoardAfterMove(moves, n);
|
||||||
|
const pits = stones.map((stoneCount, index) =>
|
||||||
|
referenceBoard.checkPitTypeIsBankByIndex(index) ? new Bank(index, stoneCount) : new Pit(index, stoneCount));
|
||||||
|
const { id, player1Id, player2Id } = game.mancalaGame;
|
||||||
|
const nextMove = moves[n];
|
||||||
|
const mancalaGame = new MancalaGame(
|
||||||
|
id,
|
||||||
|
new Board(referenceBoard.playerPitCount, referenceBoard.initialStoneCountInPits, pits),
|
||||||
|
player1Id,
|
||||||
|
player2Id,
|
||||||
|
nextMove ? nextMove.playerId : game.mancalaGame.turnPlayerId,
|
||||||
|
[],
|
||||||
|
moves.slice(0, n).map((move) => ({ ...move, gameSteps: [...move.gameSteps] })),
|
||||||
|
n === moves.length ? game.mancalaGame.state : "playing"
|
||||||
|
);
|
||||||
|
return { ...game, mancalaGame };
|
||||||
|
}
|
||||||
1
packages/core/src/history/index.ts
Normal file
1
packages/core/src/history/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './MoveHistory'
|
||||||
@ -7,4 +7,5 @@ export * from './util/index'
|
|||||||
export * from './animation/index'
|
export * from './animation/index'
|
||||||
export * from './factory/index'
|
export * from './factory/index'
|
||||||
export * from './viewmodel/index'
|
export * from './viewmodel/index'
|
||||||
export * from './service/index'
|
export * from './service/index'
|
||||||
|
export * from './history/index'
|
||||||
|
|||||||
@ -42,5 +42,34 @@ export const en = {
|
|||||||
Guest: "Guest",
|
Guest: "Guest",
|
||||||
LogIn: "Log In",
|
LogIn: "Log In",
|
||||||
SignUp: "Sign Up",
|
SignUp: "Sign Up",
|
||||||
LogOut: "Log Out"
|
LogOut: "Log Out",
|
||||||
|
LiveGames: "Live Games",
|
||||||
|
PopularGames: "Popular Games",
|
||||||
|
MyRecentGames: "My Recent Games",
|
||||||
|
NoGamesYet: "No games yet",
|
||||||
|
Watching: "{{count}} watching",
|
||||||
|
GameInProgress: "You have a game in progress",
|
||||||
|
Continue: "Continue",
|
||||||
|
Versus: "vs",
|
||||||
|
Draw: "Draw",
|
||||||
|
OpponentLeft: "Opponent left",
|
||||||
|
YouLeft: "You left",
|
||||||
|
OpponentTimedOut: "Opponent timed out",
|
||||||
|
YouTimedOut: "You timed out",
|
||||||
|
Abandoned: "Abandoned",
|
||||||
|
PlayerWon: "{{name}} won",
|
||||||
|
Moves: "Moves",
|
||||||
|
PitNumber: "Pit {{pit}}",
|
||||||
|
ExtraTurn: "Extra turn",
|
||||||
|
Capture: "Capture",
|
||||||
|
StoreGain: "+{{count}} to store",
|
||||||
|
GameOver: "Game over",
|
||||||
|
ViewingMove: "Viewing move {{number}}",
|
||||||
|
MoveOf: "Move {{number}} / {{total}}",
|
||||||
|
Start: "Start",
|
||||||
|
BackToLive: "Back to live",
|
||||||
|
WatchFromStart: "Watch from start",
|
||||||
|
Speed: "Speed",
|
||||||
|
Duration: "Duration",
|
||||||
|
OpponentOfflineCountdown: "Opponent is offline. You win in {{time}} if they don't come back."
|
||||||
}
|
}
|
||||||
@ -42,5 +42,34 @@ export const tr = {
|
|||||||
Guest: "Misafir",
|
Guest: "Misafir",
|
||||||
LogIn: "Giriş Yap",
|
LogIn: "Giriş Yap",
|
||||||
SignUp: "Kayıt Ol",
|
SignUp: "Kayıt Ol",
|
||||||
LogOut: "Çıkış Yap"
|
LogOut: "Çıkış Yap",
|
||||||
|
LiveGames: "Canlı Oyunlar",
|
||||||
|
PopularGames: "Popüler Oyunlar",
|
||||||
|
MyRecentGames: "Son Oyunlarım",
|
||||||
|
NoGamesYet: "Henüz oyun yok",
|
||||||
|
Watching: "{{count}} izliyor",
|
||||||
|
GameInProgress: "Devam eden bir oyunun var",
|
||||||
|
Continue: "Devam Et",
|
||||||
|
Versus: "-",
|
||||||
|
Draw: "Berabere",
|
||||||
|
OpponentLeft: "Rakip ayrıldı",
|
||||||
|
YouLeft: "Sen ayrıldın",
|
||||||
|
OpponentTimedOut: "Rakibin süresi doldu",
|
||||||
|
YouTimedOut: "Süren doldu",
|
||||||
|
Abandoned: "Yarıda kaldı",
|
||||||
|
PlayerWon: "{{name}} kazandı",
|
||||||
|
Moves: "Hamleler",
|
||||||
|
PitNumber: "{{pit}}. kuyu",
|
||||||
|
ExtraTurn: "Ekstra tur",
|
||||||
|
Capture: "Taş aldı",
|
||||||
|
StoreGain: "Hazineye +{{count}}",
|
||||||
|
GameOver: "Oyun bitti",
|
||||||
|
ViewingMove: "{{number}}. hamleye bakıyorsun",
|
||||||
|
MoveOf: "Hamle {{number}} / {{total}}",
|
||||||
|
Start: "Başlangıç",
|
||||||
|
BackToLive: "Canlıya dön",
|
||||||
|
WatchFromStart: "Baştan izle",
|
||||||
|
Speed: "Hız",
|
||||||
|
Duration: "Süre",
|
||||||
|
OpponentOfflineCountdown: "Rakip çevrimdışı. Dönmezse {{time}} içinde kazanırsın."
|
||||||
};
|
};
|
||||||
@ -5,6 +5,10 @@ export interface Game {
|
|||||||
id: string;
|
id: string;
|
||||||
mancalaGame: MancalaGame;
|
mancalaGame: MancalaGame;
|
||||||
gameUsersConnectionInfo: GameUsersConnectionInfo;
|
gameUsersConnectionInfo: GameUsersConnectionInfo;
|
||||||
|
// Epoch milliseconds; sent by servers that save games.
|
||||||
|
startedAt?: number;
|
||||||
|
// Time of each move, aligned with mancalaGame.history.
|
||||||
|
moveTimes?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameUsersConnectionInfo {
|
export interface GameUsersConnectionInfo {
|
||||||
|
|||||||
64
packages/core/src/models/GameHistory.ts
Normal file
64
packages/core/src/models/GameHistory.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { GameStep } from "mancala.js";
|
||||||
|
|
||||||
|
// How long the player whose turn it is may stay offline before the opponent wins.
|
||||||
|
export const TURN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
// How long both players may stay offline before the game ends without a winner.
|
||||||
|
export const INACTIVE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface PlayerSummary {
|
||||||
|
userId: string;
|
||||||
|
name?: string;
|
||||||
|
isGuest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveRecord {
|
||||||
|
playerId: string;
|
||||||
|
// Board index of the pit that was played.
|
||||||
|
moveIndex: number;
|
||||||
|
// Stones in every pit and store after the move.
|
||||||
|
boardSnapshot: number[];
|
||||||
|
gameSteps: GameStep[];
|
||||||
|
// Epoch milliseconds.
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameResult = "player1" | "player2" | "draw";
|
||||||
|
|
||||||
|
export type EndReason = "completed" | "left" | "timeout" | "inactive";
|
||||||
|
|
||||||
|
export interface LiveGameSummary {
|
||||||
|
id: string;
|
||||||
|
player1: PlayerSummary;
|
||||||
|
player2: PlayerSummary;
|
||||||
|
board: number[];
|
||||||
|
turnPlayerId: string;
|
||||||
|
spectatorCount: number;
|
||||||
|
moveCount: number;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinishedGameSummary {
|
||||||
|
id: string;
|
||||||
|
player1: PlayerSummary;
|
||||||
|
player2: PlayerSummary;
|
||||||
|
// Final board.
|
||||||
|
board: number[];
|
||||||
|
// Null when the game ended without a winner.
|
||||||
|
result: GameResult | null;
|
||||||
|
endReason: EndReason;
|
||||||
|
leftUserId?: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt: number;
|
||||||
|
moveCount: number;
|
||||||
|
peakSpectators: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameRecord extends FinishedGameSummary {
|
||||||
|
moves: MoveRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /game/:id for a game that has ended.
|
||||||
|
export interface FinishedGameResponse {
|
||||||
|
status: "finished";
|
||||||
|
record: GameRecord;
|
||||||
|
}
|
||||||
@ -4,4 +4,6 @@ export interface UserConnectionInfo {
|
|||||||
// Sent by servers with Keycloak auth; undefined for legacy userKey players.
|
// Sent by servers with Keycloak auth; undefined for legacy userKey players.
|
||||||
name?: string;
|
name?: string;
|
||||||
isGuest?: boolean;
|
isGuest?: boolean;
|
||||||
|
// Epoch milliseconds since the user has been offline; undefined while online.
|
||||||
|
offlineSince?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,4 +3,5 @@ export * from './GameMove'
|
|||||||
export * from './LoadingState'
|
export * from './LoadingState'
|
||||||
export * from './User'
|
export * from './User'
|
||||||
export * from './UserConnectionInfo'
|
export * from './UserConnectionInfo'
|
||||||
export * from './AuthUser'
|
export * from './AuthUser'
|
||||||
|
export * from './GameHistory'
|
||||||
|
|||||||
@ -19,3 +19,11 @@ export const channel_on_auth = "on_auth"
|
|||||||
// server -> client: { reason }, sent right before the server closes the socket with CLOSE_AUTH_FAILED.
|
// server -> client: { reason }, sent right before the server closes the socket with CLOSE_AUTH_FAILED.
|
||||||
export const channel_on_auth_error = "on_auth_error"
|
export const channel_on_auth_error = "on_auth_error"
|
||||||
export const CLOSE_AUTH_FAILED = 4001
|
export const CLOSE_AUTH_FAILED = 4001
|
||||||
|
|
||||||
|
// Home page previews. client -> server: subscribe_live_games / unsubscribe_live_games;
|
||||||
|
// server -> client: on_live_games with { games: LiveGameSummary[] }.
|
||||||
|
export const channel_subscribe_live_games = "subscribe_live_games"
|
||||||
|
export const channel_unsubscribe_live_games = "unsubscribe_live_games"
|
||||||
|
export const channel_on_live_games = "on_live_games"
|
||||||
|
// server -> client: { gameId }, when new_game is sent while the user has a game in progress.
|
||||||
|
export const channel_on_new_game_rejected = "on_new_game_rejected"
|
||||||
|
|||||||
138
packages/core/tests/history/MoveHistory.test.ts
Normal file
138
packages/core/tests/history/MoveHistory.test.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { CommonMancalaGame, MancalaGame } from "mancala.js";
|
||||||
|
import { createGameAtMove, describeMove, getBoardAfterMove, getInitialBoard, PlayedMove } from "../../src/history";
|
||||||
|
import { Game } from "../../src/models";
|
||||||
|
|
||||||
|
const P1 = "player-1";
|
||||||
|
const P2 = "player-2";
|
||||||
|
|
||||||
|
const newGame = (): MancalaGame => {
|
||||||
|
const game = new CommonMancalaGame("game-1", P1, P2);
|
||||||
|
game.turnPlayerId = P1;
|
||||||
|
return game;
|
||||||
|
};
|
||||||
|
|
||||||
|
const movesOf = (game: MancalaGame) => game.history as unknown as PlayedMove[];
|
||||||
|
|
||||||
|
const asGame = (mancalaGame: MancalaGame): Game => ({
|
||||||
|
id: mancalaGame.id,
|
||||||
|
mancalaGame,
|
||||||
|
gameUsersConnectionInfo: {
|
||||||
|
user1ConnectionInfo: { userId: P1, isOnline: true },
|
||||||
|
user2ConnectionInfo: { userId: P2, isOnline: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deterministic pseudo random numbers, so failures can be reproduced.
|
||||||
|
const seededRandom = (seed: number) => () => {
|
||||||
|
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||||
|
return seed / 2147483648;
|
||||||
|
};
|
||||||
|
|
||||||
|
const playRandomGame = (seed: number): MancalaGame => {
|
||||||
|
const random = seededRandom(seed);
|
||||||
|
const game = newGame();
|
||||||
|
while (game.state !== "ended") {
|
||||||
|
const playable = [0, 1, 2, 3, 4, 5].filter((pit) =>
|
||||||
|
game.board.pits[game.getBoardIndexByPlayerId(game.turnPlayerId, pit)].stoneCount > 0);
|
||||||
|
game.moveByPlayerPit(game.turnPlayerId, playable[Math.floor(random() * playable.length)]);
|
||||||
|
}
|
||||||
|
return game;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("getBoardAfterMove", () => {
|
||||||
|
it("returns the start position for move 0", () => {
|
||||||
|
expect(getInitialBoard()).toEqual([4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0]);
|
||||||
|
expect(getBoardAfterMove([], 0)).toEqual(getInitialBoard());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the board after each move and clamps out of range move numbers", () => {
|
||||||
|
const game = playRandomGame(1);
|
||||||
|
const moves = movesOf(game);
|
||||||
|
moves.forEach((move, i) => expect(getBoardAfterMove(moves, i + 1)).toEqual(move.boardSnapshot));
|
||||||
|
expect(getBoardAfterMove(moves, moves.length + 5)).toEqual(game.board.getStoneArray());
|
||||||
|
expect(getBoardAfterMove(moves, -1)).toEqual(getInitialBoard());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeMove", () => {
|
||||||
|
it("describes a move that ends in the player's own store", () => {
|
||||||
|
const game = newGame();
|
||||||
|
game.moveByPlayerPit(P1, 3); // 4 stones from the 4th pit: pits 4, 5, 6 and the store
|
||||||
|
expect(describeMove(movesOf(game), 1)).toEqual({
|
||||||
|
number: 1, playerId: P1, pit: 4, extraTurn: true, capture: false, storeGain: 1, gameOver: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts pits from player 2's own side", () => {
|
||||||
|
const game = newGame();
|
||||||
|
game.moveByPlayerPit(P1, 0);
|
||||||
|
game.moveByPlayerPit(P2, 1);
|
||||||
|
const description = describeMove(movesOf(game), 2);
|
||||||
|
expect(description.playerId).toBe(P2);
|
||||||
|
expect(description.pit).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a capture and the stones it adds to the store", () => {
|
||||||
|
const game = newGame();
|
||||||
|
game.moveByPlayerPit(P1, 0);
|
||||||
|
// Set up the position after move 1: P1's single stone in pit 0 lands in the empty pit 1,
|
||||||
|
// whose opposite pit 11 holds 5 stones.
|
||||||
|
game.board.pits.forEach((pit) => (pit.stoneCount = 0));
|
||||||
|
game.board.pits[0].stoneCount = 1;
|
||||||
|
game.board.pits[11].stoneCount = 5;
|
||||||
|
game.board.pits[4].stoneCount = 1; // keep both sides from running out of stones,
|
||||||
|
game.board.pits[12].stoneCount = 1; // which would end the game with this move
|
||||||
|
game.turnPlayerId = P1;
|
||||||
|
game.history[0].boardSnapshot = game.board.getStoneArray();
|
||||||
|
game.moveByPlayerPit(P1, 0);
|
||||||
|
expect(describeMove(movesOf(game), 2)).toMatchObject({ pit: 1, capture: true, storeGain: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only the last move of a finished game as game over, and store gains add up to the final stores", () => {
|
||||||
|
for (let seed = 1; seed <= 25; seed++) {
|
||||||
|
const game = playRandomGame(seed);
|
||||||
|
const moves = movesOf(game);
|
||||||
|
const descriptions = moves.map((_, i) => describeMove(moves, i + 1));
|
||||||
|
expect(descriptions.filter((d) => d.gameOver).map((d) => d.number)).toEqual([moves.length]);
|
||||||
|
const gainOf = (playerId: string) => descriptions.filter((d) => d.playerId === playerId)
|
||||||
|
.reduce((sum, d) => sum + d.storeGain, 0);
|
||||||
|
const final = game.board.getStoneArray();
|
||||||
|
// The sweep at the end can fill the store of the player who did not move, so compare the totals.
|
||||||
|
expect(gainOf(P1) + gainOf(P2)).toBeLessThanOrEqual(final[6] + final[13]);
|
||||||
|
expect(descriptions.every((d) => d.pit >= 1 && d.pit <= 6)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a move number that does not exist", () => {
|
||||||
|
expect(() => describeMove([], 1)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createGameAtMove", () => {
|
||||||
|
it("rebuilds the position and history up to the move, without touching the original game", () => {
|
||||||
|
const played = playRandomGame(7);
|
||||||
|
const moves = movesOf(played);
|
||||||
|
const original = JSON.stringify(played.history);
|
||||||
|
|
||||||
|
const atMove3 = createGameAtMove(asGame(played), moves, 3).mancalaGame;
|
||||||
|
expect(atMove3.board.getStoneArray()).toEqual(moves[2].boardSnapshot);
|
||||||
|
expect(atMove3.history).toHaveLength(3);
|
||||||
|
expect(atMove3.turnPlayerId).toBe(moves[3].playerId);
|
||||||
|
expect(atMove3.state).toBe("playing");
|
||||||
|
expect(atMove3.board.pits[6].isBank).toBe(true);
|
||||||
|
|
||||||
|
const atEnd = createGameAtMove(asGame(played), moves, moves.length).mancalaGame;
|
||||||
|
expect(atEnd.state).toBe("ended");
|
||||||
|
expect(atEnd.board.getStoneArray()).toEqual(played.board.getStoneArray());
|
||||||
|
|
||||||
|
atMove3.history[0].gameSteps.push({ index: 0, type: "x", data: null });
|
||||||
|
expect(JSON.stringify(played.history)).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the start position for move 0", () => {
|
||||||
|
const played = playRandomGame(3);
|
||||||
|
const start = createGameAtMove(asGame(played), movesOf(played), 0).mancalaGame;
|
||||||
|
expect(start.board.getStoneArray()).toEqual(getInitialBoard());
|
||||||
|
expect(start.history).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user