@drmxrcy/tcg-lorcana 0.0.0-202602060544

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 (100) hide show
  1. package/README.md +160 -0
  2. package/package.json +45 -0
  3. package/src/__tests__/integration/move-enumeration.test.ts +256 -0
  4. package/src/__tests__/rules/section-01-concepts.test.ts +426 -0
  5. package/src/__tests__/rules/section-03-gameplay.test.ts +298 -0
  6. package/src/__tests__/rules/section-04-turn-structure.test.ts +708 -0
  7. package/src/__tests__/rules/section-05-cards.test.ts +158 -0
  8. package/src/__tests__/rules/section-06-card-types.test.ts +342 -0
  9. package/src/__tests__/rules/section-07-abilities.test.ts +333 -0
  10. package/src/__tests__/rules/section-08-zones.test.ts +231 -0
  11. package/src/__tests__/rules/section-09-damage.test.ts +148 -0
  12. package/src/__tests__/rules/section-10-keywords.test.ts +469 -0
  13. package/src/__tests__/spec-01-foundation-types.test.ts +534 -0
  14. package/src/__tests__/spec-02-zones-card-states.test.ts +295 -0
  15. package/src/card-utils.ts +302 -0
  16. package/src/cards/README.md +296 -0
  17. package/src/cards/abilities/index.ts +175 -0
  18. package/src/cards/index.ts +10 -0
  19. package/src/deck-validation.ts +175 -0
  20. package/src/engine/lorcana-engine.ts +625 -0
  21. package/src/game-definition/__tests__/core-zone-integration.test.ts +553 -0
  22. package/src/game-definition/__tests__/zone-operations.test.ts +362 -0
  23. package/src/game-definition/__tests__/zones.test.ts +176 -0
  24. package/src/game-definition/definition.ts +45 -0
  25. package/src/game-definition/flow/turn-flow.ts +216 -0
  26. package/src/game-definition/index.ts +31 -0
  27. package/src/game-definition/moves/abilities/activate-ability.ts +51 -0
  28. package/src/game-definition/moves/core/__tests__/move-parameter-enumeration.test.ts +316 -0
  29. package/src/game-definition/moves/core/challenge.test.ts +545 -0
  30. package/src/game-definition/moves/core/challenge.ts +81 -0
  31. package/src/game-definition/moves/core/play-card.ts +83 -0
  32. package/src/game-definition/moves/core/quest.test.ts +448 -0
  33. package/src/game-definition/moves/core/quest.ts +49 -0
  34. package/src/game-definition/moves/debug/manual-exert.ts +36 -0
  35. package/src/game-definition/moves/effects/resolve-bag.ts +35 -0
  36. package/src/game-definition/moves/effects/resolve-effect.ts +34 -0
  37. package/src/game-definition/moves/index.ts +85 -0
  38. package/src/game-definition/moves/locations/move-character-to-location.ts +42 -0
  39. package/src/game-definition/moves/resources/put-card-into-inkwell.test.ts +462 -0
  40. package/src/game-definition/moves/resources/put-card-into-inkwell.ts +51 -0
  41. package/src/game-definition/moves/setup/alter-hand.test.ts +395 -0
  42. package/src/game-definition/moves/setup/alter-hand.ts +210 -0
  43. package/src/game-definition/moves/setup/choose-first-player.test.ts +450 -0
  44. package/src/game-definition/moves/setup/choose-first-player.ts +105 -0
  45. package/src/game-definition/moves/setup/draw-cards.ts +37 -0
  46. package/src/game-definition/moves/songs/sing-together.ts +47 -0
  47. package/src/game-definition/moves/songs/sing.ts +56 -0
  48. package/src/game-definition/moves/standard/concede.test.ts +189 -0
  49. package/src/game-definition/moves/standard/concede.ts +72 -0
  50. package/src/game-definition/moves/standard/pass-turn.ts +49 -0
  51. package/src/game-definition/setup/game-setup.ts +19 -0
  52. package/src/game-definition/trackers/tracker-config.ts +23 -0
  53. package/src/game-definition/win-conditions/lore-victory.ts +26 -0
  54. package/src/game-definition/zone-operations.ts +405 -0
  55. package/src/game-definition/zones/zone-configs.ts +59 -0
  56. package/src/game-definition/zones.ts +283 -0
  57. package/src/index.ts +189 -0
  58. package/src/operations/index.ts +7 -0
  59. package/src/operations/lorcana-operations.ts +288 -0
  60. package/src/queries/README.md +56 -0
  61. package/src/resolvers/__tests__/condition-resolver.test.ts +301 -0
  62. package/src/resolvers/condition-registry.ts +70 -0
  63. package/src/resolvers/condition-resolver.ts +85 -0
  64. package/src/resolvers/conditions/basic.ts +81 -0
  65. package/src/resolvers/conditions/card-state.ts +12 -0
  66. package/src/resolvers/conditions/comparison.ts +102 -0
  67. package/src/resolvers/conditions/existence.ts +219 -0
  68. package/src/resolvers/conditions/history.ts +68 -0
  69. package/src/resolvers/conditions/index.ts +15 -0
  70. package/src/resolvers/conditions/logical.ts +55 -0
  71. package/src/resolvers/conditions/resolution.ts +41 -0
  72. package/src/resolvers/conditions/revealed.ts +42 -0
  73. package/src/resolvers/conditions/zone.ts +84 -0
  74. package/src/setup.test.ts +18 -0
  75. package/src/targeting/__tests__/filter-resolver.test.ts +294 -0
  76. package/src/targeting/__tests__/real-cards-targeting.test.ts +303 -0
  77. package/src/targeting/__tests__/targeting-dsl.test.ts +386 -0
  78. package/src/targeting/enum-expansion.ts +387 -0
  79. package/src/targeting/filter-registry.ts +322 -0
  80. package/src/targeting/filter-resolver.ts +145 -0
  81. package/src/targeting/index.ts +91 -0
  82. package/src/targeting/lorcana-target-dsl.ts +495 -0
  83. package/src/targeting/targeting-ui.ts +407 -0
  84. package/src/testing/index.ts +14 -0
  85. package/src/testing/lorcana-test-engine.ts +813 -0
  86. package/src/types/README.md +303 -0
  87. package/src/types/__tests__/lorcana-state.test.ts +168 -0
  88. package/src/types/__tests__/move-enumeration.test.ts +179 -0
  89. package/src/types/branded-types.ts +106 -0
  90. package/src/types/game-state.ts +184 -0
  91. package/src/types/index.ts +87 -0
  92. package/src/types/keywords.ts +187 -0
  93. package/src/types/lorcana-state.ts +260 -0
  94. package/src/types/move-enumeration.ts +126 -0
  95. package/src/types/move-params.ts +216 -0
  96. package/src/validators/index.ts +7 -0
  97. package/src/validators/move-validators.ts +374 -0
  98. package/src/zones/card-state.ts +234 -0
  99. package/src/zones/index.ts +42 -0
  100. package/src/zones/zone-config.ts +150 -0
