@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.
Files changed (157) hide show
  1. package/README.md +882 -0
  2. package/package.json +58 -0
  3. package/src/__tests__/alpha-clash-engine-definition.test.ts +319 -0
  4. package/src/__tests__/createMockAlphaClashGame.ts +462 -0
  5. package/src/__tests__/createMockGrandArchiveGame.ts +373 -0
  6. package/src/__tests__/createMockGundamGame.ts +379 -0
  7. package/src/__tests__/createMockLorcanaGame.ts +328 -0
  8. package/src/__tests__/createMockOnePieceGame.ts +429 -0
  9. package/src/__tests__/createMockRiftboundGame.ts +462 -0
  10. package/src/__tests__/grand-archive-engine-definition.test.ts +118 -0
  11. package/src/__tests__/gundam-engine-definition.test.ts +110 -0
  12. package/src/__tests__/integration-complete-game.test.ts +508 -0
  13. package/src/__tests__/integration-network-sync.test.ts +469 -0
  14. package/src/__tests__/lorcana-engine-definition.test.ts +100 -0
  15. package/src/__tests__/move-enumeration.test.ts +725 -0
  16. package/src/__tests__/multiplayer-engine.test.ts +555 -0
  17. package/src/__tests__/one-piece-engine-definition.test.ts +114 -0
  18. package/src/__tests__/riftbound-engine-definition.test.ts +124 -0
  19. package/src/actions/action-definition.test.ts +201 -0
  20. package/src/actions/action-definition.ts +122 -0
  21. package/src/actions/action-timing.test.ts +490 -0
  22. package/src/actions/action-timing.ts +257 -0
  23. package/src/cards/card-definition.test.ts +268 -0
  24. package/src/cards/card-definition.ts +27 -0
  25. package/src/cards/card-instance.test.ts +422 -0
  26. package/src/cards/card-instance.ts +49 -0
  27. package/src/cards/computed-properties.test.ts +530 -0
  28. package/src/cards/computed-properties.ts +84 -0
  29. package/src/cards/conditional-modifiers.test.ts +390 -0
  30. package/src/cards/modifiers.test.ts +286 -0
  31. package/src/cards/modifiers.ts +51 -0
  32. package/src/engine/MULTIPLAYER.md +425 -0
  33. package/src/engine/__tests__/rule-engine-flow.test.ts +348 -0
  34. package/src/engine/__tests__/rule-engine-history.test.ts +535 -0
  35. package/src/engine/__tests__/rule-engine-moves.test.ts +488 -0
  36. package/src/engine/__tests__/rule-engine.test.ts +366 -0
  37. package/src/engine/index.ts +14 -0
  38. package/src/engine/multiplayer-engine.example.ts +571 -0
  39. package/src/engine/multiplayer-engine.ts +409 -0
  40. package/src/engine/rule-engine.test.ts +286 -0
  41. package/src/engine/rule-engine.ts +1539 -0
  42. package/src/engine/tracker-system.ts +172 -0
  43. package/src/examples/__tests__/coin-flip-game.test.ts +641 -0
  44. package/src/filtering/card-filter.test.ts +230 -0
  45. package/src/filtering/card-filter.ts +91 -0
  46. package/src/filtering/card-query.test.ts +901 -0
  47. package/src/filtering/card-query.ts +273 -0
  48. package/src/filtering/filter-matching.test.ts +944 -0
  49. package/src/filtering/filter-matching.ts +315 -0
  50. package/src/flow/SERIALIZATION.md +428 -0
  51. package/src/flow/__tests__/flow-definition.test.ts +427 -0
  52. package/src/flow/__tests__/flow-manager.test.ts +756 -0
  53. package/src/flow/__tests__/flow-serialization.test.ts +565 -0
  54. package/src/flow/flow-definition.ts +453 -0
  55. package/src/flow/flow-manager.ts +1044 -0
  56. package/src/flow/index.ts +35 -0
  57. package/src/game-definition/__tests__/game-definition-validation.test.ts +359 -0
  58. package/src/game-definition/__tests__/game-definition.test.ts +291 -0
  59. package/src/game-definition/__tests__/move-definitions.test.ts +328 -0
  60. package/src/game-definition/game-definition.ts +261 -0
  61. package/src/game-definition/index.ts +28 -0
  62. package/src/game-definition/move-definitions.ts +188 -0
  63. package/src/game-definition/validation.ts +183 -0
  64. package/src/history/history-manager.test.ts +497 -0
  65. package/src/history/history-manager.ts +312 -0
  66. package/src/history/history-operations.ts +122 -0
  67. package/src/history/index.ts +9 -0
  68. package/src/history/types.ts +255 -0
  69. package/src/index.ts +32 -0
  70. package/src/logging/index.ts +27 -0
  71. package/src/logging/log-formatter.ts +187 -0
  72. package/src/logging/logger.ts +276 -0
  73. package/src/logging/types.ts +148 -0
  74. package/src/moves/create-move.test.ts +331 -0
  75. package/src/moves/create-move.ts +64 -0
  76. package/src/moves/move-enumeration.ts +228 -0
  77. package/src/moves/move-executor.test.ts +431 -0
  78. package/src/moves/move-executor.ts +195 -0
  79. package/src/moves/move-system.test.ts +380 -0
  80. package/src/moves/move-system.ts +463 -0
  81. package/src/moves/standard-moves.ts +231 -0
  82. package/src/operations/card-operations.test.ts +236 -0
  83. package/src/operations/card-operations.ts +116 -0
  84. package/src/operations/card-registry-impl.test.ts +251 -0
  85. package/src/operations/card-registry-impl.ts +70 -0
  86. package/src/operations/card-registry.test.ts +234 -0
  87. package/src/operations/card-registry.ts +106 -0
  88. package/src/operations/counter-operations.ts +152 -0
  89. package/src/operations/game-operations.test.ts +280 -0
  90. package/src/operations/game-operations.ts +140 -0
  91. package/src/operations/index.ts +24 -0
  92. package/src/operations/operations-impl.test.ts +354 -0
  93. package/src/operations/operations-impl.ts +468 -0
  94. package/src/operations/zone-operations.test.ts +295 -0
  95. package/src/operations/zone-operations.ts +223 -0
  96. package/src/rng/seeded-rng.test.ts +339 -0
  97. package/src/rng/seeded-rng.ts +123 -0
  98. package/src/targeting/index.ts +48 -0
  99. package/src/targeting/target-definition.test.ts +273 -0
  100. package/src/targeting/target-definition.ts +37 -0
  101. package/src/targeting/target-dsl.ts +279 -0
  102. package/src/targeting/target-resolver.ts +486 -0
  103. package/src/targeting/target-validation.test.ts +994 -0
  104. package/src/targeting/target-validation.ts +286 -0
  105. package/src/telemetry/events.ts +202 -0
  106. package/src/telemetry/index.ts +21 -0
  107. package/src/telemetry/telemetry-manager.ts +127 -0
  108. package/src/telemetry/types.ts +68 -0
  109. package/src/testing/__tests__/testing-utilities-integration.test.ts +161 -0
  110. package/src/testing/index.ts +88 -0
  111. package/src/testing/test-assertions.test.ts +341 -0
  112. package/src/testing/test-assertions.ts +256 -0
  113. package/src/testing/test-card-factory.test.ts +228 -0
  114. package/src/testing/test-card-factory.ts +111 -0
  115. package/src/testing/test-context-factory.ts +187 -0
  116. package/src/testing/test-end-assertions.test.ts +262 -0
  117. package/src/testing/test-end-assertions.ts +95 -0
  118. package/src/testing/test-engine-builder.test.ts +389 -0
  119. package/src/testing/test-engine-builder.ts +46 -0
  120. package/src/testing/test-flow-assertions.test.ts +284 -0
  121. package/src/testing/test-flow-assertions.ts +115 -0
  122. package/src/testing/test-player-builder.test.ts +132 -0
  123. package/src/testing/test-player-builder.ts +46 -0
  124. package/src/testing/test-replay-assertions.test.ts +356 -0
  125. package/src/testing/test-replay-assertions.ts +164 -0
  126. package/src/testing/test-rng-helpers.test.ts +260 -0
  127. package/src/testing/test-rng-helpers.ts +190 -0
  128. package/src/testing/test-state-builder.test.ts +373 -0
  129. package/src/testing/test-state-builder.ts +99 -0
  130. package/src/testing/test-zone-factory.test.ts +295 -0
  131. package/src/testing/test-zone-factory.ts +224 -0
  132. package/src/types/branded-utils.ts +54 -0
  133. package/src/types/branded.test.ts +175 -0
  134. package/src/types/branded.ts +33 -0
  135. package/src/types/index.ts +8 -0
  136. package/src/types/state.test.ts +198 -0
  137. package/src/types/state.ts +154 -0
  138. package/src/validation/card-type-guards.test.ts +242 -0
  139. package/src/validation/card-type-guards.ts +179 -0
  140. package/src/validation/index.ts +40 -0
  141. package/src/validation/schema-builders.test.ts +403 -0
  142. package/src/validation/schema-builders.ts +345 -0
  143. package/src/validation/type-guard-builder.test.ts +216 -0
  144. package/src/validation/type-guard-builder.ts +109 -0
  145. package/src/validation/validator-builder.test.ts +375 -0
  146. package/src/validation/validator-builder.ts +273 -0
  147. package/src/zones/index.ts +28 -0
  148. package/src/zones/zone-factory.test.ts +183 -0
  149. package/src/zones/zone-factory.ts +44 -0
  150. package/src/zones/zone-operations.test.ts +800 -0
  151. package/src/zones/zone-operations.ts +306 -0
  152. package/src/zones/zone-state-helpers.test.ts +337 -0
  153. package/src/zones/zone-state-helpers.ts +128 -0
  154. package/src/zones/zone-visibility.test.ts +156 -0
  155. package/src/zones/zone-visibility.ts +36 -0
  156. package/src/zones/zone.test.ts +186 -0
  157. 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
+ }