@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.
- package/README.md +160 -0
- package/package.json +45 -0
- package/src/__tests__/integration/move-enumeration.test.ts +256 -0
- package/src/__tests__/rules/section-01-concepts.test.ts +426 -0
- package/src/__tests__/rules/section-03-gameplay.test.ts +298 -0
- package/src/__tests__/rules/section-04-turn-structure.test.ts +708 -0
- package/src/__tests__/rules/section-05-cards.test.ts +158 -0
- package/src/__tests__/rules/section-06-card-types.test.ts +342 -0
- package/src/__tests__/rules/section-07-abilities.test.ts +333 -0
- package/src/__tests__/rules/section-08-zones.test.ts +231 -0
- package/src/__tests__/rules/section-09-damage.test.ts +148 -0
- package/src/__tests__/rules/section-10-keywords.test.ts +469 -0
- package/src/__tests__/spec-01-foundation-types.test.ts +534 -0
- package/src/__tests__/spec-02-zones-card-states.test.ts +295 -0
- package/src/card-utils.ts +302 -0
- package/src/cards/README.md +296 -0
- package/src/cards/abilities/index.ts +175 -0
- package/src/cards/index.ts +10 -0
- package/src/deck-validation.ts +175 -0
- package/src/engine/lorcana-engine.ts +625 -0
- package/src/game-definition/__tests__/core-zone-integration.test.ts +553 -0
- package/src/game-definition/__tests__/zone-operations.test.ts +362 -0
- package/src/game-definition/__tests__/zones.test.ts +176 -0
- package/src/game-definition/definition.ts +45 -0
- package/src/game-definition/flow/turn-flow.ts +216 -0
- package/src/game-definition/index.ts +31 -0
- package/src/game-definition/moves/abilities/activate-ability.ts +51 -0
- package/src/game-definition/moves/core/__tests__/move-parameter-enumeration.test.ts +316 -0
- package/src/game-definition/moves/core/challenge.test.ts +545 -0
- package/src/game-definition/moves/core/challenge.ts +81 -0
- package/src/game-definition/moves/core/play-card.ts +83 -0
- package/src/game-definition/moves/core/quest.test.ts +448 -0
- package/src/game-definition/moves/core/quest.ts +49 -0
- package/src/game-definition/moves/debug/manual-exert.ts +36 -0
- package/src/game-definition/moves/effects/resolve-bag.ts +35 -0
- package/src/game-definition/moves/effects/resolve-effect.ts +34 -0
- package/src/game-definition/moves/index.ts +85 -0
- package/src/game-definition/moves/locations/move-character-to-location.ts +42 -0
- package/src/game-definition/moves/resources/put-card-into-inkwell.test.ts +462 -0
- package/src/game-definition/moves/resources/put-card-into-inkwell.ts +51 -0
- package/src/game-definition/moves/setup/alter-hand.test.ts +395 -0
- package/src/game-definition/moves/setup/alter-hand.ts +210 -0
- package/src/game-definition/moves/setup/choose-first-player.test.ts +450 -0
- package/src/game-definition/moves/setup/choose-first-player.ts +105 -0
- package/src/game-definition/moves/setup/draw-cards.ts +37 -0
- package/src/game-definition/moves/songs/sing-together.ts +47 -0
- package/src/game-definition/moves/songs/sing.ts +56 -0
- package/src/game-definition/moves/standard/concede.test.ts +189 -0
- package/src/game-definition/moves/standard/concede.ts +72 -0
- package/src/game-definition/moves/standard/pass-turn.ts +49 -0
- package/src/game-definition/setup/game-setup.ts +19 -0
- package/src/game-definition/trackers/tracker-config.ts +23 -0
- package/src/game-definition/win-conditions/lore-victory.ts +26 -0
- package/src/game-definition/zone-operations.ts +405 -0
- package/src/game-definition/zones/zone-configs.ts +59 -0
- package/src/game-definition/zones.ts +283 -0
- package/src/index.ts +189 -0
- package/src/operations/index.ts +7 -0
- package/src/operations/lorcana-operations.ts +288 -0
- package/src/queries/README.md +56 -0
- package/src/resolvers/__tests__/condition-resolver.test.ts +301 -0
- package/src/resolvers/condition-registry.ts +70 -0
- package/src/resolvers/condition-resolver.ts +85 -0
- package/src/resolvers/conditions/basic.ts +81 -0
- package/src/resolvers/conditions/card-state.ts +12 -0
- package/src/resolvers/conditions/comparison.ts +102 -0
- package/src/resolvers/conditions/existence.ts +219 -0
- package/src/resolvers/conditions/history.ts +68 -0
- package/src/resolvers/conditions/index.ts +15 -0
- package/src/resolvers/conditions/logical.ts +55 -0
- package/src/resolvers/conditions/resolution.ts +41 -0
- package/src/resolvers/conditions/revealed.ts +42 -0
- package/src/resolvers/conditions/zone.ts +84 -0
- package/src/setup.test.ts +18 -0
- package/src/targeting/__tests__/filter-resolver.test.ts +294 -0
- package/src/targeting/__tests__/real-cards-targeting.test.ts +303 -0
- package/src/targeting/__tests__/targeting-dsl.test.ts +386 -0
- package/src/targeting/enum-expansion.ts +387 -0
- package/src/targeting/filter-registry.ts +322 -0
- package/src/targeting/filter-resolver.ts +145 -0
- package/src/targeting/index.ts +91 -0
- package/src/targeting/lorcana-target-dsl.ts +495 -0
- package/src/targeting/targeting-ui.ts +407 -0
- package/src/testing/index.ts +14 -0
- package/src/testing/lorcana-test-engine.ts +813 -0
- package/src/types/README.md +303 -0
- package/src/types/__tests__/lorcana-state.test.ts +168 -0
- package/src/types/__tests__/move-enumeration.test.ts +179 -0
- package/src/types/branded-types.ts +106 -0
- package/src/types/game-state.ts +184 -0
- package/src/types/index.ts +87 -0
- package/src/types/keywords.ts +187 -0
- package/src/types/lorcana-state.ts +260 -0
- package/src/types/move-enumeration.ts +126 -0
- package/src/types/move-params.ts +216 -0
- package/src/validators/index.ts +7 -0
- package/src/validators/move-validators.ts +374 -0
- package/src/zones/card-state.ts +234 -0
- package/src/zones/index.ts +42 -0
- 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
|
+
}
|