mancala/apps/backend/src/game/GameSerialization.ts
Halit Aksoy 999ae79279 feat(backend): run a game clock for each player
Each player starts with GAME_CLOCK_MS (5 minutes). A move deducts the turn's time from the mover; a move that arrives after the time has run out is not played and the opponent wins on time. Every ending stops the clock, and clients get the clock and the server time with each game message.
2026-10-16 01:27:28 +03:00

125 lines
5.0 KiB
TypeScript

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";
import { startClock } from "./GameClock";
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.
export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame {
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,
clock: startClock(params.gameClockMs, params.now),
moveRemainingMs: [],
};
}
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;
}