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 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 | 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'), + }, }), ); 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, + ); + }); +}); 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, + }); + } +} 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, ); } 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; +} 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; diff --git a/src/game-engine/game-engine.module.ts b/src/game-engine/game-engine.module.ts index 16d56e8d..8b2448a0 100644 --- a/src/game-engine/game-engine.module.ts +++ b/src/game-engine/game-engine.module.ts @@ -15,9 +15,11 @@ 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'; @@ -29,6 +31,7 @@ import { PracticeModeController } from './controllers/practice-mode.controller'; import { Puzzle } from '../puzzles/entities/puzzle.entity'; import { gameEngineConfig } from './config/game-engine.config'; import { EnergyModule } from '../energy/energy.module'; +import { ReplayModule } from '../replay/replay.module'; @Global() @Module({ @@ -44,6 +47,7 @@ import { EnergyModule } from '../energy/energy.module'; Puzzle, ]), EnergyModule, + ReplayModule, ], providers: [ PuzzleEngineService, @@ -60,12 +64,14 @@ import { EnergyModule } from '../energy/energy.module'; PuzzleRegistryService, ScoringService, AchievementsService, + MoveHistoryService, PracticeModeService, ], controllers: [ PuzzleController, GameStateController, AnalyticsController, + MoveHistoryController, PracticeModeController, ], exports: [ @@ -83,6 +89,7 @@ import { EnergyModule } from '../energy/energy.module'; PuzzleRegistryService, ScoringService, AchievementsService, + MoveHistoryService, PracticeModeService, ], }) 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; 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; } 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([]); + }); + }); +}); 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'; + } + } +} 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 { 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, 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 }, 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; + }; +} 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'; 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'; 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 */ 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 = {