From 16026fb6cb98f94149b99e406949c2317a071b33 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 00:07:36 +0300 Subject: [PATCH] feat(core): add game history types and move history helpers - PlayerSummary, MoveRecord, live and finished game summaries, GameRecord, timeout constants, startedAt/moveTimes on Game and offlineSince on UserConnectionInfo - Channels for live game previews and for rejecting a new game while one is in progress - getInitialBoard, getBoardAfterMove, describeMove and createGameAtMove, with tests against games played with the engine - English and Turkish strings for the new screens --- docs/specs/game-history/tasks.md | 2 +- packages/core/src/history/MoveHistory.ts | 87 +++++++++++ packages/core/src/history/index.ts | 1 + packages/core/src/index.ts | 3 +- packages/core/src/localization/en.ts | 31 +++- packages/core/src/localization/tr.ts | 31 +++- packages/core/src/models/Game.ts | 4 + packages/core/src/models/GameHistory.ts | 64 ++++++++ .../core/src/models/UserConnectionInfo.ts | 2 + packages/core/src/models/index.ts | 3 +- packages/core/src/rtmt/channel_names.ts | 8 + .../core/tests/history/MoveHistory.test.ts | 138 ++++++++++++++++++ 12 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/history/MoveHistory.ts create mode 100644 packages/core/src/history/index.ts create mode 100644 packages/core/src/models/GameHistory.ts create mode 100644 packages/core/tests/history/MoveHistory.test.ts diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md index eab1633..2af7219 100644 --- a/docs/specs/game-history/tasks.md +++ b/docs/specs/game-history/tasks.md @@ -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 diff --git a/packages/core/src/history/MoveHistory.ts b/packages/core/src/history/MoveHistory.ts new file mode 100644 index 0000000..cdb47d0 --- /dev/null +++ b/packages/core/src/history/MoveHistory.ts @@ -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 }; +} diff --git a/packages/core/src/history/index.ts b/packages/core/src/history/index.ts new file mode 100644 index 0000000..aefb898 --- /dev/null +++ b/packages/core/src/history/index.ts @@ -0,0 +1 @@ +export * from './MoveHistory' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 977d591..5e3baca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,4 +7,5 @@ export * from './util/index' export * from './animation/index' export * from './factory/index' export * from './viewmodel/index' -export * from './service/index' \ No newline at end of file +export * from './service/index' +export * from './history/index' diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index 6f177f2..489a591 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -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." } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index 77a4639..f92250c 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -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." }; \ No newline at end of file diff --git a/packages/core/src/models/Game.ts b/packages/core/src/models/Game.ts index ec37311..7a0a2bb 100644 --- a/packages/core/src/models/Game.ts +++ b/packages/core/src/models/Game.ts @@ -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 { diff --git a/packages/core/src/models/GameHistory.ts b/packages/core/src/models/GameHistory.ts new file mode 100644 index 0000000..e547743 --- /dev/null +++ b/packages/core/src/models/GameHistory.ts @@ -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; +} diff --git a/packages/core/src/models/UserConnectionInfo.ts b/packages/core/src/models/UserConnectionInfo.ts index 69e6a87..7702c25 100644 --- a/packages/core/src/models/UserConnectionInfo.ts +++ b/packages/core/src/models/UserConnectionInfo.ts @@ -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; } diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 64a47b8..8e274b5 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -3,4 +3,5 @@ export * from './GameMove' export * from './LoadingState' export * from './User' export * from './UserConnectionInfo' -export * from './AuthUser' \ No newline at end of file +export * from './AuthUser' +export * from './GameHistory' diff --git a/packages/core/src/rtmt/channel_names.ts b/packages/core/src/rtmt/channel_names.ts index b554be4..e3b532d 100644 --- a/packages/core/src/rtmt/channel_names.ts +++ b/packages/core/src/rtmt/channel_names.ts @@ -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" diff --git a/packages/core/tests/history/MoveHistory.test.ts b/packages/core/tests/history/MoveHistory.test.ts new file mode 100644 index 0000000..6ad7306 --- /dev/null +++ b/packages/core/tests/history/MoveHistory.test.ts @@ -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); + }); +});