Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
12 changed files with 369 additions and 5 deletions
Showing only changes of commit 16026fb6cb - Show all commits

View File

@ -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

View 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 };
}

View File

@ -0,0 +1 @@
export * from './MoveHistory'

View File

@ -8,3 +8,4 @@ export * from './animation/index'
export * from './factory/index'
export * from './viewmodel/index'
export * from './service/index'
export * from './history/index'

View File

@ -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."
}

View File

@ -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."
};

View File

@ -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 {

View 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;
}

View File

@ -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;
}

View File

@ -4,3 +4,4 @@ export * from './LoadingState'
export * from './User'
export * from './UserConnectionInfo'
export * from './AuthUser'
export * from './GameHistory'

View File

@ -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"

View 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);
});
});