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
|
||||
(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`,
|
||||
`createGameAtMove` with unit tests; localized strings.
|
||||
- [ ] 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'
|
||||
@ -8,3 +8,4 @@ export * from './animation/index'
|
||||
export * from './factory/index'
|
||||
export * from './viewmodel/index'
|
||||
export * from './service/index'
|
||||
export * from './history/index'
|
||||
|
||||
@ -42,5 +42,34 @@ export const en = {
|
||||
Guest: "Guest",
|
||||
LogIn: "Log In",
|
||||
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",
|
||||
LogIn: "Giriş Yap",
|
||||
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;
|
||||
mancalaGame: MancalaGame;
|
||||
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 {
|
||||
|
||||
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.
|
||||
name?: string;
|
||||
isGuest?: boolean;
|
||||
// Epoch milliseconds since the user has been offline; undefined while online.
|
||||
offlineSince?: number;
|
||||
}
|
||||
|
||||
@ -4,3 +4,4 @@ export * from './LoadingState'
|
||||
export * from './User'
|
||||
export * from './UserConnectionInfo'
|
||||
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.
|
||||
export const channel_on_auth_error = "on_auth_error"
|
||||
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