2026-10-16 00:18:33 +03:00
|
|
|
import { EndReason, GameRecord, GameResult, MoveRecord } from "@mancala/core";
|
|
|
|
|
import { CommonMancalaGame, GameState, GameStep } from "mancala.js";
|
|
|
|
|
import { BackendGame, PlayerInfo } from "../models/Game";
|
|
|
|
|
import { publicUserId } from "./publicIds";
|
2026-10-16 01:27:28 +03:00
|
|
|
import { startClock } from "./GameClock";
|
2026-10-16 00:18:33 +03:00
|
|
|
|
|
|
|
|
type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] };
|
|
|
|
|
|
|
|
|
|
// What is kept in Valkey for a live game; enough to rebuild it after a restart.
|
|
|
|
|
export interface LiveGameSnapshot {
|
|
|
|
|
version: 1;
|
|
|
|
|
id: string;
|
|
|
|
|
player1Id: string;
|
|
|
|
|
player2Id: string;
|
|
|
|
|
turnPlayerId: string;
|
|
|
|
|
state: GameState;
|
|
|
|
|
board: number[];
|
|
|
|
|
history: HistoryEntry[];
|
|
|
|
|
players: Record<string, PlayerInfo>;
|
|
|
|
|
startedAt: number;
|
|
|
|
|
moveTimes: number[];
|
|
|
|
|
peakSpectators: number;
|
|
|
|
|
allSpectatorIds: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GameEnding {
|
|
|
|
|
endReason: EndReason;
|
|
|
|
|
// Undefined when the game ends without a winner.
|
|
|
|
|
winnerId?: string;
|
|
|
|
|
leftUserId?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function toSnapshot(game: BackendGame): LiveGameSnapshot {
|
|
|
|
|
const { mancalaGame } = game;
|
|
|
|
|
return {
|
|
|
|
|
version: 1,
|
|
|
|
|
id: game.id,
|
|
|
|
|
player1Id: mancalaGame.player1Id,
|
|
|
|
|
player2Id: mancalaGame.player2Id,
|
|
|
|
|
turnPlayerId: mancalaGame.turnPlayerId,
|
|
|
|
|
state: mancalaGame.state,
|
|
|
|
|
board: mancalaGame.board.getStoneArray(),
|
|
|
|
|
history: mancalaGame.history as unknown as HistoryEntry[],
|
|
|
|
|
players: game.players,
|
|
|
|
|
startedAt: game.startedAt,
|
|
|
|
|
moveTimes: game.moveTimes,
|
|
|
|
|
peakSpectators: game.peakSpectators,
|
|
|
|
|
allSpectatorIds: game.allSpectatorIds,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot {
|
|
|
|
|
const snapshot = value as LiveGameSnapshot;
|
|
|
|
|
return typeof snapshot === "object" && snapshot !== null && snapshot.version === 1
|
|
|
|
|
&& typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
|
|
|
|
|
&& Array.isArray(snapshot.history) && typeof snapshot.players === "object";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on.
|
2026-10-16 01:27:28 +03:00
|
|
|
export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame {
|
2026-10-16 00:18:33 +03:00
|
|
|
const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id);
|
|
|
|
|
snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
|
|
|
|
|
mancalaGame.turnPlayerId = snapshot.turnPlayerId;
|
|
|
|
|
mancalaGame.state = snapshot.state;
|
|
|
|
|
mancalaGame.history.push(...snapshot.history);
|
|
|
|
|
const player = (id: string): PlayerInfo => snapshot.players[id] ?? { userId: id, isGuest: true };
|
|
|
|
|
return {
|
|
|
|
|
id: snapshot.id,
|
|
|
|
|
mancalaGame,
|
|
|
|
|
gameUsersConnectionInfo: {
|
|
|
|
|
user1ConnectionInfo: { ...player(snapshot.player1Id), isOnline: false },
|
|
|
|
|
user2ConnectionInfo: { ...player(snapshot.player2Id), isOnline: false },
|
|
|
|
|
},
|
|
|
|
|
spectatorIds: [],
|
|
|
|
|
allSpectatorIds: snapshot.allSpectatorIds,
|
|
|
|
|
peakSpectators: snapshot.peakSpectators,
|
|
|
|
|
players: snapshot.players,
|
|
|
|
|
startedAt: snapshot.startedAt,
|
|
|
|
|
moveTimes: snapshot.moveTimes,
|
2026-10-16 01:27:28 +03:00
|
|
|
clock: startClock(params.gameClockMs, params.now),
|
|
|
|
|
moveRemainingMs: [],
|
2026-10-16 00:18:33 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getResult(game: BackendGame, ending: GameEnding): GameResult | null {
|
|
|
|
|
const { player1Id, player2Id } = game.mancalaGame;
|
|
|
|
|
if (ending.winnerId === player1Id) return "player1";
|
|
|
|
|
if (ending.winnerId === player2Id) return "player2";
|
|
|
|
|
return ending.endReason === "completed" ? "draw" : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The record saved for a finished game; every id in it is a public id.
|
|
|
|
|
export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: number): GameRecord {
|
|
|
|
|
const { mancalaGame } = game;
|
|
|
|
|
const history = mancalaGame.history as unknown as HistoryEntry[];
|
|
|
|
|
const playerSummary = (id: string) => {
|
|
|
|
|
const player = game.players[id];
|
|
|
|
|
const summary = { userId: publicUserId(id), isGuest: player?.isGuest ?? true };
|
|
|
|
|
return player?.name === undefined ? summary : { ...summary, name: player.name };
|
|
|
|
|
};
|
|
|
|
|
const moves: MoveRecord[] = history.map((entry, i) => ({
|
|
|
|
|
playerId: publicUserId(entry.playerId),
|
|
|
|
|
moveIndex: entry.moveIndex,
|
|
|
|
|
boardSnapshot: entry.boardSnapshot,
|
|
|
|
|
gameSteps: entry.gameSteps,
|
|
|
|
|
at: game.moveTimes[i] ?? endedAt,
|
|
|
|
|
}));
|
|
|
|
|
const record: GameRecord = {
|
|
|
|
|
id: game.id,
|
|
|
|
|
player1: playerSummary(mancalaGame.player1Id),
|
|
|
|
|
player2: playerSummary(mancalaGame.player2Id),
|
|
|
|
|
board: mancalaGame.board.getStoneArray(),
|
|
|
|
|
result: getResult(game, ending),
|
|
|
|
|
endReason: ending.endReason,
|
|
|
|
|
startedAt: game.startedAt,
|
|
|
|
|
endedAt,
|
|
|
|
|
moveCount: moves.length,
|
|
|
|
|
peakSpectators: game.peakSpectators,
|
|
|
|
|
totalSpectators: game.allSpectatorIds.length,
|
|
|
|
|
moves,
|
|
|
|
|
};
|
|
|
|
|
if (ending.leftUserId !== undefined) record.leftUserId = publicUserId(ending.leftUserId);
|
|
|
|
|
return record;
|
|
|
|
|
}
|