From 02bbd28aaf28e6e16719abe59af964f472d0e8a5 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:01:52 +0100 Subject: [PATCH 01/21] feat(game-engine): add move history types and per-puzzle-type undo depths Introduces the move history model: the ordered action log, the state snapshots, the game modes and the per-puzzle-type undo depth defaults. The defaults live here so the Nest configuration and the in-memory puzzle implementations share a single source of truth. --- src/game-engine/types/move-history.types.ts | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/game-engine/types/move-history.types.ts diff --git a/src/game-engine/types/move-history.types.ts b/src/game-engine/types/move-history.types.ts new file mode 100644 index 00000000..f8b76169 --- /dev/null +++ b/src/game-engine/types/move-history.types.ts @@ -0,0 +1,131 @@ +import type { PuzzleGameState } from '../interfaces/puzzle.interfaces'; +import type { PuzzleMove } from './puzzle.types'; +import { PuzzleType } from './puzzle.types'; + +/** The modes a puzzle session can be played in. */ +export enum GameMode { + PRACTICE = 'practice', + CHALLENGE = 'challenge', + TIMED = 'timed', + ZEN = 'zen', + RANKED = 'ranked', +} + +/** The kinds of action that can appear in a move history. */ +export enum MoveHistoryAction { + MOVE = 'move', + UNDO = 'undo', + REDO = 'redo', + RESET = 'reset', +} + +/** + * Default number of undo steps kept for each puzzle type. + * + * Complex puzzles hold large state snapshots, so they keep a shallower + * history; simple puzzles can afford a deeper one. The environment + * configuration may override each of these. + */ +export const DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE: Partial< + Record +> = { + [PuzzleType.LOGIC_GRID]: 5, + [PuzzleType.SEQUENCE]: 5, + [PuzzleType.SPATIAL]: 8, + [PuzzleType.MATHEMATICAL]: 10, + [PuzzleType.WORD_PUZZLE]: 20, + [PuzzleType.PATTERN_MATCHING]: 15, + [PuzzleType.CUSTOM]: 10, +}; + +/** Fallback undo depth for puzzle types without an explicit limit. */ +export const DEFAULT_UNDO_DEPTH = 10; + +/** A single entry in a session's move history. */ +export interface MoveHistoryEntry { + /** Monotonically increasing sequence number within the session. */ + sequence: number; + action: MoveHistoryAction; + /** The move that produced this entry; null for undo/redo/reset. */ + move: PuzzleMove | null; + /** Puzzle state before the action was applied. */ + stateBefore: PuzzleGameState | null; + /** Puzzle state after the action was applied. */ + stateAfter: PuzzleGameState | null; + timestamp: Date; + /** Running total of undos at the time this entry was recorded. */ + undoCount: number; + /** Running total of redos at the time this entry was recorded. */ + redoCount: number; + /** Score penalty charged by this entry (0 when undo is free). */ + scorePenalty: number; +} + +/** The move history of a single puzzle session. */ +export interface MoveHistory { + puzzleId: string; + playerId: string; + puzzleType: PuzzleType; + mode: GameMode; + /** Ordered action log — this is the replayable history. */ + entries: MoveHistoryEntry[]; + /** Retained state snapshots, oldest first. */ + states: PuzzleGameState[]; + /** Index of the current state inside `states`. */ + cursor: number; + /** Maximum number of steps that may be undone for this puzzle type. */ + maxUndoDepth: number; + undoCount: number; + redoCount: number; + /** Accumulated score penalty charged by undo/redo. */ + totalPenalty: number; +} + +/** The outcome of a successful undo or redo. */ +export interface MoveHistorySnapshot { + /** The state that was restored. */ + state: PuzzleGameState; + /** The history entry recorded for the action. */ + entry: MoveHistoryEntry; + /** Score penalty charged by the action. */ + scorePenalty: number; + canUndo: boolean; + canRedo: boolean; +} + +/** The effective undo/redo rules that apply to a session. */ +export interface UndoRedoPolicy { + mode: GameMode; + puzzleType: PuzzleType; + /** Maximum number of steps that may be undone. */ + maxUndoDepth: number; + /** False when undo is disabled entirely for the mode. */ + undoAllowed: boolean; + /** Score penalty charged per undo in this mode. */ + undoPenalty: number; + /** Score penalty charged per redo in this mode. */ + redoPenalty: number; + canUndo: boolean; + canRedo: boolean; +} + +/** + * A replay action derived from a move history entry. + * + * Structurally compatible with the replay module's action DTO, so a history + * can be replayed without either module depending on the other. + */ +export interface ReplayActionDraft { + actionType: 'MOVE' | 'UNDO' | 'REDO' | 'STATE_CHANGE'; + timestamp: number; + actionData: Record; + stateBefore?: Record; + stateAfter?: Record; + metadata: { + duration?: number; + difficulty?: string; + confidence?: number; + notes?: string; + [key: string]: any; + }; +} From af85d9e2542b48b5b2b1b923e17f2bfc06dfc8fa Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:01:52 +0100 Subject: [PATCH 02/21] feat(game-engine): add undo/redo configuration Adds an undoRedo section to the game engine configuration: a master switch, the per-puzzle-type undo depth limits, the modes in which undo is disabled or penalised, and the penalty ratios. Every value is environment driven. --- src/game-engine/config/game-engine.config.ts | 65 ++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/game-engine/config/game-engine.config.ts b/src/game-engine/config/game-engine.config.ts index 251c4b23..a2add57e 100644 --- a/src/game-engine/config/game-engine.config.ts +++ b/src/game-engine/config/game-engine.config.ts @@ -1,4 +1,10 @@ import { registerAs } from '@nestjs/config'; +import { + DEFAULT_UNDO_DEPTH, + DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE, + GameMode, +} from '../types/move-history.types'; +import { PuzzleType } from '../types/puzzle.types'; export interface GameEngineConfig { puzzles: { @@ -40,6 +46,21 @@ export interface GameEngineConfig { allowPartialSolutions: boolean; timeoutThreshold: number; }; + undoRedo: { + enabled: boolean; + /** Undo steps allowed when a puzzle type has no explicit limit. */ + defaultMaxUndoDepth: number; + /** Per-puzzle-type undo depth limits. */ + maxUndoDepthByPuzzleType: Partial>; + /** Modes in which undo is disabled entirely. */ + disabledModes: GameMode[]; + /** Modes in which undo is allowed but costs score. */ + penalizedModes: GameMode[]; + /** Score penalty ratio applied per undo in a penalised mode. */ + undoPenalty: number; + /** Score penalty ratio applied per redo in a penalised mode. */ + redoPenalty: number; + }; } export const gameEngineConfig = registerAs( @@ -108,5 +129,49 @@ export const gameEngineConfig = registerAs( process.env.GAME_TIMEOUT_THRESHOLD || '600000', ), }, + undoRedo: { + enabled: process.env.GAME_UNDO_ENABLED !== 'false', + defaultMaxUndoDepth: Number.parseInt( + process.env.GAME_MAX_UNDO_DEPTH || String(DEFAULT_UNDO_DEPTH), + ), + maxUndoDepthByPuzzleType: { + // Complex puzzles have expensive states, so they get a shallower + // undo history; simple puzzles can afford a deeper one. + [PuzzleType.LOGIC_GRID]: Number.parseInt( + process.env.GAME_MAX_UNDO_DEPTH_LOGIC_GRID || + String(DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE[PuzzleType.LOGIC_GRID]), + ), + [PuzzleType.SEQUENCE]: Number.parseInt( + process.env.GAME_MAX_UNDO_DEPTH_SEQUENCE || + String(DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE[PuzzleType.SEQUENCE]), + ), + [PuzzleType.SPATIAL]: Number.parseInt( + process.env.GAME_MAX_UNDO_DEPTH_SPATIAL || + String(DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE[PuzzleType.SPATIAL]), + ), + [PuzzleType.MATHEMATICAL]: Number.parseInt( + process.env.GAME_MAX_UNDO_DEPTH_MATHEMATICAL || + String(DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE[PuzzleType.MATHEMATICAL]), + ), + [PuzzleType.WORD_PUZZLE]: Number.parseInt( + process.env.GAME_MAX_UNDO_DEPTH_WORD_PUZZLE || + String(DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE[PuzzleType.WORD_PUZZLE]), + ), + [PuzzleType.PATTERN_MATCHING]: Number.parseInt( + process.env.GAME_MAX_UNDO_DEPTH_PATTERN_MATCHING || + String( + DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE[PuzzleType.PATTERN_MATCHING], + ), + ), + [PuzzleType.CUSTOM]: Number.parseInt( + process.env.GAME_MAX_UNDO_DEPTH_CUSTOM || + String(DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE[PuzzleType.CUSTOM]), + ), + }, + disabledModes: [GameMode.RANKED], + penalizedModes: [GameMode.CHALLENGE, GameMode.TIMED], + undoPenalty: Number.parseFloat(process.env.GAME_UNDO_PENALTY || '0.05'), + redoPenalty: Number.parseFloat(process.env.GAME_REDO_PENALTY || '0.02'), + }, }), ); From 2a2532c3843694b0ce344e3e87b26e51a816d9fa Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:01:53 +0100 Subject: [PATCH 03/21] feat(game-engine): add move history service with undo/redo and policy Tracks the move history of a puzzle session and implements undo/redo on top of it. - the history is a list of state snapshots plus a cursor, so undo and redo restore the exact prior state rather than replaying moves - a new move discards the redo branch - undo depth is limited per puzzle type, which also bounds the memory held - ranked modes have undo disabled, penalised modes charge a score penalty - the history converts into replay actions for the replay system --- .../services/move-history.service.ts | 440 ++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 src/game-engine/services/move-history.service.ts diff --git a/src/game-engine/services/move-history.service.ts b/src/game-engine/services/move-history.service.ts new file mode 100644 index 00000000..ab0f2582 --- /dev/null +++ b/src/game-engine/services/move-history.service.ts @@ -0,0 +1,440 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import { gameEngineConfig } from '../config/game-engine.config'; +import type { PuzzleGameState } from '../interfaces/puzzle.interfaces'; +import { + GameMode, + MoveHistory, + MoveHistoryAction, + MoveHistoryEntry, + MoveHistorySnapshot, + ReplayActionDraft, + UndoRedoPolicy, +} from '../types/move-history.types'; +import { PuzzleMove, PuzzleType } from '../types/puzzle.types'; + +export interface StartSessionOptions { + puzzleType?: PuzzleType; + mode?: GameMode; + /** The state the puzzle started from, if it is already known. */ + initialState?: PuzzleGameState; +} + +export interface RecordMoveOptions { + puzzleType?: PuzzleType; + mode?: GameMode; + /** State before the move; derived from the history when omitted. */ + stateBefore?: PuzzleGameState; + /** State after the move; derived from the history when omitted. */ + stateAfter?: PuzzleGameState; +} + +/** + * Tracks the move history of a puzzle session and implements undo/redo on top + * of it. + * + * The history is a linear list of state snapshots plus a cursor into it, which + * is the classic undo/redo model: undo moves the cursor back, redo moves it + * forward, and a new move discards whatever was ahead of the cursor. + * + * Two rules are enforced: + * + * - **Undo depth is limited per puzzle type.** Complex puzzles keep a shallow + * history because their state snapshots are large. + * - **Ranked modes do not get a free undo.** Competitive modes either disable + * undo outright or charge a score penalty for it. + */ +@Injectable() +export class MoveHistoryService { + private readonly logger = new Logger(MoveHistoryService.name); + private readonly histories = new Map(); + + constructor( + @Inject(gameEngineConfig.KEY) + private readonly config: ConfigType, + ) {} + + // ─── Session lifecycle ─────────────────────────────────────────────────── + + /** + * Starts (or returns) the move history for a puzzle session. + * + * The puzzle type and the mode can only be corrected while the session is + * still empty. Once moves have been recorded they are locked, so a client + * cannot switch a ranked session to a penalised one to get a free undo. + */ + startSession( + puzzleId: string, + playerId: string, + options: StartSessionOptions = {}, + ): MoveHistory { + const key = this.key(puzzleId, playerId); + const existing = this.histories.get(key); + + if (existing) { + if (existing.entries.length === 0) { + if (options.mode) { + existing.mode = options.mode; + } + if (options.puzzleType) { + existing.puzzleType = options.puzzleType; + existing.maxUndoDepth = this.getMaxUndoDepth(options.puzzleType); + } + } + return existing; + } + + const puzzleType = options.puzzleType ?? PuzzleType.CUSTOM; + const history: MoveHistory = { + puzzleId, + playerId, + puzzleType, + mode: options.mode ?? GameMode.PRACTICE, + entries: [], + states: options.initialState ? [options.initialState] : [], + cursor: options.initialState ? 0 : -1, + maxUndoDepth: this.getMaxUndoDepth(puzzleType), + undoCount: 0, + redoCount: 0, + totalPenalty: 0, + }; + + this.histories.set(key, history); + return history; + } + + /** The move history of a session, if one has been started. */ + getHistory(puzzleId: string, playerId: string): MoveHistory | undefined { + return this.histories.get(this.key(puzzleId, playerId)); + } + + /** The ordered action log of a session. */ + getEntries(puzzleId: string, playerId: string): MoveHistoryEntry[] { + return this.getHistory(puzzleId, playerId)?.entries ?? []; + } + + /** Drops the history of a session, e.g. when the puzzle is reset. */ + clearSession(puzzleId: string, playerId: string): boolean { + return this.histories.delete(this.key(puzzleId, playerId)); + } + + // ─── Recording moves ───────────────────────────────────────────────────── + + /** + * Records a move in the session history and advances the undo cursor. + * + * Any redo branch is discarded, because a new move invalidates the future + * that a redo would have restored. + */ + recordMove( + puzzleId: string, + playerId: string, + move: PuzzleMove, + options: RecordMoveOptions = {}, + ): MoveHistoryEntry { + const history = this.startSession(puzzleId, playerId, { + puzzleType: options.puzzleType, + mode: options.mode, + }); + + const stateBefore = + options.stateBefore ?? + (history.cursor >= 0 ? history.states[history.cursor] : null); + + const stateAfter = + options.stateAfter ?? + (stateBefore + ? { ...stateBefore, moves: [...(stateBefore.moves ?? []), move] } + : null); + + // A new move invalidates everything ahead of the cursor. + if (history.cursor < history.states.length - 1) { + history.states = history.states.slice(0, history.cursor + 1); + } + + if (stateAfter) { + history.states.push(stateAfter); + history.cursor = history.states.length - 1; + } + + this.trimToUndoDepth(history); + + const entry: MoveHistoryEntry = { + sequence: history.entries.length + 1, + action: MoveHistoryAction.MOVE, + move, + stateBefore, + stateAfter, + timestamp: new Date(), + undoCount: history.undoCount, + redoCount: history.redoCount, + scorePenalty: 0, + }; + + history.entries.push(entry); + return entry; + } + + // ─── Undo / redo ───────────────────────────────────────────────────────── + + /** + * Restores the state that preceded the most recent move. + * + * @throws ForbiddenException when undo is disabled for the session mode. + * @throws BadRequestException when there is nothing left to undo. + */ + undo(puzzleId: string, playerId: string): MoveHistorySnapshot { + const history = this.requireHistory(puzzleId, playerId); + this.assertUndoAllowed(history); + + if (history.cursor <= 0) { + throw new BadRequestException('There is nothing left to undo'); + } + + const stateBefore = history.states[history.cursor]; + history.cursor -= 1; + const state = history.states[history.cursor]; + + history.undoCount += 1; + const scorePenalty = this.getUndoPenalty(history.mode); + history.totalPenalty += scorePenalty; + + const entry: MoveHistoryEntry = { + sequence: history.entries.length + 1, + action: MoveHistoryAction.UNDO, + move: null, + stateBefore, + stateAfter: state, + timestamp: new Date(), + undoCount: history.undoCount, + redoCount: history.redoCount, + scorePenalty, + }; + history.entries.push(entry); + + this.logger.debug( + `Undid a move for puzzle ${puzzleId} (player ${playerId})`, + ); + + return { + state, + entry, + scorePenalty, + canUndo: history.cursor > 0, + canRedo: history.cursor < history.states.length - 1, + }; + } + + /** + * Re-applies the move that was most recently undone. + * + * @throws ForbiddenException when undo is disabled for the session mode. + * @throws BadRequestException when there is nothing left to redo. + */ + redo(puzzleId: string, playerId: string): MoveHistorySnapshot { + const history = this.requireHistory(puzzleId, playerId); + this.assertUndoAllowed(history); + + if (history.cursor >= history.states.length - 1) { + throw new BadRequestException('There is nothing left to redo'); + } + + const stateBefore = history.states[history.cursor]; + history.cursor += 1; + const state = history.states[history.cursor]; + + history.redoCount += 1; + const scorePenalty = this.getRedoPenalty(history.mode); + history.totalPenalty += scorePenalty; + + const entry: MoveHistoryEntry = { + sequence: history.entries.length + 1, + action: MoveHistoryAction.REDO, + move: null, + stateBefore, + stateAfter: state, + timestamp: new Date(), + undoCount: history.undoCount, + redoCount: history.redoCount, + scorePenalty, + }; + history.entries.push(entry); + + this.logger.debug( + `Redid a move for puzzle ${puzzleId} (player ${playerId})`, + ); + + return { + state, + entry, + scorePenalty, + canUndo: history.cursor > 0, + canRedo: history.cursor < history.states.length - 1, + }; + } + + // ─── Policy ────────────────────────────────────────────────────────────── + + /** The maximum number of steps that may be undone for a puzzle type. */ + getMaxUndoDepth(puzzleType: PuzzleType): number { + const perType = this.config.undoRedo.maxUndoDepthByPuzzleType?.[puzzleType]; + if (typeof perType === 'number') { + return perType; + } + return this.config.undoRedo.defaultMaxUndoDepth; + } + + /** False when undo is disabled entirely for the given mode. */ + isUndoAllowed(mode: GameMode): boolean { + if (!this.config.undoRedo.enabled) { + return false; + } + return !this.config.undoRedo.disabledModes.includes(mode); + } + + /** The score penalty charged for a single undo in the given mode. */ + getUndoPenalty(mode: GameMode): number { + if (!this.config.undoRedo.penalizedModes.includes(mode)) { + return 0; + } + return this.config.undoRedo.undoPenalty; + } + + /** The score penalty charged for a single redo in the given mode. */ + getRedoPenalty(mode: GameMode): number { + if (!this.config.undoRedo.penalizedModes.includes(mode)) { + return 0; + } + return this.config.undoRedo.redoPenalty; + } + + /** The effective rules that apply to a session. */ + getPolicy(puzzleId: string, playerId: string): UndoRedoPolicy { + const history = this.getHistory(puzzleId, playerId); + const mode = history?.mode ?? GameMode.PRACTICE; + const puzzleType = history?.puzzleType ?? PuzzleType.CUSTOM; + + return { + mode, + puzzleType, + maxUndoDepth: history?.maxUndoDepth ?? this.getMaxUndoDepth(puzzleType), + undoAllowed: this.isUndoAllowed(mode), + undoPenalty: this.getUndoPenalty(mode), + redoPenalty: this.getRedoPenalty(mode), + canUndo: (history?.cursor ?? -1) > 0, + canRedo: history ? history.cursor < history.states.length - 1 : false, + }; + } + + // ─── Replay integration ────────────────────────────────────────────────── + + /** + * Converts the move history of a session into replay actions. + * + * The shape is compatible with the replay module's action DTO, so a history + * can be handed to the replay system without either module depending on the + * other. + */ + toReplayActions(puzzleId: string, playerId: string): ReplayActionDraft[] { + const history = this.getHistory(puzzleId, playerId); + if (!history || history.entries.length === 0) { + return []; + } + + const start = history.entries[0].timestamp.getTime(); + + return history.entries.map((entry) => this.toReplayAction(entry, start)); + } + + /** + * Converts a single history entry into a replay action. + * + * @param entry the history entry to convert + * @param start the timestamp the replay starts at; defaults to the entry's + * own timestamp, which makes the relative timestamp zero. + */ + toReplayAction(entry: MoveHistoryEntry, start?: number): ReplayActionDraft { + const origin = start ?? entry.timestamp.getTime(); + + return { + actionType: this.toReplayActionType(entry.action), + timestamp: entry.timestamp.getTime() - origin, + actionData: { + sequence: entry.sequence, + moveId: entry.move?.id ?? null, + moveType: entry.move?.moveType ?? null, + moveData: entry.move?.moveData ?? {}, + isValid: entry.move?.isValid ?? null, + }, + stateBefore: entry.stateBefore + ? (entry.stateBefore.currentState as Record) + : undefined, + stateAfter: entry.stateAfter + ? (entry.stateAfter.currentState as Record) + : undefined, + metadata: { + undoCount: entry.undoCount, + redoCount: entry.redoCount, + scorePenalty: entry.scorePenalty, + }, + }; + } + + // ─── Internals ─────────────────────────────────────────────────────────── + + private key(puzzleId: string, playerId: string): string { + return `${puzzleId}:${playerId}`; + } + + private requireHistory(puzzleId: string, playerId: string): MoveHistory { + const history = this.getHistory(puzzleId, playerId); + if (!history) { + throw new NotFoundException( + `No move history for puzzle ${puzzleId} (player ${playerId})`, + ); + } + return history; + } + + private assertUndoAllowed(history: MoveHistory): void { + if (!this.isUndoAllowed(history.mode)) { + throw new ForbiddenException(`Undo is disabled in ${history.mode} mode`); + } + } + + /** + * Drops the oldest snapshots so that at most `maxUndoDepth` steps can be + * undone. This bounds both the undo depth and the memory held per session. + */ + private trimToUndoDepth(history: MoveHistory): void { + const maxStates = history.maxUndoDepth + 1; + if (history.states.length <= maxStates) { + return; + } + const excess = history.states.length - maxStates; + history.states.splice(0, excess); + history.cursor -= excess; + } + + private toReplayActionType( + action: MoveHistoryAction, + ): ReplayActionDraft['actionType'] { + switch (action) { + case MoveHistoryAction.UNDO: + return 'UNDO'; + case MoveHistoryAction.REDO: + return 'REDO'; + case MoveHistoryAction.RESET: + return 'STATE_CHANGE'; + default: + return 'MOVE'; + } + } +} From 06b6e325b16197e09039daeed2cd0d3b69ffc889 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:01:53 +0100 Subject: [PATCH 04/21] feat(game-engine): register the move history service Provides and exports MoveHistoryService from the game engine module. --- src/game-engine/game-engine.module.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/game-engine/game-engine.module.ts b/src/game-engine/game-engine.module.ts index e09aa038..5f966125 100644 --- a/src/game-engine/game-engine.module.ts +++ b/src/game-engine/game-engine.module.ts @@ -15,15 +15,18 @@ import { PuzzleGeneratorService } from './services/puzzle-generator.service'; import { PuzzleRegistryService } from './services/puzzle-registry.service'; import { ScoringService } from './services/scoring.service'; import { AchievementsService } from './services/achievements.service'; +import { MoveHistoryService } from './services/move-history.service'; import { PuzzleController } from './controllers/puzzle.controller'; import { GameStateController } from './controllers/game-state.controller'; import { AnalyticsController } from './controllers/analytics.controller'; +import { MoveHistoryController } from './controllers/move-history.controller'; import { PuzzleState } from './entities/puzzle-state.entity'; import { PlayerProgress } from './entities/player-progress.entity'; import { GameSession } from './entities/game-session.entity'; import { PuzzleAnalytics } from './entities/puzzle-analytics.entity'; import { gameEngineConfig } from './config/game-engine.config'; import { EnergyModule } from '../energy/energy.module'; +import { ReplayModule } from '../replay/replay.module'; @Global() @Module({ @@ -36,6 +39,7 @@ import { EnergyModule } from '../energy/energy.module'; PuzzleAnalytics, ]), EnergyModule, + ReplayModule, ], providers: [ PuzzleEngineService, @@ -52,8 +56,14 @@ import { EnergyModule } from '../energy/energy.module'; PuzzleRegistryService, ScoringService, AchievementsService, + MoveHistoryService, + ], + controllers: [ + PuzzleController, + GameStateController, + AnalyticsController, + MoveHistoryController, ], - controllers: [PuzzleController, GameStateController, AnalyticsController], exports: [ PuzzleEngineService, StateManagementService, @@ -69,6 +79,7 @@ import { EnergyModule } from '../energy/energy.module'; PuzzleRegistryService, ScoringService, AchievementsService, + MoveHistoryService, ], }) export class GameEngineModule {} From 1472e534867de07dc6c55d067b58226cf9c09784 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:01:53 +0100 Subject: [PATCH 05/21] feat(replay): add the REDO replay action type A redo is a first-class replay action, just like an undo. --- src/replay/entities/replay-action.entity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/replay/entities/replay-action.entity.ts b/src/replay/entities/replay-action.entity.ts index 9936720e..6eee65a4 100644 --- a/src/replay/entities/replay-action.entity.ts +++ b/src/replay/entities/replay-action.entity.ts @@ -40,6 +40,7 @@ export class ReplayAction { | 'HINT_USED' | 'STATE_CHANGE' | 'UNDO' + | 'REDO' | 'SUBMISSION' | 'PAUSE' | 'RESUME'; From 9c2749508a1bb87c9728ebb5b76db49efa7d3594 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:01:54 +0100 Subject: [PATCH 06/21] feat(replay): accept the REDO action type in the replay DTO Keeps the DTO in step with the entity's action types. --- src/replay/dto/create-replay.dto.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/replay/dto/create-replay.dto.ts b/src/replay/dto/create-replay.dto.ts index b00ec972..d216dfc7 100644 --- a/src/replay/dto/create-replay.dto.ts +++ b/src/replay/dto/create-replay.dto.ts @@ -16,6 +16,7 @@ export class RecordActionDto { | 'HINT_USED' | 'STATE_CHANGE' | 'UNDO' + | 'REDO' | 'SUBMISSION' | 'PAUSE' | 'RESUME'; From dd82b459409e3eed186a71a60809b1a82b5fb4e6 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:01:54 +0100 Subject: [PATCH 07/21] feat(replay): add bulk action recording Adds recordActions so a whole move history can be imported into a replay in one go. --- src/replay/services/replay.service.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/replay/services/replay.service.ts b/src/replay/services/replay.service.ts index 080ed1b6..397b8c19 100644 --- a/src/replay/services/replay.service.ts +++ b/src/replay/services/replay.service.ts @@ -114,6 +114,30 @@ export class ReplayService { return this.actionRepo.save(action); } + /** + * Record a batch of actions against a replay, in order. + * + * Used to import a whole move history in one go, e.g. when a session's + * history is turned into a replay after the puzzle is finished. + */ + async recordActions( + replayId: string, + actions: Array & { timestamp?: number }>, + ): Promise { + const recorded: ReplayAction[] = []; + + for (const action of actions) { + recorded.push( + await this.recordAction(replayId, { + ...action, + timestamp: action.timestamp ?? 0, + }), + ); + } + + return recorded; + } + /** * Update replay metrics based on recorded action */ From 6e4b28890e36bdcfb8248d3fc74b7b83e455382d Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:08 +0100 Subject: [PATCH 08/21] feat(game-engine): record moves and restore state on undo/redo Wires the move history into the puzzle engine. - creating a puzzle starts a move history session with the puzzle type and the session mode, so the per-type undo depth applies from the first move - makeMove records the move and persists the resulting state - undoMove and redoMove restore the exact prior state and persist it - resetPuzzle clears the history --- .../services/puzzle-engine.service.ts | 97 ++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src/game-engine/services/puzzle-engine.service.ts b/src/game-engine/services/puzzle-engine.service.ts index df7dd7e3..003d6785 100644 --- a/src/game-engine/services/puzzle-engine.service.ts +++ b/src/game-engine/services/puzzle-engine.service.ts @@ -2,8 +2,17 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { ClientProxy } from '@nestjs/microservices'; import { gameEngineConfig } from '../config/game-engine.config'; import type { ConfigType } from '@nestjs/config'; -import { PuzzleType, DifficultyLevel, PuzzleMove } from '../types/puzzle.types'; +import { + PuzzleType, + DifficultyLevel, + PuzzleMove, + PuzzleStatus, +} from '../types/puzzle.types'; +import { GameMode } from '../types/move-history.types'; +import type { MoveHistorySnapshot } from '../types/move-history.types'; import { EnergyService } from '../../energy/energy.service'; +import { MoveHistoryService } from './move-history.service'; +import { StateManagementService } from './state-management.service'; @Injectable() export class PuzzleEngineService { @@ -13,6 +22,8 @@ export class PuzzleEngineService { @Inject(gameEngineConfig.KEY) private readonly config: ConfigType, private readonly energyService: EnergyService, + private readonly moveHistory: MoveHistoryService, + private readonly stateManagement: StateManagementService, ) {} async createPuzzle( @@ -20,6 +31,7 @@ export class PuzzleEngineService { playerId: string, difficulty?: DifficultyLevel, config?: any, + mode?: GameMode, ): Promise { // Calculate energy cost based on puzzle type and difficulty const energyCost = this.calculateEnergyCost(type, difficulty); @@ -46,6 +58,27 @@ export class PuzzleEngineService { // Update the energy transaction with the actual puzzle ID // This would typically be done through a proper transaction system + // Start the move history for this session so undo/redo and the replay + // history are available from the very first move. + this.moveHistory.startSession(puzzleId, playerId, { + puzzleType: type, + mode, + initialState: { + puzzleId, + playerId, + status: PuzzleStatus.IN_PROGRESS, + currentState: { + type, + difficulty: difficulty ?? DifficultyLevel.MEDIUM, + }, + moves: [], + startTime: new Date(), + score: 0, + hintsUsed: 0, + metadata: {}, + }, + }); + return { id: puzzleId, type, @@ -54,6 +87,7 @@ export class PuzzleEngineService { config, energyCost, remainingEnergy: energyResult.currentEnergy, + undoPolicy: this.moveHistory.getPolicy(puzzleId, playerId), }; } @@ -117,7 +151,65 @@ export class PuzzleEngineService { playerId: string, move: PuzzleMove, ): Promise { - return { puzzleId, playerId, move, success: true }; + const entry = this.moveHistory.recordMove(puzzleId, playerId, move); + + let moveCount = 0; + const history = this.moveHistory.getHistory(puzzleId, playerId); + if (history) { + const current = history.states[history.cursor]; + if (current) { + moveCount = current.moves.length; + await this.stateManagement.saveState(current); + } + } + + return { + puzzleId, + playerId, + move, + success: true, + sequence: entry.sequence, + moveCount, + }; + } + + /** + * Undoes the most recent move and persists the restored state. + */ + async undoMove( + puzzleId: string, + playerId: string, + ): Promise { + const result = this.moveHistory.undo(puzzleId, playerId); + await this.stateManagement.saveState(result.state); + return result; + } + + /** + * Redoes the most recently undone move and persists the restored state. + */ + async redoMove( + puzzleId: string, + playerId: string, + ): Promise { + const result = this.moveHistory.redo(puzzleId, playerId); + await this.stateManagement.saveState(result.state); + return result; + } + + /** + * The move history of a session, together with the rules that apply to it. + */ + getMoveHistory(puzzleId: string, playerId: string) { + const history = this.moveHistory.getHistory(puzzleId, playerId); + + return { + entries: history?.entries ?? [], + undoCount: history?.undoCount ?? 0, + redoCount: history?.redoCount ?? 0, + totalPenalty: history?.totalPenalty ?? 0, + policy: this.moveHistory.getPolicy(puzzleId, playerId), + }; } async validateSolution(puzzleId: string, solution: any): Promise { @@ -130,6 +222,7 @@ export class PuzzleEngineService { async resetPuzzle(puzzleId: string, playerId: string): Promise { // Reset puzzle state + this.moveHistory.clearSession(puzzleId, playerId); } async abandonPuzzle(puzzleId: string, playerId: string): Promise { From 751968f40ad42d6ea9f5d8c54051f2817be2f3f7 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:08 +0100 Subject: [PATCH 09/21] feat(game-engine): add undo/redo request DTOs The session mode and puzzle type are fixed when the puzzle is created, so they are deliberately not accepted on an undo request - otherwise a client could downgrade a ranked session to dodge the undo restrictions. --- src/game-engine/dto/move-history.dto.ts | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/game-engine/dto/move-history.dto.ts diff --git a/src/game-engine/dto/move-history.dto.ts b/src/game-engine/dto/move-history.dto.ts new file mode 100644 index 00000000..5672d651 --- /dev/null +++ b/src/game-engine/dto/move-history.dto.ts @@ -0,0 +1,29 @@ +import { IsOptional, IsString } from 'class-validator'; + +/** + * Body for an undo or redo request. + * + * The session mode and the puzzle type are fixed when the puzzle is created, + * so they are deliberately not accepted here — otherwise a client could + * downgrade a ranked session to dodge the undo restrictions. + */ +export class UndoRedoDto { + /** + * Optional replay to mirror the undo/redo action onto, so the replay shows + * exactly what the player did. + */ + @IsOptional() + @IsString() + replayId?: string; +} + +/** Query parameters for reading a session's move history. */ +export class MoveHistoryQueryDto { + /** + * Set to `true` to include the replay actions derived from the history, so + * they can be imported straight into the replay system. + */ + @IsOptional() + @IsString() + includeReplayActions?: string; +} From bd908b7ff8e13cf474e7e619ea08960622c24376 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:08 +0100 Subject: [PATCH 10/21] feat(game-engine): add the undo, redo and move history endpoints Adds three endpoints: - POST /puzzles/:puzzleId/player/:playerId/undo - POST /puzzles/:puzzleId/player/:playerId/redo - GET /puzzles/:puzzleId/player/:playerId/move-history The undo and redo endpoints accept an optional replayId and mirror the action onto that replay, so the replay shows what the player actually did. --- .../controllers/move-history.controller.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/game-engine/controllers/move-history.controller.ts diff --git a/src/game-engine/controllers/move-history.controller.ts b/src/game-engine/controllers/move-history.controller.ts new file mode 100644 index 00000000..4edd465e --- /dev/null +++ b/src/game-engine/controllers/move-history.controller.ts @@ -0,0 +1,118 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ReplayService } from '../../replay/services/replay.service'; +import { MoveHistoryQueryDto, UndoRedoDto } from '../dto/move-history.dto'; +import { MoveHistoryService } from '../services/move-history.service'; +import { PuzzleEngineService } from '../services/puzzle-engine.service'; +import type { MoveHistoryEntry } from '../types/move-history.types'; + +/** + * Exposes the move history of a puzzle session and the undo/redo actions + * that operate on it. + */ +@Controller('puzzles') +export class MoveHistoryController { + constructor( + private readonly puzzleEngine: PuzzleEngineService, + private readonly moveHistory: MoveHistoryService, + private readonly replayService: ReplayService, + ) {} + + /** + * Restores the state that preceded the most recent move. + * + * Ranked sessions reject the request outright; sessions in a penalised mode + * accept it but return the score penalty that was charged. + */ + @Post(':puzzleId/player/:playerId/undo') + async undo( + @Param('puzzleId') puzzleId: string, + @Param('playerId') playerId: string, + @Body() dto: UndoRedoDto, + ) { + const result = await this.puzzleEngine.undoMove(puzzleId, playerId); + await this.mirrorToReplay(dto?.replayId, result.entry); + + return { + undone: true, + state: result.state, + entry: result.entry, + scorePenalty: result.scorePenalty, + policy: this.moveHistory.getPolicy(puzzleId, playerId), + }; + } + + /** + * Re-applies the move that was most recently undone. + */ + @Post(':puzzleId/player/:playerId/redo') + async redo( + @Param('puzzleId') puzzleId: string, + @Param('playerId') playerId: string, + @Body() dto: UndoRedoDto, + ) { + const result = await this.puzzleEngine.redoMove(puzzleId, playerId); + await this.mirrorToReplay(dto?.replayId, result.entry); + + return { + redone: true, + state: result.state, + entry: result.entry, + scorePenalty: result.scorePenalty, + policy: this.moveHistory.getPolicy(puzzleId, playerId), + }; + } + + /** + * The ordered move history of a session, ready to be replayed. + */ + @Get(':puzzleId/player/:playerId/move-history') + async getMoveHistory( + @Param('puzzleId') puzzleId: string, + @Param('playerId') playerId: string, + @Query() query: MoveHistoryQueryDto, + ) { + const history = this.moveHistory.getHistory(puzzleId, playerId); + + const response: Record = { + puzzleId, + playerId, + entries: history?.entries ?? [], + undoCount: history?.undoCount ?? 0, + redoCount: history?.redoCount ?? 0, + totalPenalty: history?.totalPenalty ?? 0, + policy: this.moveHistory.getPolicy(puzzleId, playerId), + }; + + if (query?.includeReplayActions === 'true') { + response.replayActions = this.moveHistory.toReplayActions( + puzzleId, + playerId, + ); + } + + return response; + } + + /** + * Mirrors an undo/redo action onto a replay, so the replay shows exactly + * what the player did. + */ + private async mirrorToReplay( + replayId: string | undefined, + entry: MoveHistoryEntry, + ): Promise { + if (!replayId) { + return; + } + + const draft = this.moveHistory.toReplayAction(entry); + await this.replayService.recordAction(replayId, { + actionType: draft.actionType, + timestamp: draft.timestamp, + actionData: draft.actionData, + stateBefore: draft.stateBefore, + stateAfter: draft.stateAfter, + metadata: draft.metadata, + }); + } +} From 042d125a28bbec5fbb68ebc0c3702393d54554f6 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:09 +0100 Subject: [PATCH 11/21] feat(game-engine): accept the session mode when creating a puzzle The mode decides whether undo is free, penalised or disabled, so it is fixed when the puzzle is created. --- src/game-engine/controllers/puzzle.controller.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/game-engine/controllers/puzzle.controller.ts b/src/game-engine/controllers/puzzle.controller.ts index 9a7049fc..cfa58837 100644 --- a/src/game-engine/controllers/puzzle.controller.ts +++ b/src/game-engine/controllers/puzzle.controller.ts @@ -3,6 +3,7 @@ import type { PuzzleEngineService } from '../services/puzzle-engine.service'; import type { HintSystemService } from '../services/hint-system.service'; import type { DifficultyScalingService } from '../services/difficulty-scaling.service'; import { PuzzleType, PuzzleMove, DifficultyLevel } from '../types/puzzle.types'; +import { GameMode } from '../types/move-history.types'; import { AntiCheatGuard } from '../../anti-cheat/guards/anti-cheat.guard'; @Controller('puzzles') @@ -21,6 +22,11 @@ export class PuzzleController { playerId: string; difficulty?: string; config?: any; + /** + * Game mode the session runs in. Ranked sessions do not allow undo; + * challenge and timed sessions charge a score penalty for it. + */ + mode?: GameMode; }, ) { const difficulty = @@ -32,6 +38,7 @@ export class PuzzleController { createPuzzleDto.playerId, difficulty, createPuzzleDto.config, + createPuzzleDto.mode, ); } From 303b67ad77cd1c34dce3912a3aace948d3c961bd Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:10 +0100 Subject: [PATCH 12/21] feat(game-engine): limit the puzzle undo stack per puzzle type The in-memory undo stack used a hardcoded limit of 50 steps. It now uses the same per-puzzle-type depth as the session move history, so complex puzzles keep a shallower history. --- src/game-engine/implementations/base-puzzle.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/game-engine/implementations/base-puzzle.ts b/src/game-engine/implementations/base-puzzle.ts index 2bfdf922..57ae6782 100644 --- a/src/game-engine/implementations/base-puzzle.ts +++ b/src/game-engine/implementations/base-puzzle.ts @@ -12,6 +12,10 @@ import type { PuzzleHint, ValidationResult, } from '../types/puzzle.types'; +import { + DEFAULT_UNDO_DEPTH, + DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE, +} from '../types/move-history.types'; /** * Abstract base class for all puzzle implementations @@ -141,14 +145,23 @@ export abstract class BasePuzzle implements IPuzzle { // Clear redo stack when new move is made this.redoStack = []; - // Limit undo stack size - const maxUndoSteps = 50; + // Limit undo stack size to the depth allowed for this puzzle type. + const maxUndoSteps = this.getMaxUndoSteps(); if (this.undoStack.length > maxUndoSteps) { this.undoStack.shift(); } } } + /** + * The number of undo steps retained for this puzzle. + * + * Puzzle types with expensive state snapshots keep a shallower history. + */ + protected getMaxUndoSteps(): number { + return DEFAULT_UNDO_DEPTH_BY_PUZZLE_TYPE[this.type] ?? DEFAULT_UNDO_DEPTH; + } + // Helper methods for scoring protected calculateBaseScore(): number { if (!this.gameState) return 0; From b78c1debf08f22827be5cb365966d63d70d216db Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:34 +0100 Subject: [PATCH 13/21] feat(game-engine): support the ranked session mode Ranked sessions have undo disabled, so the mode has to be expressible in the session configuration. --- src/game-engine/entities/game-session.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game-engine/entities/game-session.entity.ts b/src/game-engine/entities/game-session.entity.ts index ccdf6abf..e4e15bce 100644 --- a/src/game-engine/entities/game-session.entity.ts +++ b/src/game-engine/entities/game-session.entity.ts @@ -120,7 +120,7 @@ export class GameSession { // Session configuration and context @Column({ type: 'jsonb', default: {} }) sessionConfig: { - gameMode?: 'practice' | 'challenge' | 'timed' | 'zen'; + gameMode?: 'practice' | 'challenge' | 'timed' | 'zen' | 'ranked'; difficultyFilter?: string[]; categoryFilter?: string[]; timeLimit?: number; From 03e7c3de56f43e7ffd8b6b3abb55722f1b660da3 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:34 +0100 Subject: [PATCH 14/21] feat(game-engine): report undo and redo counts in the performance metrics Adds the number of undos and redos to the performance metrics, together with the mode the session was played in. --- src/game-engine/interfaces/puzzle.interfaces.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/game-engine/interfaces/puzzle.interfaces.ts b/src/game-engine/interfaces/puzzle.interfaces.ts index b45ed50c..e8db5dfa 100644 --- a/src/game-engine/interfaces/puzzle.interfaces.ts +++ b/src/game-engine/interfaces/puzzle.interfaces.ts @@ -7,6 +7,7 @@ import type { ValidationResult, CauseEffectResult, } from '../types/puzzle.types'; +import type { GameMode } from '../types/move-history.types'; export interface IPuzzle { id: string; @@ -131,4 +132,10 @@ export interface PerformanceMetrics { hintsUsed: number; score: number; timestamp: Date; + /** Number of moves that were undone during the session. */ + undosUsed?: number; + /** Number of undone moves that were re-applied. */ + redosUsed?: number; + /** The mode the session was played in; decides whether undo is penalised. */ + mode?: GameMode; } From b75abbc62b8c031f0149720784644991f0405f07 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:35 +0100 Subject: [PATCH 15/21] feat(game-engine): charge a score penalty for undo in penalised modes Undo is free in practice and zen sessions, but competitive modes charge a ratio of the base score for every undo, in the same way hints do. The penalty is reported in the score breakdown. --- src/game-engine/services/scoring.service.ts | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/game-engine/services/scoring.service.ts b/src/game-engine/services/scoring.service.ts index 06734fda..f0dbe3d0 100644 --- a/src/game-engine/services/scoring.service.ts +++ b/src/game-engine/services/scoring.service.ts @@ -5,6 +5,7 @@ import type { PerformanceMetrics, } from '../interfaces/puzzle.interfaces'; import { PuzzleType, DifficultyLevel } from '../types/puzzle.types'; +import { GameMode } from '../types/move-history.types'; import { gameEngineConfig } from '../config/game-engine.config'; interface ScoreCalculationResult { @@ -14,6 +15,7 @@ interface ScoreCalculationResult { difficultyMultiplier: number; streakBonus: number; hintsUsedPenalty: number; + undoPenalty: number; finalScore: number; breakdown: Record; } @@ -73,10 +75,13 @@ export class ScoringService { baseScore, ); + const undoPenalty = this.calculateUndoPenalty(performance, baseScore); + // Calculate final score let finalScore = baseScore * difficultyMultiplier; finalScore += timeBonus + efficiencyBonus + streakBonus; finalScore -= hintsUsedPenalty; + finalScore -= undoPenalty; // Apply minimum score threshold finalScore = Math.max(finalScore, baseScore * 0.1); @@ -88,6 +93,7 @@ export class ScoringService { difficultyMultiplier, streakBonus, hintsUsedPenalty, + undoPenalty, finalScore: Math.round(finalScore), breakdown: { base: baseScore, @@ -96,6 +102,7 @@ export class ScoringService { difficulty: difficultyMultiplier, streak: streakBonus, hints: -hintsUsedPenalty, + undo: -undoPenalty, }, }; @@ -269,6 +276,29 @@ export class ScoringService { return Math.round(baseScore * penaltyRatio); } + /** + * Penalty for undoing moves. + * + * Undo is free in practice and zen sessions, but competitive modes charge a + * ratio of the base score for every undo, in the same way hints do. + */ + private calculateUndoPenalty( + performance: PerformanceMetrics, + baseScore: number, + ): number { + const undosUsed = performance.undosUsed ?? 0; + if (undosUsed === 0) return 0; + + const undoRedo = this.config.undoRedo; + if (!undoRedo) return 0; + + const mode = performance.mode ?? GameMode.PRACTICE; + if (!undoRedo.penalizedModes.includes(mode)) return 0; + + const penaltyRatio = undoRedo.undoPenalty * undosUsed; + return Math.round(baseScore * penaltyRatio); + } + private calculateExperiencePoints( scoreResult: ScoreCalculationResult, difficulty: DifficultyLevel, From 86826e37ff72fcaa11cce305b60b602badb6b77f Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:35 +0100 Subject: [PATCH 16/21] test(game-engine): cover the move history, undo/redo and the ranked mode rules Covers the happy path, the per-puzzle-type undo depth, the ranked mode restrictions, the penalised modes, the redo branch and the replay conversion. --- .../services/move-history.service.spec.ts | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 src/game-engine/services/move-history.service.spec.ts diff --git a/src/game-engine/services/move-history.service.spec.ts b/src/game-engine/services/move-history.service.spec.ts new file mode 100644 index 00000000..b8d7ab3e --- /dev/null +++ b/src/game-engine/services/move-history.service.spec.ts @@ -0,0 +1,442 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import type { PuzzleGameState } from '../interfaces/puzzle.interfaces'; +import { GameMode, MoveHistoryAction } from '../types/move-history.types'; +import { + DifficultyLevel, + PuzzleMove, + PuzzleStatus, + PuzzleType, +} from '../types/puzzle.types'; +import { MoveHistoryService } from './move-history.service'; + +const buildConfig = (overrides: Record = {}) => ({ + undoRedo: { + enabled: true, + defaultMaxUndoDepth: 10, + maxUndoDepthByPuzzleType: { + [PuzzleType.LOGIC_GRID]: 5, + [PuzzleType.WORD_PUZZLE]: 20, + }, + disabledModes: [GameMode.RANKED], + penalizedModes: [GameMode.CHALLENGE, GameMode.TIMED], + undoPenalty: 0.05, + redoPenalty: 0.02, + ...overrides, + }, +}); + +const buildState = (step: string, moves: PuzzleMove[] = []): PuzzleGameState => + ({ + puzzleId: 'puzzle-1', + playerId: 'player-1', + status: PuzzleStatus.IN_PROGRESS, + currentState: { + type: PuzzleType.LOGIC_GRID, + difficulty: DifficultyLevel.MEDIUM, + step, + }, + moves, + startTime: new Date('2024-01-01T00:00:00.000Z'), + score: 100, + hintsUsed: 0, + metadata: {}, + } as PuzzleGameState); + +const buildMove = (id: string): PuzzleMove => ({ + id, + timestamp: new Date('2024-01-01T00:00:01.000Z'), + playerId: 'player-1', + puzzleId: 'puzzle-1', + moveType: 'place', + moveData: { cell: id }, + isValid: true, +}); + +describe('MoveHistoryService', () => { + let service: MoveHistoryService; + let config: ReturnType; + + beforeEach(() => { + config = buildConfig(); + service = new MoveHistoryService(config as never); + }); + + describe('session lifecycle', () => { + it('creates a history when a session starts', () => { + const history = service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + }); + + expect(history.puzzleId).toBe('puzzle-1'); + expect(history.playerId).toBe('player-1'); + expect(history.puzzleType).toBe(PuzzleType.LOGIC_GRID); + expect(history.mode).toBe(GameMode.PRACTICE); + expect(history.entries).toHaveLength(0); + }); + + it('keeps the recorded history when a session is restarted', () => { + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + }); + service.recordMove('puzzle-1', 'player-1', buildMove('m1'), { + stateAfter: buildState('1'), + }); + + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + }); + + expect(service.getEntries('puzzle-1', 'player-1')).toHaveLength(1); + }); + + it('returns no history for an unknown session', () => { + expect(service.getHistory('nope', 'nobody')).toBeUndefined(); + expect(service.getEntries('nope', 'nobody')).toEqual([]); + }); + + it('clears a session', () => { + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + }); + + expect(service.clearSession('puzzle-1', 'player-1')).toBe(true); + expect(service.getHistory('puzzle-1', 'player-1')).toBeUndefined(); + }); + }); + + describe('recording moves', () => { + it('appends an entry and advances the cursor', () => { + const s0 = buildState('0'); + const s1 = buildState('1', [buildMove('m1')]); + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + initialState: s0, + }); + + const entry = service.recordMove( + 'puzzle-1', + 'player-1', + buildMove('m1'), + { + stateBefore: s0, + stateAfter: s1, + }, + ); + + expect(entry.action).toBe(MoveHistoryAction.MOVE); + expect(entry.sequence).toBe(1); + expect(entry.stateBefore).toBe(s0); + expect(entry.stateAfter).toBe(s1); + expect(service.getHistory('puzzle-1', 'player-1').cursor).toBe(1); + }); + + it('derives the resulting state from the previous one when omitted', () => { + const s0 = buildState('0'); + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + initialState: s0, + }); + + const entry = service.recordMove('puzzle-1', 'player-1', buildMove('m1')); + + expect(entry.stateAfter.moves).toHaveLength(1); + expect(entry.stateAfter.moves[0].id).toBe('m1'); + }); + }); + + describe('undo and redo', () => { + const playThreeMoves = () => { + const s0 = buildState('0'); + const s1 = buildState('1', [buildMove('m1')]); + const s2 = buildState('2', [buildMove('m1'), buildMove('m2')]); + const s3 = buildState('3', [ + buildMove('m1'), + buildMove('m2'), + buildMove('m3'), + ]); + + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + initialState: s0, + }); + service.recordMove('puzzle-1', 'player-1', buildMove('m1'), { + stateBefore: s0, + stateAfter: s1, + }); + service.recordMove('puzzle-1', 'player-1', buildMove('m2'), { + stateBefore: s1, + stateAfter: s2, + }); + service.recordMove('puzzle-1', 'player-1', buildMove('m3'), { + stateBefore: s2, + stateAfter: s3, + }); + + return { s0, s1, s2, s3 }; + }; + + it('restores the exact prior state on undo', () => { + const { s2 } = playThreeMoves(); + + const result = service.undo('puzzle-1', 'player-1'); + + expect(result.state).toBe(s2); + expect(result.state.currentState.step).toBe('2'); + expect(result.entry.action).toBe(MoveHistoryAction.UNDO); + expect(result.canUndo).toBe(true); + expect(result.canRedo).toBe(true); + }); + + it('re-applies the undone move on redo', () => { + const { s3 } = playThreeMoves(); + service.undo('puzzle-1', 'player-1'); + + const result = service.redo('puzzle-1', 'player-1'); + + expect(result.state).toBe(s3); + expect(result.state.currentState.step).toBe('3'); + expect(result.entry.action).toBe(MoveHistoryAction.REDO); + }); + + it('undoes and redoes repeatedly to the same state', () => { + const { s0, s1, s2 } = playThreeMoves(); + + expect(service.undo('puzzle-1', 'player-1').state).toBe(s2); + expect(service.undo('puzzle-1', 'player-1').state).toBe(s1); + expect(service.undo('puzzle-1', 'player-1').state).toBe(s0); + + expect(service.redo('puzzle-1', 'player-1').state).toBe(s1); + expect(service.redo('puzzle-1', 'player-1').state).toBe(s2); + }); + + it('rejects an undo when there is nothing left to undo', () => { + const s0 = buildState('0'); + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + initialState: s0, + }); + + expect(() => service.undo('puzzle-1', 'player-1')).toThrow( + BadRequestException, + ); + }); + + it('rejects a redo when there is nothing left to redo', () => { + playThreeMoves(); + + expect(() => service.redo('puzzle-1', 'player-1')).toThrow( + BadRequestException, + ); + }); + + it('discards the redo branch when a new move is made', () => { + const { s1 } = playThreeMoves(); + service.undo('puzzle-1', 'player-1'); + + service.recordMove('puzzle-1', 'player-1', buildMove('m4'), { + stateBefore: s1, + stateAfter: buildState('4'), + }); + + expect(() => service.redo('puzzle-1', 'player-1')).toThrow( + BadRequestException, + ); + }); + + it('records undo and redo in the history', () => { + playThreeMoves(); + service.undo('puzzle-1', 'player-1'); + service.redo('puzzle-1', 'player-1'); + + const actions = service + .getEntries('puzzle-1', 'player-1') + .map((entry) => entry.action); + + expect(actions).toEqual([ + MoveHistoryAction.MOVE, + MoveHistoryAction.MOVE, + MoveHistoryAction.MOVE, + MoveHistoryAction.UNDO, + MoveHistoryAction.REDO, + ]); + }); + + it('rejects an undo for a session that has no history', () => { + expect(() => service.undo('puzzle-1', 'nobody')).toThrow(); + }); + }); + + describe('undo depth per puzzle type', () => { + it('uses the configured depth for each puzzle type', () => { + expect(service.getMaxUndoDepth(PuzzleType.LOGIC_GRID)).toBe(5); + expect(service.getMaxUndoDepth(PuzzleType.WORD_PUZZLE)).toBe(20); + expect(service.getMaxUndoDepth(PuzzleType.CUSTOM)).toBe(10); + }); + + it('does not allow undoing beyond the per-type depth', () => { + const s0 = buildState('0'); + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + initialState: s0, + }); + + for (let i = 1; i <= 8; i++) { + service.recordMove('puzzle-1', 'player-1', buildMove(`m${i}`), { + stateAfter: buildState(String(i)), + }); + } + + // A logic grid keeps a depth of 5, so exactly five undos are possible. + for (let i = 0; i < 5; i++) { + expect(() => service.undo('puzzle-1', 'player-1')).not.toThrow(); + } + expect(() => service.undo('puzzle-1', 'player-1')).toThrow( + BadRequestException, + ); + }); + + it('bounds the number of retained state snapshots', () => { + const s0 = buildState('0'); + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + initialState: s0, + }); + + for (let i = 1; i <= 8; i++) { + service.recordMove('puzzle-1', 'player-1', buildMove(`m${i}`), { + stateAfter: buildState(String(i)), + }); + } + + const history = service.getHistory('puzzle-1', 'player-1'); + expect(history.states.length).toBe(6); // maxUndoDepth + current state + }); + }); + + describe('ranked mode rules', () => { + const playOneMove = (mode: GameMode) => { + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + mode, + initialState: buildState('0'), + }); + service.recordMove('puzzle-1', 'player-1', buildMove('m1'), { + stateAfter: buildState('1'), + }); + }; + + it('disables undo in ranked mode', () => { + playOneMove(GameMode.RANKED); + + expect(service.isUndoAllowed(GameMode.RANKED)).toBe(false); + expect(() => service.undo('puzzle-1', 'player-1')).toThrow( + ForbiddenException, + ); + }); + + it('disables redo in ranked mode', () => { + playOneMove(GameMode.RANKED); + + expect(() => service.redo('puzzle-1', 'player-1')).toThrow( + ForbiddenException, + ); + }); + + it('allows undo in practice mode', () => { + playOneMove(GameMode.PRACTICE); + + expect(() => service.undo('puzzle-1', 'player-1')).not.toThrow(); + }); + + it('charges a score penalty for undo in a penalised mode', () => { + playOneMove(GameMode.CHALLENGE); + + const result = service.undo('puzzle-1', 'player-1'); + + expect(result.scorePenalty).toBe(0.05); + expect(result.entry.scorePenalty).toBe(0.05); + expect(service.getHistory('puzzle-1', 'player-1').totalPenalty).toBe( + 0.05, + ); + }); + + it('charges a score penalty for redo in a penalised mode', () => { + playOneMove(GameMode.TIMED); + service.undo('puzzle-1', 'player-1'); + + const result = service.redo('puzzle-1', 'player-1'); + + expect(result.scorePenalty).toBe(0.02); + }); + + it('does not charge a penalty in practice mode', () => { + playOneMove(GameMode.PRACTICE); + + const result = service.undo('puzzle-1', 'player-1'); + + expect(result.scorePenalty).toBe(0); + expect(service.getHistory('puzzle-1', 'player-1').totalPenalty).toBe(0); + }); + + it('disables undo everywhere when the feature is turned off', () => { + service = new MoveHistoryService( + buildConfig({ enabled: false }) as never, + ); + playOneMove(GameMode.PRACTICE); + + expect(() => service.undo('puzzle-1', 'player-1')).toThrow( + ForbiddenException, + ); + }); + }); + + describe('policy reporting', () => { + it('reports the effective policy for a session', () => { + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + mode: GameMode.CHALLENGE, + initialState: buildState('0'), + }); + service.recordMove('puzzle-1', 'player-1', buildMove('m1'), { + stateAfter: buildState('1'), + }); + + const policy = service.getPolicy('puzzle-1', 'player-1'); + + expect(policy.mode).toBe(GameMode.CHALLENGE); + expect(policy.maxUndoDepth).toBe(5); + expect(policy.undoAllowed).toBe(true); + expect(policy.undoPenalty).toBe(0.05); + expect(policy.canUndo).toBe(true); + expect(policy.canRedo).toBe(false); + }); + }); + + describe('replay integration', () => { + it('converts the history into replay actions', () => { + const s0 = buildState('0'); + service.startSession('puzzle-1', 'player-1', { + puzzleType: PuzzleType.LOGIC_GRID, + initialState: s0, + }); + service.recordMove('puzzle-1', 'player-1', buildMove('m1'), { + stateAfter: buildState('1'), + }); + service.undo('puzzle-1', 'player-1'); + + const actions = service.toReplayActions('puzzle-1', 'player-1'); + + expect(actions).toHaveLength(2); + expect(actions[0].actionType).toBe('MOVE'); + expect(actions[0].actionData.moveId).toBe('m1'); + expect(actions[1].actionType).toBe('UNDO'); + expect(actions[1].metadata.undoCount).toBe(1); + expect(actions[0].timestamp).toBeGreaterThanOrEqual(0); + expect(actions[1].timestamp).toBeGreaterThanOrEqual(actions[0].timestamp); + }); + + it('returns no actions for an unknown session', () => { + expect(service.toReplayActions('nope', 'nobody')).toEqual([]); + }); + }); +}); From 0ee387d33bc2de49c530d4f4d3cc839cb5dfda5c Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:36 +0100 Subject: [PATCH 17/21] test(game-engine): cover the undo, redo and move history endpoints Covers the responses, the replay mirroring and the propagation of a rejected undo. --- .../move-history.controller.spec.ts | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/game-engine/controllers/move-history.controller.spec.ts diff --git a/src/game-engine/controllers/move-history.controller.spec.ts b/src/game-engine/controllers/move-history.controller.spec.ts new file mode 100644 index 00000000..8dcee844 --- /dev/null +++ b/src/game-engine/controllers/move-history.controller.spec.ts @@ -0,0 +1,172 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ReplayService } from '../../replay/services/replay.service'; +import { GameMode } from '../types/move-history.types'; +import { MoveHistoryService } from '../services/move-history.service'; +import { PuzzleEngineService } from '../services/puzzle-engine.service'; +import { MoveHistoryController } from './move-history.controller'; + +describe('MoveHistoryController', () => { + let controller: MoveHistoryController; + let testingModule: TestingModule; + let puzzleEngine: { undoMove: jest.Mock; redoMove: jest.Mock }; + let moveHistory: { + getPolicy: jest.Mock; + getHistory: jest.Mock; + toReplayActions: jest.Mock; + toReplayAction: jest.Mock; + }; + let replayService: { recordAction: jest.Mock }; + + const policy = { + mode: GameMode.PRACTICE, + puzzleType: 'logic_grid', + maxUndoDepth: 5, + undoAllowed: true, + undoPenalty: 0, + redoPenalty: 0, + canUndo: true, + canRedo: false, + }; + + beforeEach(async () => { + puzzleEngine = { + undoMove: jest.fn(), + redoMove: jest.fn(), + }; + + moveHistory = { + getPolicy: jest.fn().mockReturnValue(policy), + getHistory: jest.fn().mockReturnValue({ + entries: [], + undoCount: 0, + redoCount: 0, + totalPenalty: 0, + }), + toReplayActions: jest.fn().mockReturnValue([]), + toReplayAction: jest.fn().mockReturnValue({ + actionType: 'UNDO', + timestamp: 0, + actionData: {}, + metadata: {}, + }), + }; + + replayService = { recordAction: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [MoveHistoryController], + providers: [ + { provide: PuzzleEngineService, useValue: puzzleEngine }, + { provide: MoveHistoryService, useValue: moveHistory }, + { provide: ReplayService, useValue: replayService }, + ], + }).compile(); + + testingModule = module; + controller = module.get(MoveHistoryController); + }); + + afterEach(async () => { + await testingModule.close(); + }); + + it('undoes a move and returns the restored state', async () => { + const state = { puzzleId: 'puzzle-1' }; + puzzleEngine.undoMove.mockResolvedValue({ + state, + entry: { action: 'undo' }, + scorePenalty: 0, + }); + + const result = await controller.undo('puzzle-1', 'player-1', {}); + + expect(result.undone).toBe(true); + expect(result.state).toBe(state); + expect(puzzleEngine.undoMove).toHaveBeenCalledWith('puzzle-1', 'player-1'); + expect(result.policy).toEqual(policy); + }); + + it('redoes a move and returns the restored state', async () => { + const state = { puzzleId: 'puzzle-1' }; + puzzleEngine.redoMove.mockResolvedValue({ + state, + entry: { action: 'redo' }, + scorePenalty: 0, + }); + + const result = await controller.redo('puzzle-1', 'player-1', {}); + + expect(result.redone).toBe(true); + expect(result.state).toBe(state); + expect(puzzleEngine.redoMove).toHaveBeenCalledWith('puzzle-1', 'player-1'); + }); + + it('mirrors the undo onto a replay when one is given', async () => { + puzzleEngine.undoMove.mockResolvedValue({ + state: {}, + entry: { action: 'undo' }, + scorePenalty: 0, + }); + + await controller.undo('puzzle-1', 'player-1', { replayId: 'replay-1' }); + + expect(replayService.recordAction).toHaveBeenCalledWith( + 'replay-1', + expect.objectContaining({ actionType: 'UNDO' }), + ); + }); + + it('does not touch a replay when none is given', async () => { + puzzleEngine.undoMove.mockResolvedValue({ + state: {}, + entry: { action: 'undo' }, + scorePenalty: 0, + }); + + await controller.undo('puzzle-1', 'player-1', {}); + + expect(replayService.recordAction).not.toHaveBeenCalled(); + }); + + it('returns the move history and the effective policy', async () => { + moveHistory.getHistory.mockReturnValue({ + entries: [{ sequence: 1 }], + undoCount: 1, + redoCount: 0, + totalPenalty: 0, + }); + + const result = await controller.getMoveHistory('puzzle-1', 'player-1', {}); + + expect(result.entries).toHaveLength(1); + expect(result.undoCount).toBe(1); + expect(result.policy).toEqual(policy); + }); + + it('includes the replay actions when they are requested', async () => { + moveHistory.toReplayActions.mockReturnValue([{ actionType: 'MOVE' }]); + + const result = await controller.getMoveHistory('puzzle-1', 'player-1', { + includeReplayActions: 'true', + }); + + expect(result.replayActions).toHaveLength(1); + }); + + it('leaves the replay actions out by default', async () => { + const result = await controller.getMoveHistory('puzzle-1', 'player-1', {}); + + expect(result.replayActions).toBeUndefined(); + }); + + it('propagates a rejected undo', async () => { + puzzleEngine.undoMove.mockRejectedValue( + new ForbiddenException('Undo is disabled in ranked mode'), + ); + + await expect(controller.undo('puzzle-1', 'player-1', {})).rejects.toThrow( + ForbiddenException, + ); + }); +}); From d010df8fc432eea904f593d0bc87460c7679ef12 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:36 +0100 Subject: [PATCH 18/21] test(replay): cover bulk action recording Covers recording a batch of actions in order and the undo counter on the replay. --- src/replay/tests/replay.service.spec.ts | 63 +++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/replay/tests/replay.service.spec.ts b/src/replay/tests/replay.service.spec.ts index 04b3858b..9c5ba902 100644 --- a/src/replay/tests/replay.service.spec.ts +++ b/src/replay/tests/replay.service.spec.ts @@ -178,6 +178,69 @@ describe('ReplayService', () => { }); }); + describe('recordActions', () => { + it('should record a batch of actions in order', async () => { + const mockReplay = { + id: mockReplayId, + isCompleted: false, + movesCount: 0, + undosCount: 0, + }; + + jest.spyOn(replayRepo, 'findOne').mockResolvedValue(mockReplay as any); + jest.spyOn(actionRepo, 'findOne').mockResolvedValue(null); + jest + .spyOn(actionRepo, 'create') + .mockImplementation( + (data: any) => ({ id: 'action-x', ...data } as any), + ); + jest + .spyOn(actionRepo, 'save') + .mockImplementation((data: any) => Promise.resolve(data)); + jest.spyOn(replayRepo, 'save').mockResolvedValue(mockReplay as any); + + const recorded = await service.recordActions(mockReplayId, [ + { actionType: 'MOVE', timestamp: 0, actionData: { moveId: 'm1' } }, + { actionType: 'UNDO', timestamp: 10, actionData: {} }, + { actionType: 'REDO', timestamp: 20, actionData: {} }, + ]); + + expect(recorded).toHaveLength(3); + expect(recorded.map((action) => action.actionType)).toEqual([ + 'MOVE', + 'UNDO', + 'REDO', + ]); + }); + + it('should count undo actions towards the replay metrics', async () => { + const mockReplay = { + id: mockReplayId, + isCompleted: false, + movesCount: 0, + undosCount: 0, + }; + + jest.spyOn(replayRepo, 'findOne').mockResolvedValue(mockReplay as any); + jest.spyOn(actionRepo, 'findOne').mockResolvedValue(null); + jest + .spyOn(actionRepo, 'create') + .mockImplementation( + (data: any) => ({ id: 'action-x', ...data } as any), + ); + jest + .spyOn(actionRepo, 'save') + .mockImplementation((data: any) => Promise.resolve(data)); + jest.spyOn(replayRepo, 'save').mockResolvedValue(mockReplay as any); + + await service.recordActions(mockReplayId, [ + { actionType: 'UNDO', timestamp: 0, actionData: {} }, + ]); + + expect(mockReplay.undosCount).toBe(1); + }); + }); + describe('completeReplay', () => { it('should complete a replay with solved status', async () => { const completeDto: CompleteReplayDto = { From 744ce31d4d00973e30d5892263a304f8744d3d90 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:36 +0100 Subject: [PATCH 19/21] test(game-engine): provide the missing engine dependencies in the integration spec The testing module never provided EnergyService, so its dependency graph could not be resolved and the spec could not compile. It also now provides MoveHistoryService. --- src/game-engine/tests/puzzle-engine.integration.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game-engine/tests/puzzle-engine.integration.spec.ts b/src/game-engine/tests/puzzle-engine.integration.spec.ts index fb5ef9fb..da311665 100644 --- a/src/game-engine/tests/puzzle-engine.integration.spec.ts +++ b/src/game-engine/tests/puzzle-engine.integration.spec.ts @@ -2,9 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PuzzleEngineService } from '../services/puzzle-engine.service'; import { PuzzleType, DifficultyLevel, PuzzleMove } from '../types/puzzle.types'; import { StateManagementService } from '../services/state-management.service'; +import { MoveHistoryService } from '../services/move-history.service'; import { ValidationService } from '../services/validation.service'; import { CauseEffectEngineService } from '../services/cause-effect-engine.service'; import { AnalyticsService } from '../services/analytics.service'; +import { EnergyService } from '../../energy/energy.service'; import { PuzzleState } from '../entities/puzzle-state.entity'; import { getRepositoryToken } from '@nestjs/typeorm'; @@ -40,6 +42,8 @@ describe('Puzzle Engine Integration', () => { PuzzleEngineService, { provide: getRepositoryToken(PuzzleState), useValue: mockRepository }, { provide: StateManagementService, useValue: mockService }, + { provide: MoveHistoryService, useValue: mockService }, + { provide: EnergyService, useValue: mockService }, { provide: ValidationService, useValue: mockService }, { provide: CauseEffectEngineService, useValue: mockService }, { provide: AnalyticsService, useValue: mockService }, From 00b500b482b541136067730c27069ab922d5face Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:02:37 +0100 Subject: [PATCH 20/21] docs(game-engine): document undo, redo and the move history Explains how the move history works, the undo depth per puzzle type, the ranked mode rules, the endpoints and the configuration. Closes #461 --- src/game-engine/README.md | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/game-engine/README.md diff --git a/src/game-engine/README.md b/src/game-engine/README.md new file mode 100644 index 00000000..a0450e4f --- /dev/null +++ b/src/game-engine/README.md @@ -0,0 +1,93 @@ +# Game Engine + +The game engine owns the puzzle lifecycle: creating a puzzle, accepting moves, +scoring the result and handing the state back to the player. + +## Undo / redo and move history + +Every puzzle session keeps an ordered **move history**: the moves the player +made, the state before and after each of them, and any undo/redo actions in +between. The history is the single source of truth for three things: + +- **Undo / redo** — restoring the exact state that preceded a move. +- **Replays** — the history converts into replay actions, so a replay shows + exactly what the player did, including their undos. +- **Analytics** — move counts, undo counts and the penalties they incurred. + +### How undo and redo work + +The history is a list of state snapshots with a cursor into it: + +- a new move truncates anything ahead of the cursor (the redo branch is gone), +- **undo** moves the cursor back one step and returns that state, +- **redo** moves the cursor forward one step and returns that state. + +Because the snapshot is returned as-is, undo and redo restore the *exact* +prior state rather than replaying moves and hoping for the same result. + +### Rules + +Two rules are enforced by `MoveHistoryService`: + +**Undo depth is limited per puzzle type.** Complex puzzles keep a shallower +history because their state snapshots are large. + +| Puzzle type | Undo depth | +| --------------- | ---------- | +| `logic_grid` | 5 | +| `sequence` | 5 | +| `spatial` | 8 | +| `mathematical` | 10 | +| `pattern_matching` | 15 | +| `word_puzzle` | 20 | +| `custom` | 10 | + +**Ranked modes do not get a free undo.** Competitive modes either disable +undo outright or charge a score penalty for it: + +| Mode | Undo | +| ----------- | ------------------------ | +| `practice` | allowed, free | +| `zen` | allowed, free | +| `challenge` | allowed, penalised | +| `timed` | allowed, penalised | +| `ranked` | **disabled** | + +The penalty is a ratio of the base score, applied by `ScoringService` in the +same way the hint penalty is, and is reported back on the undo/redo response. + +The mode and the puzzle type are fixed when the puzzle is created and cannot +be changed afterwards, so a client cannot downgrade a ranked session to get a +free undo. + +### Endpoints + +| Method | Path | Purpose | +| ------ | ---- | ------- | +| `POST` | `/puzzles/:puzzleId/player/:playerId/undo` | Undo the last move | +| `POST` | `/puzzles/:puzzleId/player/:playerId/redo` | Redo the last undone move | +| `GET` | `/puzzles/:puzzleId/player/:playerId/move-history` | The ordered move history | + +The undo and redo endpoints accept an optional `replayId`. When it is given, +the action is mirrored onto that replay so the replay records the undo as +well. The move history endpoint accepts `includeReplayActions=true` to return +the history converted into replay actions, ready to be imported into the +replay system. + +### Configuration + +All values are environment driven (see `.env.example`): + +| Variable | Default | Purpose | +| -------- | ------- | ------- | +| `GAME_UNDO_ENABLED` | `true` | Master switch for undo | +| `GAME_MAX_UNDO_DEPTH` | `10` | Undo depth for types without an explicit limit | +| `GAME_MAX_UNDO_DEPTH_LOGIC_GRID` | `5` | Per-type undo depth | +| `GAME_MAX_UNDO_DEPTH_SEQUENCE` | `5` | Per-type undo depth | +| `GAME_MAX_UNDO_DEPTH_SPATIAL` | `8` | Per-type undo depth | +| `GAME_MAX_UNDO_DEPTH_MATHEMATICAL` | `10` | Per-type undo depth | +| `GAME_MAX_UNDO_DEPTH_PATTERN_MATCHING` | `15` | Per-type undo depth | +| `GAME_MAX_UNDO_DEPTH_WORD_PUZZLE` | `20` | Per-type undo depth | +| `GAME_MAX_UNDO_DEPTH_CUSTOM` | `10` | Per-type undo depth | +| `GAME_UNDO_PENALTY` | `0.05` | Score penalty ratio per undo in a penalised mode | +| `GAME_REDO_PENALTY` | `0.02` | Score penalty ratio per redo in a penalised mode | From 426b8b54916200b74e15e81e877a58d3dce6c581 Mon Sep 17 00:00:00 2001 From: akargi Date: Tue, 20 Oct 2026 17:03:04 +0100 Subject: [PATCH 21/21] chore: document the undo/redo environment variables Adds the game engine undo/redo settings to the environment template. --- .env.example | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.env.example b/.env.example index 640d693b..e1d089c8 100644 --- a/.env.example +++ b/.env.example @@ -64,3 +64,16 @@ DISCORD_PUBLIC_KEY=your-discord-public-key TWITTER_CLIENT_ID=your-twitter-client-id TWITTER_CLIENT_SECRET=your-twitter-client-secret APP_URL=http://localhost:3000 + +#Game Engine - Undo / Redo +GAME_UNDO_ENABLED=true +GAME_MAX_UNDO_DEPTH=10 +GAME_MAX_UNDO_DEPTH_LOGIC_GRID=5 +GAME_MAX_UNDO_DEPTH_SEQUENCE=5 +GAME_MAX_UNDO_DEPTH_SPATIAL=8 +GAME_MAX_UNDO_DEPTH_MATHEMATICAL=10 +GAME_MAX_UNDO_DEPTH_PATTERN_MATCHING=15 +GAME_MAX_UNDO_DEPTH_WORD_PUZZLE=20 +GAME_MAX_UNDO_DEPTH_CUSTOM=10 +GAME_UNDO_PENALTY=0.05 +GAME_REDO_PENALTY=0.02