@drmxrcy/tcg-core 0.0.0-202602060542
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +882 -0
- package/package.json +58 -0
- package/src/__tests__/alpha-clash-engine-definition.test.ts +319 -0
- package/src/__tests__/createMockAlphaClashGame.ts +462 -0
- package/src/__tests__/createMockGrandArchiveGame.ts +373 -0
- package/src/__tests__/createMockGundamGame.ts +379 -0
- package/src/__tests__/createMockLorcanaGame.ts +328 -0
- package/src/__tests__/createMockOnePieceGame.ts +429 -0
- package/src/__tests__/createMockRiftboundGame.ts +462 -0
- package/src/__tests__/grand-archive-engine-definition.test.ts +118 -0
- package/src/__tests__/gundam-engine-definition.test.ts +110 -0
- package/src/__tests__/integration-complete-game.test.ts +508 -0
- package/src/__tests__/integration-network-sync.test.ts +469 -0
- package/src/__tests__/lorcana-engine-definition.test.ts +100 -0
- package/src/__tests__/move-enumeration.test.ts +725 -0
- package/src/__tests__/multiplayer-engine.test.ts +555 -0
- package/src/__tests__/one-piece-engine-definition.test.ts +114 -0
- package/src/__tests__/riftbound-engine-definition.test.ts +124 -0
- package/src/actions/action-definition.test.ts +201 -0
- package/src/actions/action-definition.ts +122 -0
- package/src/actions/action-timing.test.ts +490 -0
- package/src/actions/action-timing.ts +257 -0
- package/src/cards/card-definition.test.ts +268 -0
- package/src/cards/card-definition.ts +27 -0
- package/src/cards/card-instance.test.ts +422 -0
- package/src/cards/card-instance.ts +49 -0
- package/src/cards/computed-properties.test.ts +530 -0
- package/src/cards/computed-properties.ts +84 -0
- package/src/cards/conditional-modifiers.test.ts +390 -0
- package/src/cards/modifiers.test.ts +286 -0
- package/src/cards/modifiers.ts +51 -0
- package/src/engine/MULTIPLAYER.md +425 -0
- package/src/engine/__tests__/rule-engine-flow.test.ts +348 -0
- package/src/engine/__tests__/rule-engine-history.test.ts +535 -0
- package/src/engine/__tests__/rule-engine-moves.test.ts +488 -0
- package/src/engine/__tests__/rule-engine.test.ts +366 -0
- package/src/engine/index.ts +14 -0
- package/src/engine/multiplayer-engine.example.ts +571 -0
- package/src/engine/multiplayer-engine.ts +409 -0
- package/src/engine/rule-engine.test.ts +286 -0
- package/src/engine/rule-engine.ts +1539 -0
- package/src/engine/tracker-system.ts +172 -0
- package/src/examples/__tests__/coin-flip-game.test.ts +641 -0
- package/src/filtering/card-filter.test.ts +230 -0
- package/src/filtering/card-filter.ts +91 -0
- package/src/filtering/card-query.test.ts +901 -0
- package/src/filtering/card-query.ts +273 -0
- package/src/filtering/filter-matching.test.ts +944 -0
- package/src/filtering/filter-matching.ts +315 -0
- package/src/flow/SERIALIZATION.md +428 -0
- package/src/flow/__tests__/flow-definition.test.ts +427 -0
- package/src/flow/__tests__/flow-manager.test.ts +756 -0
- package/src/flow/__tests__/flow-serialization.test.ts +565 -0
- package/src/flow/flow-definition.ts +453 -0
- package/src/flow/flow-manager.ts +1044 -0
- package/src/flow/index.ts +35 -0
- package/src/game-definition/__tests__/game-definition-validation.test.ts +359 -0
- package/src/game-definition/__tests__/game-definition.test.ts +291 -0
- package/src/game-definition/__tests__/move-definitions.test.ts +328 -0
- package/src/game-definition/game-definition.ts +261 -0
- package/src/game-definition/index.ts +28 -0
- package/src/game-definition/move-definitions.ts +188 -0
- package/src/game-definition/validation.ts +183 -0
- package/src/history/history-manager.test.ts +497 -0
- package/src/history/history-manager.ts +312 -0
- package/src/history/history-operations.ts +122 -0
- package/src/history/index.ts +9 -0
- package/src/history/types.ts +255 -0
- package/src/index.ts +32 -0
- package/src/logging/index.ts +27 -0
- package/src/logging/log-formatter.ts +187 -0
- package/src/logging/logger.ts +276 -0
- package/src/logging/types.ts +148 -0
- package/src/moves/create-move.test.ts +331 -0
- package/src/moves/create-move.ts +64 -0
- package/src/moves/move-enumeration.ts +228 -0
- package/src/moves/move-executor.test.ts +431 -0
- package/src/moves/move-executor.ts +195 -0
- package/src/moves/move-system.test.ts +380 -0
- package/src/moves/move-system.ts +463 -0
- package/src/moves/standard-moves.ts +231 -0
- package/src/operations/card-operations.test.ts +236 -0
- package/src/operations/card-operations.ts +116 -0
- package/src/operations/card-registry-impl.test.ts +251 -0
- package/src/operations/card-registry-impl.ts +70 -0
- package/src/operations/card-registry.test.ts +234 -0
- package/src/operations/card-registry.ts +106 -0
- package/src/operations/counter-operations.ts +152 -0
- package/src/operations/game-operations.test.ts +280 -0
- package/src/operations/game-operations.ts +140 -0
- package/src/operations/index.ts +24 -0
- package/src/operations/operations-impl.test.ts +354 -0
- package/src/operations/operations-impl.ts +468 -0
- package/src/operations/zone-operations.test.ts +295 -0
- package/src/operations/zone-operations.ts +223 -0
- package/src/rng/seeded-rng.test.ts +339 -0
- package/src/rng/seeded-rng.ts +123 -0
- package/src/targeting/index.ts +48 -0
- package/src/targeting/target-definition.test.ts +273 -0
- package/src/targeting/target-definition.ts +37 -0
- package/src/targeting/target-dsl.ts +279 -0
- package/src/targeting/target-resolver.ts +486 -0
- package/src/targeting/target-validation.test.ts +994 -0
- package/src/targeting/target-validation.ts +286 -0
- package/src/telemetry/events.ts +202 -0
- package/src/telemetry/index.ts +21 -0
- package/src/telemetry/telemetry-manager.ts +127 -0
- package/src/telemetry/types.ts +68 -0
- package/src/testing/__tests__/testing-utilities-integration.test.ts +161 -0
- package/src/testing/index.ts +88 -0
- package/src/testing/test-assertions.test.ts +341 -0
- package/src/testing/test-assertions.ts +256 -0
- package/src/testing/test-card-factory.test.ts +228 -0
- package/src/testing/test-card-factory.ts +111 -0
- package/src/testing/test-context-factory.ts +187 -0
- package/src/testing/test-end-assertions.test.ts +262 -0
- package/src/testing/test-end-assertions.ts +95 -0
- package/src/testing/test-engine-builder.test.ts +389 -0
- package/src/testing/test-engine-builder.ts +46 -0
- package/src/testing/test-flow-assertions.test.ts +284 -0
- package/src/testing/test-flow-assertions.ts +115 -0
- package/src/testing/test-player-builder.test.ts +132 -0
- package/src/testing/test-player-builder.ts +46 -0
- package/src/testing/test-replay-assertions.test.ts +356 -0
- package/src/testing/test-replay-assertions.ts +164 -0
- package/src/testing/test-rng-helpers.test.ts +260 -0
- package/src/testing/test-rng-helpers.ts +190 -0
- package/src/testing/test-state-builder.test.ts +373 -0
- package/src/testing/test-state-builder.ts +99 -0
- package/src/testing/test-zone-factory.test.ts +295 -0
- package/src/testing/test-zone-factory.ts +224 -0
- package/src/types/branded-utils.ts +54 -0
- package/src/types/branded.test.ts +175 -0
- package/src/types/branded.ts +33 -0
- package/src/types/index.ts +8 -0
- package/src/types/state.test.ts +198 -0
- package/src/types/state.ts +154 -0
- package/src/validation/card-type-guards.test.ts +242 -0
- package/src/validation/card-type-guards.ts +179 -0
- package/src/validation/index.ts +40 -0
- package/src/validation/schema-builders.test.ts +403 -0
- package/src/validation/schema-builders.ts +345 -0
- package/src/validation/type-guard-builder.test.ts +216 -0
- package/src/validation/type-guard-builder.ts +109 -0
- package/src/validation/validator-builder.test.ts +375 -0
- package/src/validation/validator-builder.ts +273 -0
- package/src/zones/index.ts +28 -0
- package/src/zones/zone-factory.test.ts +183 -0
- package/src/zones/zone-factory.ts +44 -0
- package/src/zones/zone-operations.test.ts +800 -0
- package/src/zones/zone-operations.ts +306 -0
- package/src/zones/zone-state-helpers.test.ts +337 -0
- package/src/zones/zone-state-helpers.ts +128 -0
- package/src/zones/zone-visibility.test.ts +156 -0
- package/src/zones/zone-visibility.ts +36 -0
- package/src/zones/zone.test.ts +186 -0
- package/src/zones/zone.ts +66 -0
|
@@ -0,0 +1,1539 @@
|
|
|
1
|
+
import {
|
|
2
|
+
enablePatches,
|
|
3
|
+
applyPatches as immerApplyPatches,
|
|
4
|
+
type Patch,
|
|
5
|
+
produce,
|
|
6
|
+
} from "immer";
|
|
7
|
+
import { FlowManager } from "../flow/flow-manager";
|
|
8
|
+
import type {
|
|
9
|
+
GameDefinition,
|
|
10
|
+
Player,
|
|
11
|
+
} from "../game-definition/game-definition";
|
|
12
|
+
import {
|
|
13
|
+
type FormattedHistoryEntry,
|
|
14
|
+
HistoryManager,
|
|
15
|
+
type HistoryQueryOptions,
|
|
16
|
+
} from "../history";
|
|
17
|
+
import { createHistoryOperations } from "../history/history-operations";
|
|
18
|
+
import { Logger, type LoggerOptions } from "../logging";
|
|
19
|
+
import type {
|
|
20
|
+
EnumeratedMove,
|
|
21
|
+
MoveEnumerationOptions,
|
|
22
|
+
} from "../moves/move-enumeration";
|
|
23
|
+
import type {
|
|
24
|
+
ConditionFailure,
|
|
25
|
+
MoveContext,
|
|
26
|
+
MoveContextInput,
|
|
27
|
+
} from "../moves/move-system";
|
|
28
|
+
import type { CardRegistry } from "../operations/card-registry";
|
|
29
|
+
import { createCardRegistry } from "../operations/card-registry-impl";
|
|
30
|
+
import {
|
|
31
|
+
createCardOperations,
|
|
32
|
+
createCounterOperations,
|
|
33
|
+
createGameOperations,
|
|
34
|
+
createZoneOperations,
|
|
35
|
+
} from "../operations/operations-impl";
|
|
36
|
+
import { SeededRNG } from "../rng/seeded-rng";
|
|
37
|
+
import { TelemetryManager, type TelemetryOptions } from "../telemetry";
|
|
38
|
+
import type { PlayerId } from "../types/branded";
|
|
39
|
+
import { createPlayerId } from "../types/branded-utils";
|
|
40
|
+
import type { InternalState } from "../types/state";
|
|
41
|
+
import { TrackerSystem } from "./tracker-system";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* RuleEngine Options
|
|
45
|
+
*
|
|
46
|
+
* Configuration options for RuleEngine initialization
|
|
47
|
+
*/
|
|
48
|
+
export type RuleEngineOptions = {
|
|
49
|
+
/** Optional RNG seed for deterministic gameplay */
|
|
50
|
+
seed?: string;
|
|
51
|
+
/** Optional initial patches (for replay/restore) */
|
|
52
|
+
initialPatches?: Patch[];
|
|
53
|
+
/** Optional logger configuration for structured logging */
|
|
54
|
+
logger?: LoggerOptions;
|
|
55
|
+
/** Optional telemetry configuration for event tracking */
|
|
56
|
+
telemetry?: TelemetryOptions;
|
|
57
|
+
/** Optional player ID to designate as the choosing player (e.g., loser of previous game in best-of-three). If not provided, randomly selected. */
|
|
58
|
+
choosingFirstPlayer?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Move Execution Result
|
|
63
|
+
*
|
|
64
|
+
* Result of executing a move through the engine
|
|
65
|
+
*/
|
|
66
|
+
export type MoveExecutionResult =
|
|
67
|
+
| {
|
|
68
|
+
success: true;
|
|
69
|
+
patches: Patch[];
|
|
70
|
+
inversePatches: Patch[];
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
success: false;
|
|
74
|
+
error: string;
|
|
75
|
+
errorCode: string;
|
|
76
|
+
errorContext?: Record<string, unknown>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Replay History Entry
|
|
81
|
+
*
|
|
82
|
+
* Record of a move execution for replay/undo functionality.
|
|
83
|
+
* Contains full context, patches, and inverse patches for deterministic replay.
|
|
84
|
+
*
|
|
85
|
+
* Note: For user-facing history with localization, use HistoryEntry from @drmxrcy/tcg-core/history
|
|
86
|
+
*/
|
|
87
|
+
export type ReplayHistoryEntry<
|
|
88
|
+
TParams = any,
|
|
89
|
+
TCardMeta = any,
|
|
90
|
+
TCardDefinition = any,
|
|
91
|
+
> = {
|
|
92
|
+
moveId: string;
|
|
93
|
+
context: MoveContext<TParams, TCardMeta, TCardDefinition>;
|
|
94
|
+
patches: Patch[];
|
|
95
|
+
inversePatches: Patch[];
|
|
96
|
+
timestamp: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* RuleEngine - Core game engine
|
|
101
|
+
*
|
|
102
|
+
* Task 11: Integrates all game systems:
|
|
103
|
+
* - State management with Immer
|
|
104
|
+
* - Move execution and validation
|
|
105
|
+
* - Flow orchestration (turns/phases)
|
|
106
|
+
* - History tracking (undo/redo)
|
|
107
|
+
* - Patch generation (delta sync)
|
|
108
|
+
* - RNG for determinism
|
|
109
|
+
* - Player view filtering
|
|
110
|
+
* - Internal state management (zones/cards)
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```typescript
|
|
114
|
+
* const engine = new RuleEngine(gameDefinition, players);
|
|
115
|
+
*
|
|
116
|
+
* // Execute moves
|
|
117
|
+
* const result = engine.executeMove('playCard', {
|
|
118
|
+
* playerId: 'p1',
|
|
119
|
+
* data: { cardId: 'card-123' }
|
|
120
|
+
* });
|
|
121
|
+
*
|
|
122
|
+
* // Get player view
|
|
123
|
+
* const playerState = engine.getPlayerView('p1');
|
|
124
|
+
*
|
|
125
|
+
* // Check game end
|
|
126
|
+
* const gameEnd = engine.checkGameEnd();
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export class RuleEngine<
|
|
130
|
+
TState,
|
|
131
|
+
TMoves extends Record<string, any>,
|
|
132
|
+
TCardDefinition = any,
|
|
133
|
+
TCardMeta = any,
|
|
134
|
+
> {
|
|
135
|
+
private currentState: TState;
|
|
136
|
+
protected readonly gameDefinition: GameDefinition<
|
|
137
|
+
TState,
|
|
138
|
+
TMoves,
|
|
139
|
+
TCardDefinition,
|
|
140
|
+
TCardMeta
|
|
141
|
+
>;
|
|
142
|
+
private readonly rng: SeededRNG;
|
|
143
|
+
private readonly history: ReplayHistoryEntry<
|
|
144
|
+
any,
|
|
145
|
+
TCardMeta,
|
|
146
|
+
TCardDefinition
|
|
147
|
+
>[] = [];
|
|
148
|
+
private historyIndex = -1;
|
|
149
|
+
private readonly moveHistory: HistoryManager; // New move history system
|
|
150
|
+
private flowManager?: FlowManager<TState, TCardMeta>;
|
|
151
|
+
private readonly initialPlayers: Player[]; // Store for replay
|
|
152
|
+
private readonly initialChoosingFirstPlayer?: string; // Store for replay
|
|
153
|
+
private internalState: InternalState<TCardDefinition, TCardMeta>;
|
|
154
|
+
private readonly cardRegistry: CardRegistry<TCardDefinition>;
|
|
155
|
+
private trackerSystem: TrackerSystem;
|
|
156
|
+
private readonly logger: Logger;
|
|
157
|
+
private readonly telemetry: TelemetryManager;
|
|
158
|
+
private gameEnded = false;
|
|
159
|
+
private gameEndResult?: {
|
|
160
|
+
winner?: PlayerId;
|
|
161
|
+
reason: string;
|
|
162
|
+
metadata?: Record<string, unknown>;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Create a new RuleEngine instance
|
|
167
|
+
*
|
|
168
|
+
* Task 11.1, 11.2: Constructor with GameDefinition
|
|
169
|
+
*
|
|
170
|
+
* @param gameDefinition - Game definition with setup, moves, flow
|
|
171
|
+
* @param players - Array of players for the game
|
|
172
|
+
* @param options - Optional configuration (seed, patches)
|
|
173
|
+
*/
|
|
174
|
+
constructor(
|
|
175
|
+
gameDefinition: GameDefinition<TState, TMoves, TCardDefinition, TCardMeta>,
|
|
176
|
+
players: Player[],
|
|
177
|
+
options?: RuleEngineOptions,
|
|
178
|
+
) {
|
|
179
|
+
// Enable Immer patches for state tracking
|
|
180
|
+
enablePatches();
|
|
181
|
+
|
|
182
|
+
// Initialize logging and telemetry FIRST (before any other operations)
|
|
183
|
+
this.logger = new Logger(options?.logger ?? { level: "SILENT" });
|
|
184
|
+
this.telemetry = new TelemetryManager(
|
|
185
|
+
options?.telemetry ?? { enabled: false },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
this.gameDefinition = gameDefinition;
|
|
189
|
+
this.initialPlayers = players;
|
|
190
|
+
this.initialChoosingFirstPlayer = options?.choosingFirstPlayer;
|
|
191
|
+
|
|
192
|
+
// Initialize RNG with optional seed
|
|
193
|
+
this.rng = new SeededRNG(options?.seed);
|
|
194
|
+
|
|
195
|
+
// Initialize move history manager
|
|
196
|
+
this.moveHistory = new HistoryManager();
|
|
197
|
+
|
|
198
|
+
// Initialize card registry from game definition
|
|
199
|
+
this.cardRegistry = createCardRegistry(gameDefinition.cards);
|
|
200
|
+
|
|
201
|
+
// Initialize tracker system from game definition
|
|
202
|
+
this.trackerSystem = new TrackerSystem(gameDefinition.trackers);
|
|
203
|
+
|
|
204
|
+
// Initialize internal state with zones from game definition
|
|
205
|
+
this.internalState = {
|
|
206
|
+
zones: {},
|
|
207
|
+
cards: {},
|
|
208
|
+
cardMetas: {},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Create zone instances from zone configs (if provided)
|
|
212
|
+
if (gameDefinition.zones) {
|
|
213
|
+
for (const zoneId in gameDefinition.zones) {
|
|
214
|
+
const zoneConfig = gameDefinition.zones[zoneId];
|
|
215
|
+
if (zoneConfig) {
|
|
216
|
+
this.internalState.zones[zoneId] = {
|
|
217
|
+
config: zoneConfig,
|
|
218
|
+
cardIds: [],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Set which player gets to choose who goes first
|
|
225
|
+
// This follows TCG rules where one player is designated to make the choice
|
|
226
|
+
// (e.g., via coin flip, dice roll, rock-paper-scissors, or loser of previous game)
|
|
227
|
+
// IMPORTANT: This must happen BEFORE setup to ensure deterministic replay
|
|
228
|
+
if (players.length > 0) {
|
|
229
|
+
if (options?.choosingFirstPlayer) {
|
|
230
|
+
// Use explicitly specified choosing player (e.g., loser of previous game in best-of-three)
|
|
231
|
+
this.internalState.choosingFirstPlayer = createPlayerId(
|
|
232
|
+
options.choosingFirstPlayer,
|
|
233
|
+
);
|
|
234
|
+
} else {
|
|
235
|
+
// Randomly select if not specified
|
|
236
|
+
const randomIndex = Math.floor(this.rng.random() * players.length);
|
|
237
|
+
const choosingPlayer = players[randomIndex];
|
|
238
|
+
if (choosingPlayer) {
|
|
239
|
+
this.internalState.choosingFirstPlayer = createPlayerId(
|
|
240
|
+
choosingPlayer.id,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Call setup to create initial state
|
|
247
|
+
this.currentState = gameDefinition.setup(players);
|
|
248
|
+
|
|
249
|
+
// Initialize flow manager if flow definition exists
|
|
250
|
+
if (gameDefinition.flow) {
|
|
251
|
+
// Create operations for flow manager
|
|
252
|
+
const zoneOps = createZoneOperations(this.internalState);
|
|
253
|
+
const cardOps = createCardOperations<TCardDefinition, TCardMeta>(
|
|
254
|
+
this.internalState,
|
|
255
|
+
);
|
|
256
|
+
const gameOps = createGameOperations(this.internalState);
|
|
257
|
+
|
|
258
|
+
this.flowManager = new FlowManager(
|
|
259
|
+
gameDefinition.flow,
|
|
260
|
+
this.currentState,
|
|
261
|
+
{
|
|
262
|
+
onTurnEnd: () => this.trackerSystem.resetTurn(),
|
|
263
|
+
onPhaseEnd: (phaseName) => this.trackerSystem.resetPhase(phaseName),
|
|
264
|
+
gameOperations: gameOps,
|
|
265
|
+
zoneOperations: zoneOps,
|
|
266
|
+
cardOperations: cardOps,
|
|
267
|
+
logger: this.logger.child("flow"),
|
|
268
|
+
telemetry: this.telemetry,
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Register telemetry hooks from game definition
|
|
274
|
+
if (gameDefinition.telemetryHooks) {
|
|
275
|
+
if (gameDefinition.telemetryHooks.onPlayerAction) {
|
|
276
|
+
this.telemetry.registerHook(
|
|
277
|
+
"onPlayerAction",
|
|
278
|
+
gameDefinition.telemetryHooks.onPlayerAction,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (gameDefinition.telemetryHooks.onStateChange) {
|
|
282
|
+
this.telemetry.registerHook(
|
|
283
|
+
"onStateChange",
|
|
284
|
+
gameDefinition.telemetryHooks.onStateChange,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (gameDefinition.telemetryHooks.onRuleEvaluation) {
|
|
288
|
+
this.telemetry.registerHook(
|
|
289
|
+
"onRuleEvaluation",
|
|
290
|
+
gameDefinition.telemetryHooks.onRuleEvaluation,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (gameDefinition.telemetryHooks.onFlowTransition) {
|
|
294
|
+
this.telemetry.registerHook(
|
|
295
|
+
"onFlowTransition",
|
|
296
|
+
gameDefinition.telemetryHooks.onFlowTransition,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (gameDefinition.telemetryHooks.onEngineError) {
|
|
300
|
+
this.telemetry.registerHook(
|
|
301
|
+
"onEngineError",
|
|
302
|
+
gameDefinition.telemetryHooks.onEngineError,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (gameDefinition.telemetryHooks.onPerformance) {
|
|
306
|
+
this.telemetry.registerHook(
|
|
307
|
+
"onPerformance",
|
|
308
|
+
gameDefinition.telemetryHooks.onPerformance,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get current game state
|
|
316
|
+
*
|
|
317
|
+
* Task 11.3, 11.4: getState method
|
|
318
|
+
*
|
|
319
|
+
* Returns immutable snapshot of current state.
|
|
320
|
+
* Modifications to returned state do not affect engine.
|
|
321
|
+
*
|
|
322
|
+
* @returns Current game state (immutable)
|
|
323
|
+
*/
|
|
324
|
+
getState(): TState {
|
|
325
|
+
// Use structuredClone for deep cloning with better performance and type safety
|
|
326
|
+
// than JSON serialization. Note: structuredClone preserves more types (Date, Map, Set, etc.)
|
|
327
|
+
// but still creates a deep copy to ensure immutability
|
|
328
|
+
return structuredClone(this.currentState);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Get player-specific view of game state
|
|
333
|
+
*
|
|
334
|
+
* Task 11.5, 11.6: getPlayerView with filtering
|
|
335
|
+
*
|
|
336
|
+
* Applies playerView filter from GameDefinition to hide private information.
|
|
337
|
+
* If no playerView function defined, returns full state.
|
|
338
|
+
*
|
|
339
|
+
* @param playerId - Player requesting the view
|
|
340
|
+
* @returns Filtered state for this player
|
|
341
|
+
*/
|
|
342
|
+
getPlayerView(playerId: string): TState {
|
|
343
|
+
if (this.gameDefinition.playerView) {
|
|
344
|
+
const filteredState = this.gameDefinition.playerView(
|
|
345
|
+
this.currentState,
|
|
346
|
+
playerId,
|
|
347
|
+
);
|
|
348
|
+
// Use structuredClone for deep cloning filtered state
|
|
349
|
+
return structuredClone(filteredState);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// No filter defined, return full state
|
|
353
|
+
return this.getState();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Check if the game has ended
|
|
358
|
+
*
|
|
359
|
+
* @returns True if game has ended via endGame() call
|
|
360
|
+
*/
|
|
361
|
+
hasGameEnded(): boolean {
|
|
362
|
+
return this.gameEnded;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Get move history with player-aware filtering and verbosity levels
|
|
367
|
+
*
|
|
368
|
+
* Returns formatted history entries with player-specific visibility filtering
|
|
369
|
+
* and message formatting based on requested verbosity level.
|
|
370
|
+
*
|
|
371
|
+
* @param options - Query options for filtering and formatting
|
|
372
|
+
* @returns Array of formatted history entries
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* ```typescript
|
|
376
|
+
* // Get all history for a specific player (casual verbosity)
|
|
377
|
+
* const history = engine.getHistory({
|
|
378
|
+
* playerId: 'player_one',
|
|
379
|
+
* verbosity: 'CASUAL'
|
|
380
|
+
* });
|
|
381
|
+
*
|
|
382
|
+
* // Get recent history (since timestamp)
|
|
383
|
+
* const recentHistory = engine.getHistory({
|
|
384
|
+
* since: Date.now() - 60000 // Last minute
|
|
385
|
+
* });
|
|
386
|
+
*
|
|
387
|
+
* // Get technical details for debugging
|
|
388
|
+
* const debugHistory = engine.getHistory({
|
|
389
|
+
* verbosity: 'DEVELOPER'
|
|
390
|
+
* });
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
getHistory(options?: HistoryQueryOptions): FormattedHistoryEntry[] {
|
|
394
|
+
return this.moveHistory.query(options ?? {});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Get the game end result
|
|
399
|
+
*
|
|
400
|
+
* @returns Game end result if game has ended, undefined otherwise
|
|
401
|
+
*/
|
|
402
|
+
getGameEndResult():
|
|
403
|
+
| {
|
|
404
|
+
winner?: PlayerId;
|
|
405
|
+
reason: string;
|
|
406
|
+
metadata?: Record<string, unknown>;
|
|
407
|
+
}
|
|
408
|
+
| undefined {
|
|
409
|
+
return this.gameEndResult;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Execute a move
|
|
414
|
+
*
|
|
415
|
+
* Task 11.7, 11.8, 11.9, 11.10: executeMove with validation
|
|
416
|
+
* Task 11.25, 11.26: RNG integration in move context
|
|
417
|
+
*
|
|
418
|
+
* Validates and executes a move, updating game state.
|
|
419
|
+
* Returns patches for network synchronization.
|
|
420
|
+
*
|
|
421
|
+
* Process:
|
|
422
|
+
* 1. Validate move exists
|
|
423
|
+
* 2. Add RNG to context
|
|
424
|
+
* 3. Check move condition
|
|
425
|
+
* 4. Execute reducer with Immer
|
|
426
|
+
* 5. Capture patches
|
|
427
|
+
* 6. Update history
|
|
428
|
+
* 7. Check game end condition
|
|
429
|
+
*
|
|
430
|
+
* @param moveId - Name of move to execute
|
|
431
|
+
* @param context - Move context (player, typed params, targets)
|
|
432
|
+
* @returns Execution result with patches or error
|
|
433
|
+
*/
|
|
434
|
+
executeMove(
|
|
435
|
+
moveId: string,
|
|
436
|
+
contextInput: MoveContextInput<any>,
|
|
437
|
+
): MoveExecutionResult {
|
|
438
|
+
const startTime = Date.now();
|
|
439
|
+
|
|
440
|
+
// Log move execution start (INFO level)
|
|
441
|
+
this.logger.info(`Executing move: ${moveId}`, {
|
|
442
|
+
moveId,
|
|
443
|
+
playerId: contextInput.playerId,
|
|
444
|
+
params: contextInput.params as Record<string, unknown>,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// Task 11.7: Validate move exists
|
|
448
|
+
const moveDef = this.gameDefinition.moves[moveId as keyof TMoves];
|
|
449
|
+
if (!moveDef) {
|
|
450
|
+
const error = `Move '${moveId}' not found`;
|
|
451
|
+
this.logger.error(error, { moveId });
|
|
452
|
+
return {
|
|
453
|
+
success: false,
|
|
454
|
+
error,
|
|
455
|
+
errorCode: "MOVE_NOT_FOUND",
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Check if game has already ended BEFORE checking move conditions
|
|
460
|
+
// This ensures GAME_ENDED error takes precedence over condition failures
|
|
461
|
+
if (this.gameEnded) {
|
|
462
|
+
const error = "Game has already ended";
|
|
463
|
+
this.logger.warn(error, { moveId });
|
|
464
|
+
return {
|
|
465
|
+
success: false,
|
|
466
|
+
error,
|
|
467
|
+
errorCode: "GAME_ENDED",
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Task 11.8: Check move condition with detailed failure information
|
|
472
|
+
const conditionResult = this.checkMoveCondition(moveId, contextInput);
|
|
473
|
+
if (!conditionResult.success) {
|
|
474
|
+
// Type narrow to failure case
|
|
475
|
+
const failure = conditionResult as {
|
|
476
|
+
success: false;
|
|
477
|
+
error: string;
|
|
478
|
+
errorCode: string;
|
|
479
|
+
errorContext?: Record<string, unknown>;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Log condition failure (WARN level)
|
|
483
|
+
this.logger.warn(`Move condition failed: ${moveId}`, {
|
|
484
|
+
moveId,
|
|
485
|
+
playerId: contextInput.playerId,
|
|
486
|
+
error: failure.error,
|
|
487
|
+
errorCode: failure.errorCode,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// Add history entry for failed move
|
|
491
|
+
this.moveHistory.addEntry({
|
|
492
|
+
moveId,
|
|
493
|
+
playerId: contextInput.playerId,
|
|
494
|
+
params: contextInput.params,
|
|
495
|
+
timestamp: contextInput.timestamp ?? Date.now(),
|
|
496
|
+
turn: this.flowManager?.getTurnNumber(),
|
|
497
|
+
phase: this.flowManager?.getCurrentPhase(),
|
|
498
|
+
segment: this.flowManager?.getCurrentSegment(),
|
|
499
|
+
success: false,
|
|
500
|
+
error: {
|
|
501
|
+
code: failure.errorCode,
|
|
502
|
+
message: failure.error,
|
|
503
|
+
context: failure.errorContext,
|
|
504
|
+
},
|
|
505
|
+
messages: {
|
|
506
|
+
visibility: "PUBLIC",
|
|
507
|
+
messages: {
|
|
508
|
+
casual: {
|
|
509
|
+
key: `moves.${moveId}.failure`,
|
|
510
|
+
values: {
|
|
511
|
+
playerId: contextInput.playerId,
|
|
512
|
+
error: failure.error,
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
advanced: {
|
|
516
|
+
key: `moves.${moveId}.failure.detailed`,
|
|
517
|
+
values: {
|
|
518
|
+
playerId: contextInput.playerId,
|
|
519
|
+
error: failure.error,
|
|
520
|
+
errorCode: failure.errorCode,
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Emit telemetry event for failed move
|
|
528
|
+
this.telemetry.emitEvent({
|
|
529
|
+
type: "playerAction",
|
|
530
|
+
moveId,
|
|
531
|
+
playerId: contextInput.playerId,
|
|
532
|
+
params: contextInput.params,
|
|
533
|
+
result: "failure",
|
|
534
|
+
error: failure.error,
|
|
535
|
+
errorCode: failure.errorCode,
|
|
536
|
+
duration: Date.now() - startTime,
|
|
537
|
+
timestamp: startTime,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
return failure;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Task 11.25, 11.26: Add RNG to context for deterministic randomness
|
|
544
|
+
// Also add operations API for zone and card management
|
|
545
|
+
const zoneOps = createZoneOperations(
|
|
546
|
+
this.internalState,
|
|
547
|
+
this.logger.child("zones"),
|
|
548
|
+
);
|
|
549
|
+
const cardOps = createCardOperations(
|
|
550
|
+
this.internalState,
|
|
551
|
+
this.logger.child("cards"),
|
|
552
|
+
);
|
|
553
|
+
const gameOps = createGameOperations(
|
|
554
|
+
this.internalState,
|
|
555
|
+
this.logger.child("game"),
|
|
556
|
+
);
|
|
557
|
+
const counterOps = createCounterOperations(
|
|
558
|
+
this.internalState,
|
|
559
|
+
this.logger.child("counters"),
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// Track pending flow transitions
|
|
563
|
+
let pendingPhaseEnd = false;
|
|
564
|
+
let pendingSegmentEnd = false;
|
|
565
|
+
let pendingTurnEnd = false;
|
|
566
|
+
|
|
567
|
+
// Inject flow state from FlowManager if available
|
|
568
|
+
const flowState = this.flowManager
|
|
569
|
+
? {
|
|
570
|
+
currentPhase: this.flowManager.getCurrentPhase(),
|
|
571
|
+
currentSegment: this.flowManager.getCurrentSegment(),
|
|
572
|
+
turn: this.flowManager.getTurnNumber(),
|
|
573
|
+
currentPlayer: this.flowManager.getCurrentPlayer() as PlayerId,
|
|
574
|
+
isFirstTurn: this.flowManager.isFirstTurn(),
|
|
575
|
+
// Provide flow control methods (deferred until after move completes)
|
|
576
|
+
endPhase: () => {
|
|
577
|
+
pendingPhaseEnd = true;
|
|
578
|
+
},
|
|
579
|
+
endSegment: () => {
|
|
580
|
+
pendingSegmentEnd = true;
|
|
581
|
+
},
|
|
582
|
+
endTurn: () => {
|
|
583
|
+
pendingTurnEnd = true;
|
|
584
|
+
},
|
|
585
|
+
setCurrentPlayer: (playerId?: PlayerId) => {
|
|
586
|
+
this.flowManager?.setCurrentPlayer(playerId);
|
|
587
|
+
},
|
|
588
|
+
}
|
|
589
|
+
: undefined;
|
|
590
|
+
|
|
591
|
+
// Create endGame function to allow moves to end the game
|
|
592
|
+
const endGame = (result: {
|
|
593
|
+
winner?: PlayerId;
|
|
594
|
+
reason: string;
|
|
595
|
+
metadata?: Record<string, unknown>;
|
|
596
|
+
}) => {
|
|
597
|
+
this.gameEnded = true;
|
|
598
|
+
this.gameEndResult = result;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// Create history operations for this move
|
|
602
|
+
const historyOps = createHistoryOperations(this.moveHistory, {
|
|
603
|
+
moveId,
|
|
604
|
+
playerId: contextInput.playerId,
|
|
605
|
+
params: contextInput.params,
|
|
606
|
+
timestamp: contextInput.timestamp ?? Date.now(),
|
|
607
|
+
turn: flowState?.turn,
|
|
608
|
+
phase: flowState?.currentPhase,
|
|
609
|
+
segment: flowState?.currentSegment,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const contextWithOperations: MoveContext<any, TCardMeta, TCardDefinition> =
|
|
613
|
+
{
|
|
614
|
+
...contextInput,
|
|
615
|
+
rng: this.rng,
|
|
616
|
+
zones: zoneOps,
|
|
617
|
+
cards: cardOps,
|
|
618
|
+
game: gameOps,
|
|
619
|
+
counters: counterOps,
|
|
620
|
+
history: historyOps,
|
|
621
|
+
registry: this.cardRegistry,
|
|
622
|
+
flow: flowState,
|
|
623
|
+
endGame,
|
|
624
|
+
trackers: {
|
|
625
|
+
check: (name, playerId) => this.trackerSystem.check(name, playerId),
|
|
626
|
+
mark: (name, playerId) => this.trackerSystem.mark(name, playerId),
|
|
627
|
+
unmark: (name, playerId) => this.trackerSystem.unmark(name, playerId),
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// Task 11.9: Execute reducer with Immer and capture patches
|
|
632
|
+
let patches: Patch[] = [];
|
|
633
|
+
let inversePatches: Patch[] = [];
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
this.currentState = produce(
|
|
637
|
+
this.currentState,
|
|
638
|
+
(draft) => {
|
|
639
|
+
moveDef.reducer(draft, contextWithOperations);
|
|
640
|
+
},
|
|
641
|
+
(p, ip) => {
|
|
642
|
+
patches = p;
|
|
643
|
+
inversePatches = ip;
|
|
644
|
+
},
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
// Task 11.10: Update history (store full context for replay)
|
|
648
|
+
this.addToHistory({
|
|
649
|
+
moveId,
|
|
650
|
+
context: contextWithOperations,
|
|
651
|
+
patches,
|
|
652
|
+
inversePatches,
|
|
653
|
+
timestamp: Date.now(),
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// Add automatic base history entry for successful move
|
|
657
|
+
this.moveHistory.addEntry({
|
|
658
|
+
moveId,
|
|
659
|
+
playerId: contextInput.playerId,
|
|
660
|
+
params: contextInput.params,
|
|
661
|
+
timestamp: contextInput.timestamp ?? Date.now(),
|
|
662
|
+
turn: flowState?.turn,
|
|
663
|
+
phase: flowState?.currentPhase,
|
|
664
|
+
segment: flowState?.currentSegment,
|
|
665
|
+
success: true,
|
|
666
|
+
messages: {
|
|
667
|
+
visibility: "PUBLIC",
|
|
668
|
+
messages: {
|
|
669
|
+
casual: {
|
|
670
|
+
key: `moves.${moveId}.success`,
|
|
671
|
+
values: {
|
|
672
|
+
playerId: contextInput.playerId,
|
|
673
|
+
params: contextInput.params,
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// Execute any pending flow transitions after move completes
|
|
681
|
+
if (this.flowManager) {
|
|
682
|
+
// Sync FlowManager state with new state after move execution
|
|
683
|
+
// This ensures endIf conditions check against the latest state
|
|
684
|
+
this.flowManager.syncState(this.currentState);
|
|
685
|
+
|
|
686
|
+
if (pendingPhaseEnd) {
|
|
687
|
+
this.flowManager.nextPhase();
|
|
688
|
+
}
|
|
689
|
+
if (pendingSegmentEnd) {
|
|
690
|
+
this.flowManager.nextGameSegment();
|
|
691
|
+
}
|
|
692
|
+
if (pendingTurnEnd) {
|
|
693
|
+
this.flowManager.nextTurn();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Check automatic endIf transitions after move execution
|
|
697
|
+
// This enables automatic phase/segment/turn transitions based on endIf conditions
|
|
698
|
+
this.flowManager.checkEndConditions();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Log successful completion (DEBUG level)
|
|
702
|
+
const duration = Date.now() - startTime;
|
|
703
|
+
this.logger.debug(`Move completed: ${moveId}`, {
|
|
704
|
+
moveId,
|
|
705
|
+
playerId: contextInput.playerId,
|
|
706
|
+
duration,
|
|
707
|
+
patchCount: patches.length,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// Emit telemetry events for successful move
|
|
711
|
+
this.telemetry.emitEvent({
|
|
712
|
+
type: "playerAction",
|
|
713
|
+
moveId,
|
|
714
|
+
playerId: contextInput.playerId,
|
|
715
|
+
params: contextInput.params,
|
|
716
|
+
result: "success",
|
|
717
|
+
duration,
|
|
718
|
+
timestamp: startTime,
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
this.telemetry.emitEvent({
|
|
722
|
+
type: "stateChange",
|
|
723
|
+
patches,
|
|
724
|
+
inversePatches,
|
|
725
|
+
moveId,
|
|
726
|
+
timestamp: Date.now(),
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
return {
|
|
730
|
+
success: true,
|
|
731
|
+
patches,
|
|
732
|
+
inversePatches,
|
|
733
|
+
};
|
|
734
|
+
} catch (error) {
|
|
735
|
+
// Log error (ERROR level)
|
|
736
|
+
const errorMessage =
|
|
737
|
+
error instanceof Error ? error.message : "Move execution failed";
|
|
738
|
+
this.logger.error(`Move execution error: ${moveId}`, {
|
|
739
|
+
moveId,
|
|
740
|
+
playerId: contextInput.playerId,
|
|
741
|
+
error: errorMessage,
|
|
742
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Emit telemetry error event
|
|
746
|
+
this.telemetry.emitEvent({
|
|
747
|
+
type: "engineError",
|
|
748
|
+
error: errorMessage,
|
|
749
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
750
|
+
context: {
|
|
751
|
+
moveId,
|
|
752
|
+
playerId: contextInput.playerId,
|
|
753
|
+
params: contextInput.params,
|
|
754
|
+
},
|
|
755
|
+
moveId,
|
|
756
|
+
playerId: contextInput.playerId,
|
|
757
|
+
timestamp: Date.now(),
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
success: false,
|
|
762
|
+
error: errorMessage,
|
|
763
|
+
errorCode: "EXECUTION_ERROR",
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Build full move context with engine-provided services
|
|
770
|
+
*
|
|
771
|
+
* Centralizes context building logic used by condition checks and move execution.
|
|
772
|
+
* Includes RNG, operations APIs, and flow state.
|
|
773
|
+
*
|
|
774
|
+
* @param contextInput - Base context from caller
|
|
775
|
+
* @returns Full context with all engine services
|
|
776
|
+
* @private
|
|
777
|
+
*/
|
|
778
|
+
private buildMoveContext(
|
|
779
|
+
contextInput: MoveContextInput<any>,
|
|
780
|
+
): MoveContext<any, TCardMeta, TCardDefinition> {
|
|
781
|
+
const zoneOps = createZoneOperations(
|
|
782
|
+
this.internalState,
|
|
783
|
+
this.logger.child("zones"),
|
|
784
|
+
);
|
|
785
|
+
const cardOps = createCardOperations(
|
|
786
|
+
this.internalState,
|
|
787
|
+
this.logger.child("cards"),
|
|
788
|
+
);
|
|
789
|
+
const gameOps = createGameOperations(
|
|
790
|
+
this.internalState,
|
|
791
|
+
this.logger.child("game"),
|
|
792
|
+
);
|
|
793
|
+
const counterOps = createCounterOperations(
|
|
794
|
+
this.internalState,
|
|
795
|
+
this.logger.child("counters"),
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
// Add flow state for condition checks
|
|
799
|
+
const flowState = this.flowManager
|
|
800
|
+
? {
|
|
801
|
+
currentPhase: this.flowManager.getCurrentPhase(),
|
|
802
|
+
currentSegment: this.flowManager.getCurrentSegment(),
|
|
803
|
+
turn: this.flowManager.getTurnNumber(),
|
|
804
|
+
currentPlayer: this.flowManager.getCurrentPlayer() as PlayerId,
|
|
805
|
+
isFirstTurn: this.flowManager.getTurnNumber() === 1,
|
|
806
|
+
// Condition doesn't need control methods (endPhase, endSegment, endTurn)
|
|
807
|
+
// as conditions should be side-effect free
|
|
808
|
+
endPhase: () => {},
|
|
809
|
+
endSegment: () => {},
|
|
810
|
+
endTurn: () => {},
|
|
811
|
+
setCurrentPlayer: () => {},
|
|
812
|
+
}
|
|
813
|
+
: undefined;
|
|
814
|
+
|
|
815
|
+
// Create dummy history operations (conditions should be side-effect free)
|
|
816
|
+
const dummyHistoryOps = {
|
|
817
|
+
log: () => {
|
|
818
|
+
// No-op: conditions shouldn't add history entries
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
...contextInput,
|
|
824
|
+
rng: this.rng,
|
|
825
|
+
zones: zoneOps,
|
|
826
|
+
cards: cardOps,
|
|
827
|
+
game: gameOps,
|
|
828
|
+
counters: counterOps,
|
|
829
|
+
history: dummyHistoryOps,
|
|
830
|
+
registry: this.cardRegistry,
|
|
831
|
+
flow: flowState,
|
|
832
|
+
trackers: {
|
|
833
|
+
check: (name, playerId) => this.trackerSystem.check(name, playerId),
|
|
834
|
+
mark: (name, playerId) => this.trackerSystem.mark(name, playerId),
|
|
835
|
+
unmark: (name, playerId) => this.trackerSystem.unmark(name, playerId),
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Check move condition and return detailed failure information
|
|
842
|
+
*
|
|
843
|
+
* Evaluates move condition and returns either success or detailed failure info.
|
|
844
|
+
* Supports both legacy boolean conditions and new ConditionFailure returns.
|
|
845
|
+
*
|
|
846
|
+
* @param moveId - Name of move to check
|
|
847
|
+
* @param contextInput - Move context with typed params
|
|
848
|
+
* @returns Success indicator or detailed failure information
|
|
849
|
+
* @private
|
|
850
|
+
*/
|
|
851
|
+
private checkMoveCondition(
|
|
852
|
+
moveId: string,
|
|
853
|
+
contextInput: MoveContextInput<any>,
|
|
854
|
+
):
|
|
855
|
+
| { success: true }
|
|
856
|
+
| {
|
|
857
|
+
success: false;
|
|
858
|
+
error: string;
|
|
859
|
+
errorCode: string;
|
|
860
|
+
errorContext?: Record<string, unknown>;
|
|
861
|
+
} {
|
|
862
|
+
const moveDef = this.gameDefinition.moves[moveId as keyof TMoves];
|
|
863
|
+
|
|
864
|
+
if (!moveDef?.condition) {
|
|
865
|
+
return { success: true };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Log condition evaluation (DEBUG level)
|
|
869
|
+
this.logger.debug(`Evaluating move condition: ${moveId}`, {
|
|
870
|
+
moveId,
|
|
871
|
+
playerId: contextInput.playerId,
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const contextWithOperations = this.buildMoveContext(contextInput);
|
|
875
|
+
const result = moveDef.condition(this.currentState, contextWithOperations);
|
|
876
|
+
|
|
877
|
+
if (result === true) {
|
|
878
|
+
// Log success (TRACE level)
|
|
879
|
+
this.logger.trace(`Condition passed: ${moveId}`, { moveId });
|
|
880
|
+
return { success: true };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (result === false) {
|
|
884
|
+
// Legacy boolean false - return generic error for backward compatibility
|
|
885
|
+
this.logger.debug(`Condition failed: ${moveId}`, {
|
|
886
|
+
moveId,
|
|
887
|
+
reason: "Condition returned false",
|
|
888
|
+
});
|
|
889
|
+
return {
|
|
890
|
+
success: false,
|
|
891
|
+
error: `Move '${moveId}' condition not met`,
|
|
892
|
+
errorCode: "CONDITION_FAILED",
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Detailed ConditionFailure object (result must be ConditionFailure here)
|
|
897
|
+
const failure = result as ConditionFailure; // TypeScript narrowing
|
|
898
|
+
this.logger.debug(`Condition failed: ${moveId}`, {
|
|
899
|
+
moveId,
|
|
900
|
+
reason: failure.reason,
|
|
901
|
+
errorCode: failure.errorCode,
|
|
902
|
+
});
|
|
903
|
+
return {
|
|
904
|
+
success: false,
|
|
905
|
+
error: failure.reason,
|
|
906
|
+
errorCode: failure.errorCode,
|
|
907
|
+
errorContext: failure.context,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Check if a move can be executed
|
|
913
|
+
*
|
|
914
|
+
* Task 11.11, 11.12: canExecuteMove without side effects
|
|
915
|
+
*
|
|
916
|
+
* Validates move without actually executing it.
|
|
917
|
+
* Used for UI state (enable/disable buttons) and AI move filtering.
|
|
918
|
+
*
|
|
919
|
+
* @param moveId - Name of move to check
|
|
920
|
+
* @param context - Move context with typed params
|
|
921
|
+
* @returns True if move can be executed, false otherwise
|
|
922
|
+
*/
|
|
923
|
+
canExecuteMove(moveId: string, contextInput: MoveContextInput<any>): boolean {
|
|
924
|
+
const moveDef = this.gameDefinition.moves[moveId as keyof TMoves];
|
|
925
|
+
if (!moveDef) {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Build full context with engine-provided services
|
|
930
|
+
const contextWithOperations = this.buildMoveContext(contextInput);
|
|
931
|
+
|
|
932
|
+
if (!moveDef.condition) {
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const result = moveDef.condition(this.currentState, contextWithOperations);
|
|
937
|
+
|
|
938
|
+
// Support both boolean and ConditionFailure returns
|
|
939
|
+
return result === true;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Get all valid moves for current state
|
|
944
|
+
*
|
|
945
|
+
* Task 11.13, 11.14: getValidMoves enumeration
|
|
946
|
+
*
|
|
947
|
+
* Framework hook that games can use to enumerate available moves.
|
|
948
|
+
* Returns list of move IDs that pass their conditions.
|
|
949
|
+
*
|
|
950
|
+
* Note: This is a basic implementation. Games may want to use
|
|
951
|
+
* enumerateMoves() for more sophisticated enumeration that includes
|
|
952
|
+
* parameter combinations and full validation.
|
|
953
|
+
*
|
|
954
|
+
* @param playerId - Player to get moves for
|
|
955
|
+
* @returns Array of valid move IDs
|
|
956
|
+
*/
|
|
957
|
+
getValidMoves(playerId: PlayerId): string[] {
|
|
958
|
+
const validMoves: string[] = [];
|
|
959
|
+
|
|
960
|
+
for (const moveId of Object.keys(this.gameDefinition.moves)) {
|
|
961
|
+
// Create a minimal context for validation (params will be empty object for moves requiring no params)
|
|
962
|
+
const context: MoveContextInput<any> = {
|
|
963
|
+
playerId,
|
|
964
|
+
params: {}, // Empty params - moves with required params won't validate with empty context
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
if (this.canExecuteMove(moveId, context)) {
|
|
968
|
+
validMoves.push(moveId);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return validMoves;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Enumerate all valid moves with parameters
|
|
977
|
+
*
|
|
978
|
+
* Discovers all possible moves for a given player by invoking
|
|
979
|
+
* each move's enumerator function (if provided). Each enumerated
|
|
980
|
+
* parameter set is then validated against the move's condition.
|
|
981
|
+
*
|
|
982
|
+
* This is the primary API for AI agents and UI components to discover
|
|
983
|
+
* available actions at any game state.
|
|
984
|
+
*
|
|
985
|
+
* @param playerId - Player to enumerate moves for
|
|
986
|
+
* @param options - Optional configuration for enumeration
|
|
987
|
+
* @returns Array of enumerated moves with parameters
|
|
988
|
+
*
|
|
989
|
+
* @example
|
|
990
|
+
* ```typescript
|
|
991
|
+
* // Get all valid moves with parameters
|
|
992
|
+
* const moves = engine.enumerateMoves(playerId, {
|
|
993
|
+
* validOnly: true, // Only return moves that pass condition
|
|
994
|
+
* includeMetadata: true
|
|
995
|
+
* });
|
|
996
|
+
*
|
|
997
|
+
* for (const move of moves) {
|
|
998
|
+
* console.log(`${move.moveId}:`, move.params);
|
|
999
|
+
* if (move.isValid) {
|
|
1000
|
+
* // Can execute this move
|
|
1001
|
+
* engine.executeMove(move.moveId, {
|
|
1002
|
+
* playerId: move.playerId,
|
|
1003
|
+
* params: move.params,
|
|
1004
|
+
* targets: move.targets
|
|
1005
|
+
* });
|
|
1006
|
+
* }
|
|
1007
|
+
* }
|
|
1008
|
+
*
|
|
1009
|
+
* // Enumerate specific moves only
|
|
1010
|
+
* const attackMoves = engine.enumerateMoves(playerId, {
|
|
1011
|
+
* moveIds: ['attack', 'special-attack'],
|
|
1012
|
+
* validOnly: true
|
|
1013
|
+
* });
|
|
1014
|
+
* ```
|
|
1015
|
+
*/
|
|
1016
|
+
enumerateMoves(
|
|
1017
|
+
playerId: PlayerId,
|
|
1018
|
+
options?: MoveEnumerationOptions,
|
|
1019
|
+
): EnumeratedMove<unknown>[] {
|
|
1020
|
+
const results: EnumeratedMove<unknown>[] = [];
|
|
1021
|
+
const validOnly = options?.validOnly ?? false;
|
|
1022
|
+
const includeMetadata = options?.includeMetadata ?? false;
|
|
1023
|
+
const moveIdsFilter = options?.moveIds;
|
|
1024
|
+
const maxPerMove = options?.maxPerMove;
|
|
1025
|
+
|
|
1026
|
+
// Log enumeration start (DEBUG level)
|
|
1027
|
+
this.logger.debug("Enumerating moves", {
|
|
1028
|
+
playerId,
|
|
1029
|
+
validOnly,
|
|
1030
|
+
includeMetadata,
|
|
1031
|
+
moveIdsFilter,
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// Build enumeration context (similar to move execution context)
|
|
1035
|
+
const context = this.buildEnumerationContext(playerId);
|
|
1036
|
+
|
|
1037
|
+
// Iterate through all moves
|
|
1038
|
+
for (const [moveId, moveDef] of Object.entries(this.gameDefinition.moves)) {
|
|
1039
|
+
// Filter by moveIds if specified
|
|
1040
|
+
if (moveIdsFilter && !moveIdsFilter.includes(moveId)) {
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// If move has no enumerator, add a placeholder result
|
|
1045
|
+
if (!moveDef.enumerator) {
|
|
1046
|
+
if (!validOnly) {
|
|
1047
|
+
results.push({
|
|
1048
|
+
moveId,
|
|
1049
|
+
playerId,
|
|
1050
|
+
params: {} as any,
|
|
1051
|
+
isValid: false,
|
|
1052
|
+
validationError: {
|
|
1053
|
+
reason: "Move requires parameters but no enumerator provided",
|
|
1054
|
+
errorCode: "NO_ENUMERATOR",
|
|
1055
|
+
},
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
try {
|
|
1062
|
+
// Invoke enumerator to get parameter combinations
|
|
1063
|
+
const paramCombinations = moveDef.enumerator(
|
|
1064
|
+
this.currentState,
|
|
1065
|
+
context,
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
// Limit results per move if specified
|
|
1069
|
+
const limitedCombinations = maxPerMove
|
|
1070
|
+
? paramCombinations.slice(0, maxPerMove)
|
|
1071
|
+
: paramCombinations;
|
|
1072
|
+
|
|
1073
|
+
// Log parameter combinations (TRACE level)
|
|
1074
|
+
this.logger.trace(
|
|
1075
|
+
`Enumerated ${limitedCombinations.length} parameter combinations for ${moveId}`,
|
|
1076
|
+
{
|
|
1077
|
+
moveId,
|
|
1078
|
+
count: limitedCombinations.length,
|
|
1079
|
+
},
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
// Validate each parameter combination
|
|
1083
|
+
for (const params of limitedCombinations) {
|
|
1084
|
+
const contextInput: MoveContextInput<any> = {
|
|
1085
|
+
playerId,
|
|
1086
|
+
params,
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// Check if this move is valid
|
|
1090
|
+
const conditionResult = this.checkMoveCondition(moveId, contextInput);
|
|
1091
|
+
|
|
1092
|
+
const enumeratedMove: import("../moves/move-enumeration").EnumeratedMove<any> =
|
|
1093
|
+
{
|
|
1094
|
+
moveId,
|
|
1095
|
+
playerId,
|
|
1096
|
+
params,
|
|
1097
|
+
isValid: conditionResult.success,
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// Add validation error if failed
|
|
1101
|
+
if (!conditionResult.success) {
|
|
1102
|
+
enumeratedMove.validationError = {
|
|
1103
|
+
reason: conditionResult.error,
|
|
1104
|
+
errorCode: conditionResult.errorCode,
|
|
1105
|
+
context: conditionResult.errorContext,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Add metadata if requested
|
|
1110
|
+
if (includeMetadata && moveDef.metadata) {
|
|
1111
|
+
enumeratedMove.metadata = moveDef.metadata;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Add to results (filter by validOnly)
|
|
1115
|
+
if (!validOnly || enumeratedMove.isValid) {
|
|
1116
|
+
results.push(enumeratedMove);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
// Log enumerator error (ERROR level)
|
|
1121
|
+
const errorMessage =
|
|
1122
|
+
error instanceof Error
|
|
1123
|
+
? error.message
|
|
1124
|
+
: "Enumerator function threw an error";
|
|
1125
|
+
this.logger.error(`Enumerator error for move: ${moveId}`, {
|
|
1126
|
+
moveId,
|
|
1127
|
+
error: errorMessage,
|
|
1128
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// Add error result if not validOnly
|
|
1132
|
+
if (!validOnly) {
|
|
1133
|
+
results.push({
|
|
1134
|
+
moveId,
|
|
1135
|
+
playerId,
|
|
1136
|
+
params: {} as any,
|
|
1137
|
+
isValid: false,
|
|
1138
|
+
validationError: {
|
|
1139
|
+
reason: `Enumerator failed: ${errorMessage}`,
|
|
1140
|
+
errorCode: "ENUMERATOR_ERROR",
|
|
1141
|
+
context: { error: errorMessage },
|
|
1142
|
+
},
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Log completion (DEBUG level)
|
|
1149
|
+
this.logger.debug(`Enumeration complete: ${results.length} moves found`, {
|
|
1150
|
+
playerId,
|
|
1151
|
+
count: results.length,
|
|
1152
|
+
validCount: results.filter((m) => m.isValid).length,
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
return results;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Build enumeration context for move enumerators
|
|
1160
|
+
*
|
|
1161
|
+
* Creates a context with all necessary operations for parameter discovery.
|
|
1162
|
+
* Similar to buildMoveContext but focused on enumeration needs.
|
|
1163
|
+
*
|
|
1164
|
+
* @param playerId - Player to enumerate for
|
|
1165
|
+
* @returns Enumeration context
|
|
1166
|
+
* @private
|
|
1167
|
+
*/
|
|
1168
|
+
private buildEnumerationContext(
|
|
1169
|
+
playerId: PlayerId,
|
|
1170
|
+
): import("../moves/move-enumeration").MoveEnumerationContext<
|
|
1171
|
+
TCardMeta,
|
|
1172
|
+
TCardDefinition
|
|
1173
|
+
> {
|
|
1174
|
+
const zoneOps = createZoneOperations(
|
|
1175
|
+
this.internalState,
|
|
1176
|
+
this.logger.child("zones"),
|
|
1177
|
+
);
|
|
1178
|
+
const cardOps = createCardOperations(
|
|
1179
|
+
this.internalState,
|
|
1180
|
+
this.logger.child("cards"),
|
|
1181
|
+
);
|
|
1182
|
+
const gameOps = createGameOperations(
|
|
1183
|
+
this.internalState,
|
|
1184
|
+
this.logger.child("game"),
|
|
1185
|
+
);
|
|
1186
|
+
const counterOps = createCounterOperations(
|
|
1187
|
+
this.internalState,
|
|
1188
|
+
this.logger.child("counters"),
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
// Add flow state if available
|
|
1192
|
+
const flowState = this.flowManager
|
|
1193
|
+
? {
|
|
1194
|
+
currentPhase: this.flowManager.getCurrentPhase(),
|
|
1195
|
+
currentSegment: this.flowManager.getCurrentSegment(),
|
|
1196
|
+
turn: this.flowManager.getTurnNumber(),
|
|
1197
|
+
currentPlayer: this.flowManager.getCurrentPlayer() as PlayerId,
|
|
1198
|
+
isFirstTurn: this.flowManager.isFirstTurn(),
|
|
1199
|
+
}
|
|
1200
|
+
: undefined;
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
playerId,
|
|
1204
|
+
zones: zoneOps,
|
|
1205
|
+
cards: cardOps,
|
|
1206
|
+
game: gameOps,
|
|
1207
|
+
counters: counterOps,
|
|
1208
|
+
registry: this.cardRegistry,
|
|
1209
|
+
flow: flowState,
|
|
1210
|
+
rng: this.rng,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Check if game has ended
|
|
1216
|
+
*
|
|
1217
|
+
* Task 10.10: Evaluate endIf condition
|
|
1218
|
+
*
|
|
1219
|
+
* Checks game end condition from GameDefinition.
|
|
1220
|
+
* Should be called after each move execution.
|
|
1221
|
+
*
|
|
1222
|
+
* @returns Game end result if ended, undefined otherwise
|
|
1223
|
+
*/
|
|
1224
|
+
checkGameEnd() {
|
|
1225
|
+
if (this.gameDefinition.endIf) {
|
|
1226
|
+
return this.gameDefinition.endIf(this.currentState);
|
|
1227
|
+
}
|
|
1228
|
+
return undefined;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Get game history for replay and undo
|
|
1233
|
+
*
|
|
1234
|
+
* Task 11.17, 11.18: getReplayHistory
|
|
1235
|
+
*
|
|
1236
|
+
* Returns full move history with context for replay and undo features.
|
|
1237
|
+
* Contains complete move context, patches, and inverse patches for deterministic replay.
|
|
1238
|
+
*
|
|
1239
|
+
* **Breaking Change**: This method was previously named `getHistory()`.
|
|
1240
|
+
*
|
|
1241
|
+
* **Migration Guide**:
|
|
1242
|
+
* - For replay/undo functionality: Use `getReplayHistory()` (this method)
|
|
1243
|
+
* - For user-facing history display: Use `getHistory()` with query options
|
|
1244
|
+
*
|
|
1245
|
+
* @example
|
|
1246
|
+
* ```typescript
|
|
1247
|
+
* // Before (old API):
|
|
1248
|
+
* const history = engine.getHistory();
|
|
1249
|
+
*
|
|
1250
|
+
* // After (new API):
|
|
1251
|
+
* // For replay/undo:
|
|
1252
|
+
* const replayHistory = engine.getReplayHistory();
|
|
1253
|
+
*
|
|
1254
|
+
* // For UI display:
|
|
1255
|
+
* const displayHistory = engine.getHistory({
|
|
1256
|
+
* playerId: 'player_one',
|
|
1257
|
+
* verbosity: 'CASUAL',
|
|
1258
|
+
* includeFailures: false
|
|
1259
|
+
* });
|
|
1260
|
+
* ```
|
|
1261
|
+
*
|
|
1262
|
+
* @returns Array of history entries with full context, patches, and inverse patches
|
|
1263
|
+
*/
|
|
1264
|
+
getReplayHistory(): readonly ReplayHistoryEntry<
|
|
1265
|
+
any,
|
|
1266
|
+
TCardMeta,
|
|
1267
|
+
TCardDefinition
|
|
1268
|
+
>[] {
|
|
1269
|
+
return this.history;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Get patches since a specific point
|
|
1274
|
+
*
|
|
1275
|
+
* Task 11.21, 11.22: getPatches
|
|
1276
|
+
*
|
|
1277
|
+
* Returns all patches accumulated since the given history index.
|
|
1278
|
+
* Used for incremental network synchronization.
|
|
1279
|
+
*
|
|
1280
|
+
* @param sinceIndex - History index to get patches from (default: 0)
|
|
1281
|
+
* @returns Array of patches
|
|
1282
|
+
*/
|
|
1283
|
+
getPatches(sinceIndex = 0): Patch[] {
|
|
1284
|
+
const patches: Patch[] = [];
|
|
1285
|
+
|
|
1286
|
+
for (let i = sinceIndex; i < this.history.length; i++) {
|
|
1287
|
+
const entry = this.history[i];
|
|
1288
|
+
if (entry) {
|
|
1289
|
+
patches.push(...entry.patches);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
return patches;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Apply patches to state
|
|
1298
|
+
*
|
|
1299
|
+
* Task 11.23, 11.24: applyPatches for network sync
|
|
1300
|
+
*
|
|
1301
|
+
* Applies a set of patches from the server to update local state.
|
|
1302
|
+
* Used by clients to stay in sync with authoritative server state.
|
|
1303
|
+
*
|
|
1304
|
+
* @param patches - Patches to apply
|
|
1305
|
+
*/
|
|
1306
|
+
applyPatches(patches: Patch[]): void {
|
|
1307
|
+
// Use Immer's built-in applyPatches for correct patch application
|
|
1308
|
+
// Type assertion is safe here because Immer patches preserve the type
|
|
1309
|
+
this.currentState = immerApplyPatches(
|
|
1310
|
+
this.currentState as object,
|
|
1311
|
+
patches,
|
|
1312
|
+
) as TState;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Undo last move
|
|
1317
|
+
*
|
|
1318
|
+
* Task 11.15, 11.16: Undo with inverse patches
|
|
1319
|
+
*
|
|
1320
|
+
* Reverts the last move using inverse patches.
|
|
1321
|
+
* Updates history index to enable redo.
|
|
1322
|
+
*
|
|
1323
|
+
* @returns True if undo succeeded, false if no moves to undo
|
|
1324
|
+
*/
|
|
1325
|
+
undo(): boolean {
|
|
1326
|
+
if (this.historyIndex < 0) {
|
|
1327
|
+
return false;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const entry = this.history[this.historyIndex];
|
|
1331
|
+
if (!entry) {
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Apply inverse patches to revert the move using Immer's applyPatches
|
|
1336
|
+
// Type assertion is safe here because Immer patches preserve the type
|
|
1337
|
+
this.currentState = immerApplyPatches(
|
|
1338
|
+
this.currentState as object,
|
|
1339
|
+
entry.inversePatches,
|
|
1340
|
+
) as TState;
|
|
1341
|
+
|
|
1342
|
+
this.historyIndex--;
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Redo previously undone move
|
|
1348
|
+
*
|
|
1349
|
+
* Task 11.15, 11.16: Redo with forward patches
|
|
1350
|
+
*
|
|
1351
|
+
* Re-applies a move that was undone.
|
|
1352
|
+
*
|
|
1353
|
+
* @returns True if redo succeeded, false if no moves to redo
|
|
1354
|
+
*/
|
|
1355
|
+
redo(): boolean {
|
|
1356
|
+
if (this.historyIndex >= this.history.length - 1) {
|
|
1357
|
+
return false;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const entry = this.history[this.historyIndex + 1];
|
|
1361
|
+
if (!entry) {
|
|
1362
|
+
return false;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Apply forward patches to redo the move using Immer's applyPatches
|
|
1366
|
+
// Type assertion is safe here because Immer patches preserve the type
|
|
1367
|
+
this.currentState = immerApplyPatches(
|
|
1368
|
+
this.currentState as object,
|
|
1369
|
+
entry.patches,
|
|
1370
|
+
) as TState;
|
|
1371
|
+
|
|
1372
|
+
this.historyIndex++;
|
|
1373
|
+
return true;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Replay game from history
|
|
1378
|
+
*
|
|
1379
|
+
* Task 11.19, 11.20: Replay with deterministic execution
|
|
1380
|
+
*
|
|
1381
|
+
* Replays the game from initial state using recorded history.
|
|
1382
|
+
* Useful for game analysis, bug reproduction, and verification.
|
|
1383
|
+
*
|
|
1384
|
+
* @param upToIndex - Optional index to replay up to (default: all)
|
|
1385
|
+
* @returns Final state after replay
|
|
1386
|
+
*/
|
|
1387
|
+
replay(upToIndex?: number): TState {
|
|
1388
|
+
// Reset RNG to initial seed for deterministic replay
|
|
1389
|
+
const originalSeed = this.rng.getSeed();
|
|
1390
|
+
this.rng.setSeed(originalSeed);
|
|
1391
|
+
|
|
1392
|
+
// Reset internal state (zones, cards, choosingFirstPlayer, etc.)
|
|
1393
|
+
this.internalState = {
|
|
1394
|
+
zones: {},
|
|
1395
|
+
cards: {},
|
|
1396
|
+
cardMetas: {},
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
// Recreate zones from game definition
|
|
1400
|
+
if (this.gameDefinition.zones) {
|
|
1401
|
+
for (const zoneId in this.gameDefinition.zones) {
|
|
1402
|
+
const zoneConfig = this.gameDefinition.zones[zoneId];
|
|
1403
|
+
if (zoneConfig) {
|
|
1404
|
+
this.internalState.zones[zoneId] = {
|
|
1405
|
+
config: zoneConfig,
|
|
1406
|
+
cardIds: [],
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Set which player gets to choose who goes first
|
|
1413
|
+
// This must match the original constructor behavior for deterministic replay
|
|
1414
|
+
if (this.initialPlayers.length > 0) {
|
|
1415
|
+
if (this.initialChoosingFirstPlayer) {
|
|
1416
|
+
// Use explicitly specified choosing player (stored from initial options)
|
|
1417
|
+
this.internalState.choosingFirstPlayer = createPlayerId(
|
|
1418
|
+
this.initialChoosingFirstPlayer,
|
|
1419
|
+
);
|
|
1420
|
+
} else {
|
|
1421
|
+
// Randomly select if not specified (must match constructor)
|
|
1422
|
+
const randomIndex = Math.floor(
|
|
1423
|
+
this.rng.random() * this.initialPlayers.length,
|
|
1424
|
+
);
|
|
1425
|
+
const choosingPlayer = this.initialPlayers[randomIndex];
|
|
1426
|
+
if (choosingPlayer) {
|
|
1427
|
+
this.internalState.choosingFirstPlayer = createPlayerId(
|
|
1428
|
+
choosingPlayer.id,
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Reset to initial state
|
|
1435
|
+
this.currentState = this.gameDefinition.setup(this.initialPlayers);
|
|
1436
|
+
|
|
1437
|
+
const endIndex = upToIndex ?? this.history.length;
|
|
1438
|
+
|
|
1439
|
+
for (let i = 0; i < endIndex; i++) {
|
|
1440
|
+
const entry = this.history[i];
|
|
1441
|
+
if (!entry) break;
|
|
1442
|
+
|
|
1443
|
+
// Re-execute the move
|
|
1444
|
+
this.executeMove(entry.moveId, entry.context);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
return this.getState();
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Get RNG instance
|
|
1452
|
+
*
|
|
1453
|
+
* Task 11.25, 11.26: RNG integration
|
|
1454
|
+
*
|
|
1455
|
+
* Provides access to the seeded RNG for deterministic random operations.
|
|
1456
|
+
* Games should use this RNG (not Math.random()) for all randomness.
|
|
1457
|
+
*
|
|
1458
|
+
* @returns Seeded RNG instance
|
|
1459
|
+
*/
|
|
1460
|
+
getRNG(): SeededRNG {
|
|
1461
|
+
return this.rng;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Get flow manager
|
|
1466
|
+
*
|
|
1467
|
+
* Task 11.27, 11.28: Flow integration
|
|
1468
|
+
*
|
|
1469
|
+
* Provides access to the flow orchestration system.
|
|
1470
|
+
* Returns undefined if no flow definition in GameDefinition.
|
|
1471
|
+
*
|
|
1472
|
+
* @returns FlowManager instance or undefined
|
|
1473
|
+
*/
|
|
1474
|
+
getFlowManager(): FlowManager<TState> | undefined {
|
|
1475
|
+
return this.flowManager;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Get logger instance
|
|
1480
|
+
*
|
|
1481
|
+
* Provides access to the engine's logger for custom logging.
|
|
1482
|
+
* Useful for game-specific logging or debugging.
|
|
1483
|
+
*
|
|
1484
|
+
* @returns Logger instance
|
|
1485
|
+
*
|
|
1486
|
+
* @example
|
|
1487
|
+
* ```typescript
|
|
1488
|
+
* const engine = new RuleEngine(gameDefinition, players, {
|
|
1489
|
+
* logger: { level: 'DEVELOPER', pretty: true }
|
|
1490
|
+
* });
|
|
1491
|
+
*
|
|
1492
|
+
* const logger = engine.getLogger();
|
|
1493
|
+
* logger.info('Custom game event', { eventId: 'custom-123' });
|
|
1494
|
+
* ```
|
|
1495
|
+
*/
|
|
1496
|
+
getLogger(): Logger {
|
|
1497
|
+
return this.logger;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Get telemetry manager instance
|
|
1502
|
+
*
|
|
1503
|
+
* Provides access to the telemetry system for custom event tracking.
|
|
1504
|
+
* Useful for analytics, monitoring, and debugging integrations.
|
|
1505
|
+
*
|
|
1506
|
+
* @returns TelemetryManager instance
|
|
1507
|
+
*
|
|
1508
|
+
* @example
|
|
1509
|
+
* ```typescript
|
|
1510
|
+
* const engine = new RuleEngine(gameDefinition, players, {
|
|
1511
|
+
* telemetry: { enabled: true }
|
|
1512
|
+
* });
|
|
1513
|
+
*
|
|
1514
|
+
* const telemetry = engine.getTelemetry();
|
|
1515
|
+
* telemetry.on('playerAction', (event) => {
|
|
1516
|
+
* analytics.track('game.move', event);
|
|
1517
|
+
* });
|
|
1518
|
+
* ```
|
|
1519
|
+
*/
|
|
1520
|
+
getTelemetry(): TelemetryManager {
|
|
1521
|
+
return this.telemetry;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Add entry to history
|
|
1526
|
+
*
|
|
1527
|
+
* Internal method to manage history tracking.
|
|
1528
|
+
* Truncates forward history when new move is made after undo.
|
|
1529
|
+
*/
|
|
1530
|
+
private addToHistory(entry: ReplayHistoryEntry): void {
|
|
1531
|
+
// Truncate forward history if we're not at the end
|
|
1532
|
+
if (this.historyIndex < this.history.length - 1) {
|
|
1533
|
+
this.history.splice(this.historyIndex + 1);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
this.history.push(entry);
|
|
1537
|
+
this.historyIndex = this.history.length - 1;
|
|
1538
|
+
}
|
|
1539
|
+
}
|