Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
02bbd28
feat(game-engine): add move history types and per-puzzle-type undo de…
akargi Oct 20, 2026
af85d9e
feat(game-engine): add undo/redo configuration
akargi Oct 20, 2026
2a2532c
feat(game-engine): add move history service with undo/redo and policy
akargi Oct 20, 2026
06b6e32
feat(game-engine): register the move history service
akargi Oct 20, 2026
1472e53
feat(replay): add the REDO replay action type
akargi Oct 20, 2026
9c27495
feat(replay): accept the REDO action type in the replay DTO
akargi Oct 20, 2026
dd82b45
feat(replay): add bulk action recording
akargi Oct 20, 2026
6e4b288
feat(game-engine): record moves and restore state on undo/redo
akargi Oct 20, 2026
751968f
feat(game-engine): add undo/redo request DTOs
akargi Oct 20, 2026
bd908b7
feat(game-engine): add the undo, redo and move history endpoints
akargi Oct 20, 2026
042d125
feat(game-engine): accept the session mode when creating a puzzle
akargi Oct 20, 2026
303b67a
feat(game-engine): limit the puzzle undo stack per puzzle type
akargi Oct 20, 2026
b78c1de
feat(game-engine): support the ranked session mode
akargi Oct 20, 2026
03e7c3d
feat(game-engine): report undo and redo counts in the performance met…
akargi Oct 20, 2026
b75abbc
feat(game-engine): charge a score penalty for undo in penalised modes
akargi Oct 20, 2026
86826e3
test(game-engine): cover the move history, undo/redo and the ranked m…
akargi Oct 20, 2026
0ee387d
test(game-engine): cover the undo, redo and move history endpoints
akargi Oct 20, 2026
d010df8
test(replay): cover bulk action recording
akargi Oct 20, 2026
744ce31
test(game-engine): provide the missing engine dependencies in the int…
akargi Oct 20, 2026
00b500b
docs(game-engine): document undo, redo and the move history
akargi Oct 20, 2026
426b8b5
chore: document the undo/redo environment variables
akargi Oct 20, 2026
5fc901c
Merge branch 'main' into feature/undo-redo-move-history
akargi Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
93 changes: 93 additions & 0 deletions src/game-engine/README.md
Original file line number Diff line number Diff line change
@@ -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 |
65 changes: 65 additions & 0 deletions src/game-engine/config/game-engine.config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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<Record<PuzzleType, number>>;
/** 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(
Expand Down Expand Up @@ -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'),
},
}),
);
172 changes: 172 additions & 0 deletions src/game-engine/controllers/move-history.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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,
);
});
});
Loading
Loading