@@ -0,0 +1,813 @@
1
+ /**
2
+ * Lorcana Test Engine
3
+ *
4
+ * Convenient test wrapper around RuleEngine for testing Lorcana games.
5
+ * Provides ergonomic API similar to legacy test engine while using @drmxrcy/tcg-core framework.
6
+ *
7
+ * Features:
8
+ * - Multiple engine instances (authoritative + player engines) for realistic testing
9
+ * - Simple board state setup ({ hand: 7, deck: 10 })
10
+ * - Convenient move execution methods
11
+ * - State inspection helpers
12
+ * - Automatic state synchronization checks
13
+ */
14
+
15
+ import {
16
+ createCardId,
17
+ createCardOperations,
18
+ createPlayerId,
19
+ createZoneOperations,
20
+ type RuleEngineOptions,
21
+ } from "@drmxrcy/tcg-core";
22
+ import type {
23
+ LorcanaCardDefinition,
24
+ ActionCard as LorcanaTypesActionCard,
25
+ CharacterCard as LorcanaTypesCharacterCard,
26
+ ItemCard as LorcanaTypesItemCard,
27
+ LocationCard as LorcanaTypesLocationCard,
28
+ } from "@drmxrcy/tcg-lorcana-types";
29
+ import { LorcanaEngine } from "../engine/lorcana-engine";
30
+ import { lorcanaGameDefinition } from "../game-definition/definition";
31
+ import type {
32
+ LorcanaCardMeta,
33
+ LorcanaGameState,
34
+ LorcanaMoveParams,
35
+ } from "../types";
36
+
37
+ // Union of engine types and types package types for compatibility
38
+ type LorcanaCardDefinitionInput =
39
+ | LorcanaCardDefinition
40
+ | LorcanaTypesCharacterCard
41
+ | LorcanaTypesActionCard
42
+ | LorcanaTypesItemCard
43
+ | LorcanaTypesLocationCard;
44
+
45
+ // Export player ID constants for tests
46
+ export const PLAYER_ONE = createPlayerId("player_one");
47
+ export const PLAYER_TWO = createPlayerId("player_two");
48
+
49
+ /**
50
+ * Test Initial State
51
+ *
52
+ * Simple zone configuration for setting up test games.
53
+ * Zones can be configured with either a number (to create placeholder cards)
54
+ * or an array of LorcanaCardDefinitionInput (to create cards with actual definitions).
55
+ */
56
+ export type TestInitialState = {
57
+ /** Cards in hand - number or card definitions */
58
+ hand?: number | LorcanaCardDefinitionInput[];
59
+ /** Cards in deck - number or card definitions */
60
+ deck?: number | LorcanaCardDefinitionInput[];
61
+ /** Cards in play - number or card definitions */
62
+ play?: number | LorcanaCardDefinitionInput[];
63
+ /** Cards in inkwell - number or card definitions */
64
+ inkwell?: number | LorcanaCardDefinitionInput[];
65
+ /** Cards in discard - number or card definitions */
66
+ discard?: number | LorcanaCardDefinitionInput[];
67
+ /** Starting lore */
68
+ lore?: number;
69
+ };
70
+
71
+ /**
72
+ * Test Card Definition
73
+ *
74
+ * Minimal card definition for testing combat and stats
75
+ */
76
+ export type TestCardDefinition = {
77
+ id: string;
78
+ name?: string;
79
+ strength?: number;
80
+ willpower?: number;
81
+ lore?: number;
82
+ cost?: number;
83
+ };
84
+
85
+ /**
86
+ * Test Engine Options
87
+ */
88
+ export type TestEngineOptions = {
89
+ /** Skip pre-game phase (start directly in main game) */
90
+ skipPreGame?: boolean;
91
+ /** Optional RNG seed for deterministic tests */
92
+ seed?: string;
93
+ /** Enable debug logging */
94
+ debug?: boolean;
95
+ /** Optional card definitions for testing (with stats like strength, willpower) */
96
+ cardDefinitions?: Record<string, TestCardDefinition>;
97
+ };
98
+
99
+ /**
100
+ * Test Card Model
101
+ *
102
+ * Wraps a card definition and provides keyword checking methods.
103
+ * Used for testing that cards have the expected keywords.
104
+ */
105
+ export class TestCardModel {
106
+ constructor(private readonly card: LorcanaCardDefinitionInput) {}
107
+
108
+ /** Check if card has a specific keyword */
109
+ private hasKeywordAbility(keyword: string): boolean {
110
+ // Check abilities array (keyword abilities)
111
+ if (this.card.abilities) {
112
+ for (const ability of this.card.abilities) {
113
+ if (
114
+ ability.type === "keyword" &&
115
+ ability.keyword?.toLowerCase() === keyword.toLowerCase()
116
+ ) {
117
+ return true;
118
+ }
119
+ }
120
+ }
121
+
122
+ return false;
123
+ }
124
+
125
+ // Keyword methods (matching legacy TestEngine API)
126
+ hasBodyguard(): boolean {
127
+ return this.hasKeywordAbility("bodyguard");
128
+ }
129
+ hasSupport(): boolean {
130
+ return this.hasKeywordAbility("support");
131
+ }
132
+ hasSinger(): boolean {
133
+ return this.hasKeywordAbility("singer");
134
+ }
135
+ hasShift(): boolean {
136
+ return this.hasKeywordAbility("shift");
137
+ }
138
+ hasReckless(): boolean {
139
+ return this.hasKeywordAbility("reckless");
140
+ }
141
+ hasWard(): boolean {
142
+ return this.hasKeywordAbility("ward");
143
+ }
144
+
145
+ // Keyword properties (matching legacy TestEngine API)
146
+ get hasVanish(): boolean {
147
+ return this.hasKeywordAbility("vanish");
148
+ }
149
+ get hasEvasive(): boolean {
150
+ return this.hasKeywordAbility("evasive");
151
+ }
152
+ get hasChallenger(): boolean {
153
+ return this.hasKeywordAbility("challenger");
154
+ }
155
+ get hasResist(): boolean {
156
+ return this.hasKeywordAbility("resist");
157
+ }
158
+ get hasRush(): boolean {
159
+ return this.hasKeywordAbility("rush");
160
+ }
161
+ get hasAlert(): boolean {
162
+ return this.hasKeywordAbility("alert");
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Lorcana Test Engine
168
+ *
169
+ * Wraps a single RuleEngine for testing.
170
+ *
171
+ * Note: In the future, this could be extended to use multiple engines
172
+ * (authoritative + client engines) for more realistic multiplayer testing,
173
+ * but that requires syncing internal state which is complex.
174
+ */
175
+ export class LorcanaTestEngine {
176
+ /** Single authoritative engine */
177
+ public readonly engine: LorcanaEngine;
178
+
179
+ // Aliases for compatibility with legacy test patterns
180
+ public readonly authoritativeEngine: LorcanaEngine;
181
+ public readonly playerOneEngine: LorcanaEngine;
182
+ public readonly playerTwoEngine: LorcanaEngine;
183
+
184
+ /** Currently active player for move execution */
185
+ private activePlayerEngine: string = PLAYER_ONE;
186
+
187
+ /** Card definitions registry (mutable for dynamic card creation in tests) */
188
+ private cardDefinitions: Record<string, TestCardDefinition> = {};
189
+
190
+ /** Counter for generating unique card IDs */
191
+ private cardCounter = 0;
192
+
193
+ /** Registry of Lorcana card definitions placed in play (for getCardModel) */
194
+ private playedCardDefinitions: Map<string, LorcanaCardDefinitionInput> =
195
+ new Map();
196
+
197
+ constructor(
198
+ _playerOneState: TestInitialState = {},
199
+ _playerTwoState: TestInitialState = {},
200
+ opts: TestEngineOptions = { skipPreGame: true },
201
+ ) {
202
+ // Create players
203
+ const players = [
204
+ { id: PLAYER_ONE, name: "Player One" },
205
+ { id: PLAYER_TWO, name: "Player Two" },
206
+ ];
207
+
208
+ // Engine options
209
+ const engineOptions: RuleEngineOptions = {
210
+ seed: opts.seed || "test-seed-123",
211
+ };
212
+
213
+ // Initialize card definitions registry (mutable for dynamic card creation)
214
+ // Use provided definitions or empty object
215
+ this.cardDefinitions = opts.cardDefinitions || {};
216
+
217
+ // Create game definition with card definitions registry
218
+ // The registry will be a closure over this.cardDefinitions, so modifications
219
+ // to this.cardDefinitions will be visible to the registry
220
+ const gameDefinition = {
221
+ ...lorcanaGameDefinition,
222
+ cards: this.cardDefinitions,
223
+ };
224
+
225
+ // Create single engine instance
226
+ this.engine = new LorcanaEngine(gameDefinition, players, engineOptions);
227
+
228
+ // Aliases point to same engine
229
+ this.authoritativeEngine = this.engine;
230
+ this.playerOneEngine = this.engine;
231
+ this.playerTwoEngine = this.engine;
232
+
233
+ // Initialize zones with test cards
234
+ this.initializeZones(_playerOneState, _playerTwoState);
235
+
236
+ // If skipPreGame is true, fast-forward to main game
237
+ if (opts.skipPreGame) {
238
+ // Set OTP to skip chooseFirstPlayer phase
239
+ // @ts-expect-error - Accessing internal properties for testing setup
240
+ const internalState = this.engine.internalState;
241
+ if (internalState) {
242
+ internalState.otp = createPlayerId(PLAYER_ONE);
243
+ internalState.pendingMulligan = []; // Clear mulligan requirement
244
+ }
245
+
246
+ // Transition to main game segment by accessing flow manager's internal state
247
+ const flowManager = this.engine.getFlowManager();
248
+ if (flowManager) {
249
+ // @ts-expect-error - Accessing private property for testing setup
250
+ flowManager.currentGameSegment = "mainGame";
251
+ // @ts-expect-error - Accessing private property for testing setup
252
+ flowManager.currentPhase = "main";
253
+ // Set current player to PLAYER_ONE
254
+ // @ts-expect-error - Accessing private property for testing setup
255
+ flowManager.currentPlayer = createPlayerId(PLAYER_ONE);
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Check if a zone value is an array of card definitions
262
+ */
263
+ private isCardDefinitionsArray(
264
+ value: number | LorcanaCardDefinitionInput[] | undefined,
265
+ ): value is LorcanaCardDefinitionInput[] {
266
+ return Array.isArray(value);
267
+ }
268
+
269
+ /**
270
+ * Initialize zones with test cards
271
+ *
272
+ * BACKDOOR for testing: Accesses RuleEngine internal state to populate zones
273
+ * before any moves execute. This violates encapsulation but is necessary for
274
+ * AAA testing (Arrange-Act-Assert) where board state must be set up before moves.
275
+ *
276
+ * Supports both:
277
+ * - Numbers: Creates placeholder cards (e.g., { hand: 7 })
278
+ * - Card definitions: Creates real cards with the provided definitions (e.g., { play: [heiheiCard] })
279
+ *
280
+ * TODO: @drmxrcy/tcg-core should expose a proper TestEngine base class with this capability
281
+ */
282
+ private initializeZones(
283
+ playerOneState: TestInitialState,
284
+ playerTwoState: TestInitialState,
285
+ ) {
286
+ // Access internal state directly (testing backdoor)
287
+ const internalState = (this.engine as any).internalState;
288
+
289
+ if (!internalState) {
290
+ throw new Error("Cannot access engine internal state for test setup");
291
+ }
292
+
293
+ // Add safety check for expected internal structure
294
+ if (!(internalState.zones && internalState.cards)) {
295
+ throw new Error("Engine internal state structure has changed");
296
+ }
297
+
298
+ // Create zone operations and card operations using internal state
299
+ const zoneOps = createZoneOperations(internalState);
300
+ const cardOps = createCardOperations(internalState);
301
+
302
+ // Helper to initialize metadata for created cards
303
+ const initializeCardMetadata = (cardIds: string[]) => {
304
+ for (const cardId of cardIds) {
305
+ // Initialize with default Lorcana card metadata
306
+ cardOps.setCardMeta(createCardId(cardId), {
307
+ damage: 0,
308
+ state: "ready",
309
+ isDrying: true, // Characters start with summoning sickness
310
+ } as any);
311
+ }
312
+ };
313
+
314
+ // Helper to initialize a zone with either a number or card definitions
315
+ const initializeZone = (
316
+ zoneId: string,
317
+ playerId: string,
318
+ value: number | LorcanaCardDefinitionInput[] | undefined,
319
+ shuffle: boolean,
320
+ ) => {
321
+ if (value === undefined) return;
322
+
323
+ if (this.isCardDefinitionsArray(value)) {
324
+ // Handle card definitions array
325
+ for (const cardDef of value) {
326
+ // Register the card definition for getCardModel lookup
327
+ this.playedCardDefinitions.set(cardDef.id, cardDef);
328
+ }
329
+ // Create placeholder cards for the count (cards are registered for keyword lookup)
330
+ const cardIds = zoneOps.createDeck({
331
+ zoneId: zoneId as any,
332
+ playerId: createPlayerId(playerId),
333
+ cardCount: value.length,
334
+ shuffle,
335
+ });
336
+ initializeCardMetadata(cardIds);
337
+ } else {
338
+ // Handle number - create placeholder cards
339
+ const cardIds = zoneOps.createDeck({
340
+ zoneId: zoneId as any,
341
+ playerId: createPlayerId(playerId),
342
+ cardCount: value,
343
+ shuffle,
344
+ });
345
+ initializeCardMetadata(cardIds);
346
+ }
347
+ };
348
+
349
+ // Initialize zones for player one
350
+ initializeZone("hand", PLAYER_ONE, playerOneState.hand, false);
351
+ initializeZone("deck", PLAYER_ONE, playerOneState.deck, true);
352
+ initializeZone("play", PLAYER_ONE, playerOneState.play, false);
353
+ initializeZone("inkwell", PLAYER_ONE, playerOneState.inkwell, false);
354
+ initializeZone("discard", PLAYER_ONE, playerOneState.discard, false);
355
+
356
+ // Initialize zones for player two
357
+ initializeZone("hand", PLAYER_TWO, playerTwoState.hand, false);
358
+ initializeZone("deck", PLAYER_TWO, playerTwoState.deck, true);
359
+ initializeZone("play", PLAYER_TWO, playerTwoState.play, false);
360
+ initializeZone("inkwell", PLAYER_TWO, playerTwoState.inkwell, false);
361
+ initializeZone("discard", PLAYER_TWO, playerTwoState.discard, false);
362
+ }
363
+
364
+ /**
365
+ * Get a TestCardModel for a card definition
366
+ *
367
+ * Returns a wrapper around the card definition that provides keyword checking methods.
368
+ * This allows tests to verify card keywords using the same API as the legacy TestEngine.
369
+ *
370
+ * @example
371
+ * ```typescript
372
+ * const testEngine = new LorcanaTestEngine({ play: [heiheiBoatSnack] });
373
+ * const cardUnderTest = testEngine.getCardModel(heiheiBoatSnack);
374
+ * expect(cardUnderTest.hasSupport()).toBe(true);
375
+ * ```
376
+ */
377
+ getCardModel(cardDef: LorcanaCardDefinitionInput): TestCardModel {
378
+ // Prefer the engine-registered definition if it exists, as it may have additional state
379
+ const registered = this.playedCardDefinitions.get(cardDef.id);
380
+ const cardDefToUse = registered ?? cardDef;
381
+ return new TestCardModel(cardDefToUse);
382
+ }
383
+
384
+ /**
385
+ * Change the active player (for move execution)
386
+ */
387
+ changeActivePlayer(playerId: string) {
388
+ if (playerId !== PLAYER_ONE && playerId !== PLAYER_TWO) {
389
+ throw new Error(`Invalid player ID: ${playerId}`);
390
+ }
391
+ this.activePlayerEngine = playerId;
392
+ return this; // For chaining
393
+ }
394
+
395
+ // ========== State Inspection ==========
396
+
397
+ /**
398
+ * Get game state
399
+ */
400
+ getState(): LorcanaGameState {
401
+ return this.engine.getState();
402
+ }
403
+
404
+ /**
405
+ * Get context/flow state
406
+ * Uses FlowManager to get current phase, segment, turn, etc.
407
+ *
408
+ * Note: Since RuleEngine doesn't expose internal state directly,
409
+ * we access it via type casting. This is acceptable for test utilities.
410
+ */
411
+ getCtx() {
412
+ const flowManager = this.engine.getFlowManager();
413
+ if (!flowManager) {
414
+ throw new Error("No flow manager available");
415
+ }
416
+
417
+ // Access internal state for test purposes
418
+ // @ts-expect-error - Accessing private property for testing
419
+ const internalState = this.engine.internalState;
420
+
421
+ // Access tracker system for test purposes
422
+ // @ts-expect-error - Accessing private property for testing
423
+ const trackerSystem = this.engine.trackerSystem;
424
+
425
+ return {
426
+ currentPhase: flowManager.getCurrentPhase(),
427
+ currentSegment: flowManager.getCurrentSegment(),
428
+ turnNumber: flowManager.getTurnNumber(),
429
+ currentPlayer: flowManager.getCurrentPlayer(),
430
+ otp: internalState?.otp,
431
+ choosingFirstPlayer: internalState?.choosingFirstPlayer,
432
+ pendingMulligan: internalState?.pendingMulligan,
433
+ trackers: trackerSystem
434
+ ? {
435
+ check: (name: string, playerId: any) =>
436
+ trackerSystem.check(name, playerId),
437
+ mark: (name: string, playerId: any) =>
438
+ trackerSystem.mark(name, playerId),
439
+ unmark: (name: string, playerId: any) =>
440
+ trackerSystem.unmark(name, playerId),
441
+ }
442
+ : undefined,
443
+ flow: {
444
+ currentPhase: flowManager.getCurrentPhase(),
445
+ },
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Get current game segment
451
+ */
452
+ getGameSegment(): string | undefined {
453
+ return this.engine.getFlowManager()?.getCurrentSegment();
454
+ }
455
+
456
+ /**
457
+ * Get current game phase
458
+ */
459
+ getGamePhase(): string | undefined {
460
+ return this.engine.getFlowManager()?.getCurrentPhase();
461
+ }
462
+
463
+ /**
464
+ * Get current turn player
465
+ *
466
+ * During startingAGame segment, there is NO turn player until OTP is chosen.
467
+ * During mainGame segment, turn player is the currentPlayer from flow.
468
+ */
469
+ getTurnPlayer(): string | undefined {
470
+ const segment = this.getGameSegment();
471
+ if (segment === "startingAGame") {
472
+ // No turn player during startingAGame until OTP is chosen
473
+ const otp = this.getCtx().otp;
474
+ return otp;
475
+ }
476
+
477
+ // During mainGame, turn player is the currentPlayer
478
+ return this.engine.getFlowManager()?.getCurrentPlayer() || undefined;
479
+ }
480
+
481
+ /**
482
+ * Get turn number
483
+ */
484
+ getTurnNumber(): number {
485
+ return this.engine.getFlowManager()?.getTurnNumber() || 0;
486
+ }
487
+
488
+ /**
489
+ * Get priority players
490
+ *
491
+ * Priority player is who can currently take actions.
492
+ * During startingAGame, priority = currentPlayer (choosingFirstPlayer or OTP for mulligan)
493
+ * During mainGame, priority = turn player
494
+ */
495
+ getPriorityPlayers(): string[] {
496
+ const segment = this.getGameSegment();
497
+ const currentPlayer = this.engine.getFlowManager()?.getCurrentPlayer();
498
+
499
+ if (segment === "startingAGame") {
500
+ // During startingAGame, priority = currentPlayer
501
+ // (which is set to choosingFirstPlayer, then OTP for mulligan)
502
+ return currentPlayer ? [currentPlayer] : [];
503
+ }
504
+
505
+ // During mainGame, priority = turn player
506
+ const turnPlayer = this.getTurnPlayer();
507
+ return turnPlayer ? [turnPlayer] : [];
508
+ }
509
+
510
+ // ========== Move Execution ==========
511
+
512
+ /**
513
+ * Execute a move
514
+ */
515
+ private executeMove(moveId: keyof LorcanaMoveParams, params: any) {
516
+ const playerId = createPlayerId(this.activePlayerEngine);
517
+
518
+ // Execute on engine
519
+ const result = this.engine.executeMove(moveId, {
520
+ playerId,
521
+ params,
522
+ });
523
+
524
+ if (!result.success) {
525
+ throw new Error(`Move failed: ${result.error}`);
526
+ }
527
+
528
+ return result;
529
+ }
530
+
531
+ // ========== Setup Moves ==========
532
+
533
+ /**
534
+ * Choose who goes first
535
+ */
536
+ chooseWhoGoesFirst(playerId: string) {
537
+ return this.executeMove("chooseWhoGoesFirstMove", {
538
+ playerId: createPlayerId(playerId),
539
+ });
540
+ }
541
+
542
+ /**
543
+ * Alter hand (mulligan)
544
+ */
545
+ alterHand(cardsToMulligan: string[]) {
546
+ const playerId = createPlayerId(this.activePlayerEngine);
547
+ return this.executeMove("alterHand", {
548
+ playerId,
549
+ cardsToMulligan,
550
+ });
551
+ }
552
+
553
+ // ========== Resource Moves ==========
554
+
555
+ /**
556
+ * Put a card into the inkwell
557
+ */
558
+ putCardInInkwell(cardId: string) {
559
+ return this.executeMove("putACardIntoTheInkwell", {
560
+ cardId,
561
+ });
562
+ }
563
+
564
+ // ========== Core Game Moves ==========
565
+
566
+ /**
567
+ * Quest with a character to gain lore
568
+ */
569
+ quest(cardId: string) {
570
+ return this.executeMove("quest", {
571
+ cardId,
572
+ });
573
+ }
574
+
575
+ /**
576
+ * Challenge another character (combat)
577
+ */
578
+ challenge(attackerId: string, defenderId: string) {
579
+ return this.executeMove("challenge", {
580
+ attackerId,
581
+ defenderId,
582
+ });
583
+ }
584
+
585
+ // ========== Standard Moves ==========
586
+
587
+ /**
588
+ * Pass turn to next player
589
+ *
590
+ * Automatically syncs activePlayerEngine with flow manager's current player
591
+ * after turn transition completes.
592
+ */
593
+ passTurn() {
594
+ const result = this.executeMove("passTurn", {});
595
+
596
+ // Sync activePlayerEngine with flow manager's current player
597
+ // This ensures subsequent moves execute as the correct player
598
+ const currentPlayer = this.engine.getFlowManager()?.getCurrentPlayer();
599
+ if (currentPlayer) {
600
+ this.activePlayerEngine = currentPlayer;
601
+ }
602
+
603
+ return result;
604
+ }
605
+
606
+ // ========== Move Enumeration ==========
607
+
608
+ /**
609
+ * Get available moves for a player
610
+ *
611
+ * @param playerId - Player to get moves for
612
+ * @returns Array of available move IDs
613
+ */
614
+ getAvailableMoves(playerId: string): string[] {
615
+ return this.engine.getAvailableMoves(createPlayerId(playerId));
616
+ }
617
+
618
+ /**
619
+ * Get detailed information about available moves
620
+ *
621
+ * @param playerId - Player to get moves for
622
+ * @returns Array of move information objects
623
+ */
624
+ getAvailableMovesDetailed(playerId: string) {
625
+ return this.engine.getAvailableMovesDetailed(createPlayerId(playerId));
626
+ }
627
+
628
+ /**
629
+ * Enumerate valid parameters for a move
630
+ *
631
+ * @param moveId - Move to enumerate parameters for
632
+ * @param playerId - Player attempting the move
633
+ * @returns Valid parameter combinations or null
634
+ */
635
+ enumerateMoveParameters(moveId: string, playerId: string) {
636
+ return this.engine.enumerateMoveParameters(
637
+ moveId as keyof LorcanaMoveParams,
638
+ createPlayerId(playerId),
639
+ );
640
+ }
641
+
642
+ /**
643
+ * Get explanation of why a move cannot be executed
644
+ *
645
+ * @param moveId - Move to check
646
+ * @param params - Parameters to use for the move
647
+ * @returns Error information or null
648
+ */
649
+ whyCannotExecuteMove(moveId: string, params: any) {
650
+ return this.engine.whyCannotExecuteMove(
651
+ moveId as keyof LorcanaMoveParams,
652
+ params,
653
+ );
654
+ }
655
+
656
+ // ========== Zone Access Helpers ==========
657
+
658
+ /**
659
+ * Get cards in a zone for a player
660
+ */
661
+ getZone(zoneId: string, playerId: string): string[] {
662
+ // Access internal state directly (testing backdoor)
663
+ const internalState = (this.engine as any).internalState;
664
+
665
+ if (!internalState) {
666
+ return [];
667
+ }
668
+
669
+ // Add safety check for expected internal structure
670
+ if (!(internalState.zones && internalState.cards)) {
671
+ console.warn("Engine internal state structure has changed");
672
+ return [];
673
+ }
674
+
675
+ // Create zone operations using internal state
676
+ const zoneOps = createZoneOperations(internalState);
677
+
678
+ return zoneOps.getCardsInZone(zoneId as any, createPlayerId(playerId));
679
+ }
680
+
681
+ /**
682
+ * Get lore total for a player
683
+ */
684
+ getLore(playerId: string): number {
685
+ const state = this.getState();
686
+ return state.external.loreScores[createPlayerId(playerId)] || 0;
687
+ }
688
+
689
+ /**
690
+ * Get damage on a card
691
+ */
692
+ getDamage(cardId: string): number {
693
+ // Access internal state directly (testing backdoor)
694
+ const internalState = (this.engine as any).internalState;
695
+
696
+ if (!internalState) {
697
+ return 0;
698
+ }
699
+
700
+ // Get card metadata which tracks damage
701
+ const cardMeta = internalState?.cardMetas?.[cardId];
702
+ return cardMeta?.damage || 0;
703
+ }
704
+
705
+ /**
706
+ * Get card metadata (for testing)
707
+ */
708
+ getCardMeta(cardId: string): LorcanaCardMeta | undefined {
709
+ // Access internal state directly (testing backdoor)
710
+ const internalState = (this.engine as any).internalState;
711
+
712
+ if (!internalState) {
713
+ return undefined;
714
+ }
715
+
716
+ return internalState?.cardMetas?.[cardId];
717
+ }
718
+
719
+ /**
720
+ * Create a test character in play with specific stats
721
+ *
722
+ * BACKDOOR for testing: Creates a character card with stats directly in play zone.
723
+ * Useful for testing combat mechanics that require strength/willpower.
724
+ *
725
+ * @param playerId - Player who owns the character
726
+ * @param stats - Character stats (strength, willpower, etc.)
727
+ * @returns Card ID of the created character
728
+ *
729
+ * @example
730
+ * ```typescript
731
+ * const strongChar = testEngine.createCharacterInPlay(PLAYER_ONE, {
732
+ * strength: 5,
733
+ * willpower: 7,
734
+ * });
735
+ * ```
736
+ */
737
+ createCharacterInPlay(
738
+ playerId: string,
739
+ stats: { strength?: number; willpower?: number; lore?: number } = {},
740
+ ): string {
741
+ // Access internal state directly (testing backdoor)
742
+ const internalState = (this.engine as any).internalState;
743
+
744
+ if (!internalState) {
745
+ throw new Error("Cannot access engine internal state for test setup");
746
+ }
747
+
748
+ // Create zone operations using internal state
749
+ const zoneOps = createZoneOperations(internalState);
750
+ const pid = createPlayerId(playerId);
751
+
752
+ // Generate unique card ID using counter
753
+ const cardId = `test-character-${this.cardCounter++}`;
754
+
755
+ // Manually add card to zone (bypassing createDeck which generates deterministic IDs)
756
+ // Access internal state to directly add card
757
+ if (!internalState.zones["play"]) {
758
+ throw new Error("Play zone not found");
759
+ }
760
+ internalState.zones["play"].cardIds.push(cardId);
761
+ internalState.cards[cardId] = {
762
+ definitionId: "placeholder",
763
+ owner: pid,
764
+ controller: pid,
765
+ zone: "play" as any,
766
+ position: internalState.zones["play"].cardIds.length - 1,
767
+ };
768
+
769
+ // Add card definition to registry (modifying this.cardDefinitions which the registry wraps)
770
+ this.cardDefinitions[cardId] = {
771
+ id: cardId,
772
+ name: "Test Character",
773
+ strength: stats.strength ?? 1,
774
+ willpower: stats.willpower ?? 1,
775
+ lore: stats.lore ?? 1,
776
+ };
777
+
778
+ // Initialize card metadata (ready to use, no summoning sickness)
779
+ const cardOps = createCardOperations(internalState);
780
+ cardOps.setCardMeta(createCardId(cardId), {
781
+ damage: 0,
782
+ state: "ready",
783
+ isDrying: false, // No summoning sickness - ready to use immediately
784
+ } as any);
785
+
786
+ return cardId;
787
+ }
788
+
789
+ // ========== Card Manipulation ==========
790
+
791
+ /**
792
+ * Move a card from one zone to another
793
+ * Useful for test setup
794
+ */
795
+ moveCard(cardId: string, targetZone: string, playerId?: string) {
796
+ const internalState = (this.engine as any).internalState;
797
+ const zoneOps = createZoneOperations(internalState);
798
+
799
+ zoneOps.moveCard({
800
+ cardId: createCardId(cardId),
801
+ targetZoneId: targetZone as any,
802
+ });
803
+ }
804
+
805
+ // ========== Cleanup ==========
806
+
807
+ /**
808
+ * Dispose of resources
809
+ */
810
+ dispose() {
811
+ // No cleanup needed for now
812
+ }
813
+ }