@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,288 @@
1
+ /**
2
+ * Lorcana Operations Layer
3
+ *
4
+ * Domain-specific operations for Disney Lorcana.
5
+ * Provides high-level Lorcana semantics on top of generic engine operations.
6
+ *
7
+ * These operations encapsulate Lorcana rules and can be used across multiple moves.
8
+ * Each operation is pure and operates through the MoveContext API.
9
+ */
10
+
11
+ import type { CardId, MoveContext, PlayerId } from "@drmxrcy/tcg-core";
12
+ import type { Draft } from "immer";
13
+ import type { LorcanaCardMeta, LorcanaGameState } from "../types";
14
+
15
+ /**
16
+ * Lorcana Operations Type
17
+ *
18
+ * Extension of MoveContext with Lorcana-specific operations.
19
+ * This type can be used in move reducers for cleaner code.
20
+ */
21
+ export type LorcanaOperations = {
22
+ /**
23
+ * Exert a card (turn sideways)
24
+ *
25
+ * Rule 5.1.2: Exerted cards are turned sideways
26
+ *
27
+ * @param cardId - Card to exert
28
+ */
29
+ exertCard(cardId: CardId): void;
30
+
31
+ /**
32
+ * Ready a card (return to upright position)
33
+ *
34
+ * Rule 4.2.1.1: Cards are readied at start of turn
35
+ *
36
+ * @param cardId - Card to ready
37
+ */
38
+ readyCard(cardId: CardId): void;
39
+
40
+ /**
41
+ * Add lore to a player's total
42
+ *
43
+ * Rule 4.3.5.8: Gain lore from questing
44
+ * Rule 1.9.1.1: Win condition - first to 20 lore
45
+ *
46
+ * @param draft - Game state draft
47
+ * @param playerId - Player gaining lore
48
+ * @param amount - Amount of lore to add
49
+ * @returns New lore total
50
+ */
51
+ addLore(
52
+ draft: Draft<LorcanaGameState>,
53
+ playerId: PlayerId,
54
+ amount: number,
55
+ ): number;
56
+
57
+ /**
58
+ * Get lore total for a player
59
+ *
60
+ * @param state - Game state
61
+ * @param playerId - Player to check
62
+ * @returns Current lore total
63
+ */
64
+ getLore(state: LorcanaGameState, playerId: PlayerId): number;
65
+
66
+ /**
67
+ * Add damage to a card
68
+ *
69
+ * Rule 9.1: Each counter represents 1 damage
70
+ * Rule 1.9.1.3: Banished when damage >= Willpower
71
+ *
72
+ * @param cardId - Card taking damage
73
+ * @param amount - Damage amount
74
+ * @returns New damage total
75
+ */
76
+ addDamage(cardId: CardId, amount: number): number;
77
+
78
+ /**
79
+ * Get damage on a card
80
+ *
81
+ * @param cardId - Card to check
82
+ * @returns Current damage amount
83
+ */
84
+ getDamage(cardId: CardId): number;
85
+
86
+ /**
87
+ * Remove damage from a card
88
+ *
89
+ * @param cardId - Card to heal
90
+ * @param amount - Amount to heal (default: all damage)
91
+ * @returns New damage total
92
+ */
93
+ removeDamage(cardId: CardId, amount?: number): number;
94
+
95
+ /**
96
+ * Mark a card as "drying" (played this turn)
97
+ *
98
+ * Rule 4.2.2.1: Characters are "drying" the turn they're played
99
+ *
100
+ * @param cardId - Card that was played
101
+ */
102
+ markAsDrying(cardId: CardId): void;
103
+
104
+ /**
105
+ * Mark a card as "dry" (ready to act)
106
+ *
107
+ * Rule 4.2.2.1: Becomes dry at Set step of next turn
108
+ *
109
+ * @param cardId - Card to mark as dry
110
+ */
111
+ markAsDry(cardId: CardId): void;
112
+
113
+ /**
114
+ * Check if a card is "drying"
115
+ *
116
+ * @param cardId - Card to check
117
+ * @returns True if card was played this turn
118
+ */
119
+ isDrying(cardId: CardId): boolean;
120
+
121
+ /**
122
+ * Check if a card is exerted
123
+ *
124
+ * @param cardId - Card to check
125
+ * @returns True if card is exerted
126
+ */
127
+ isExerted(cardId: CardId): boolean;
128
+
129
+ /**
130
+ * Get card type from registry
131
+ *
132
+ * @param cardId - Card to check
133
+ * @returns Card type (character, action, item, location)
134
+ */
135
+ getCardType(cardId: CardId): string | undefined;
136
+
137
+ /**
138
+ * Move a character to a location
139
+ *
140
+ * Rule 6.5: Characters can move to locations
141
+ *
142
+ * @param characterId - Character to move
143
+ * @param locationId - Target location
144
+ */
145
+ moveToLocation(characterId: CardId, locationId: CardId): void;
146
+
147
+ /**
148
+ * Remove a character from a location
149
+ *
150
+ * @param characterId - Character to move
151
+ */
152
+ leaveLocation(characterId: CardId): void;
153
+
154
+ /**
155
+ * Get the location a character is at
156
+ *
157
+ * @param characterId - Character to check
158
+ * @returns Location ID, or undefined if not at a location
159
+ */
160
+ getLocation(characterId: CardId): CardId | undefined;
161
+ };
162
+
163
+ /**
164
+ * Create Lorcana operations from a MoveContext
165
+ *
166
+ * Factory function that creates Lorcana-specific operations
167
+ * using the provided context's zones, cards, and other APIs.
168
+ *
169
+ * @param context - Move context
170
+ * @returns Lorcana operations object
171
+ *
172
+ * @example
173
+ * ```typescript
174
+ * // In a move reducer:
175
+ * const ops = createLorcanaOperations(context);
176
+ * ops.exertCard(cardId);
177
+ * ops.addLore(draft, playerId, 2);
178
+ * ```
179
+ */
180
+ export function createLorcanaOperations<TParams>(
181
+ context: MoveContext<TParams, LorcanaCardMeta>,
182
+ ): LorcanaOperations {
183
+ return {
184
+ exertCard(cardId: CardId): void {
185
+ context.cards.updateCardMeta(cardId, { state: "exerted" });
186
+ },
187
+
188
+ readyCard(cardId: CardId): void {
189
+ context.cards.updateCardMeta(cardId, { state: "ready" });
190
+ },
191
+
192
+ addLore(
193
+ draft: Draft<LorcanaGameState>,
194
+ playerId: PlayerId,
195
+ amount: number,
196
+ ): number {
197
+ const current = draft.external.loreScores[playerId] ?? 0;
198
+ const newTotal = current + amount;
199
+ draft.external.loreScores[playerId] = newTotal;
200
+
201
+ // Check win condition (Rule 1.9.1.1)
202
+ if (newTotal >= 20 && context.endGame) {
203
+ context.endGame({
204
+ winner: playerId,
205
+ reason: "lore_victory",
206
+ metadata: { finalLore: newTotal },
207
+ });
208
+ }
209
+
210
+ return newTotal;
211
+ },
212
+
213
+ getLore(state: LorcanaGameState, playerId: PlayerId): number {
214
+ return state.external.loreScores[playerId] ?? 0;
215
+ },
216
+
217
+ addDamage(cardId: CardId, amount: number): number {
218
+ const current = context.cards.getCardMeta(cardId)?.damage ?? 0;
219
+ const newDamage = current + amount;
220
+ context.cards.updateCardMeta(cardId, { damage: newDamage });
221
+ return newDamage;
222
+ },
223
+
224
+ getDamage(cardId: CardId): number {
225
+ return context.cards.getCardMeta(cardId)?.damage ?? 0;
226
+ },
227
+
228
+ removeDamage(cardId: CardId, amount?: number): number {
229
+ const current = context.cards.getCardMeta(cardId)?.damage ?? 0;
230
+ const newDamage =
231
+ amount === undefined ? 0 : Math.max(0, current - amount);
232
+ context.cards.updateCardMeta(cardId, { damage: newDamage });
233
+ return newDamage;
234
+ },
235
+
236
+ markAsDrying(cardId: CardId): void {
237
+ context.cards.updateCardMeta(cardId, { isDrying: true });
238
+ },
239
+
240
+ markAsDry(cardId: CardId): void {
241
+ context.cards.updateCardMeta(cardId, { isDrying: false });
242
+ },
243
+
244
+ isDrying(cardId: CardId): boolean {
245
+ return context.cards.getCardMeta(cardId)?.isDrying ?? false;
246
+ },
247
+
248
+ isExerted(cardId: CardId): boolean {
249
+ return context.cards.getCardMeta(cardId)?.state === "exerted";
250
+ },
251
+
252
+ getCardType(cardId: CardId): string | undefined {
253
+ const card = context.registry?.getCard(cardId);
254
+ return card?.type;
255
+ },
256
+
257
+ moveToLocation(characterId: CardId, locationId: CardId): void {
258
+ context.cards.updateCardMeta(characterId, { atLocationId: locationId });
259
+ },
260
+
261
+ leaveLocation(characterId: CardId): void {
262
+ context.cards.updateCardMeta(characterId, { atLocationId: undefined });
263
+ },
264
+
265
+ getLocation(characterId: CardId): CardId | undefined {
266
+ return context.cards.getCardMeta(characterId)?.atLocationId;
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Helper function to use in move reducers
273
+ *
274
+ * Provides a shorthand for creating operations in reducers.
275
+ *
276
+ * @param context - Move context
277
+ * @returns Lorcana operations
278
+ *
279
+ * @example
280
+ * ```typescript
281
+ * reducer: (draft, context) => {
282
+ * const ops = useLorcanaOps(context);
283
+ * ops.exertCard(context.params.cardId);
284
+ * ops.addLore(draft, context.playerId, 2);
285
+ * }
286
+ * ```
287
+ */
288
+ export const useLorcanaOps = createLorcanaOperations;
@@ -0,0 +1,56 @@
1
+ # Queries
2
+
3
+ This directory contains helper functions for querying Lorcana game state.
4
+
5
+ ## Purpose
6
+
7
+ Provides type-safe, reusable functions for reading game state without modifying it. These queries are used by move validators, ability effects, and UI integrations.
8
+
9
+ ## Structure
10
+
11
+ - **`card-queries.ts`** - Query cards and their properties
12
+ - **`game-queries.ts`** - Query game-level state
13
+ - **`player-queries.ts`** - Query player-specific state
14
+ - **`zone-queries.ts`** - Query zone contents
15
+ - **`ability-queries.ts`** - Query available abilities
16
+ - **`index.ts`** - Public exports
17
+
18
+ ## Example Queries
19
+
20
+ ```typescript
21
+ // Get all ready characters a player controls
22
+ export const getReadyCharacters = (
23
+ state: LorcanaState,
24
+ playerId: PlayerId
25
+ ): CardId[] => {
26
+ return getCardsInZone(state, "play", playerId).filter(
27
+ cardId => !isExerted(state, cardId)
28
+ );
29
+ };
30
+
31
+ // Check if player can afford to play a card
32
+ export const canAffordCard = (
33
+ state: LorcanaState,
34
+ playerId: PlayerId,
35
+ cardId: CardId
36
+ ): boolean => {
37
+ const cost = getCardCost(state, cardId);
38
+ const available = getAvailableInk(state, playerId);
39
+ return available >= cost;
40
+ };
41
+
42
+ // Get characters that can quest
43
+ export const getQuestableCharacters = (
44
+ state: LorcanaState,
45
+ playerId: PlayerId
46
+ ): CardId[] => {
47
+ return getReadyCharacters(state, playerId).filter(
48
+ cardId => canQuest(state, cardId)
49
+ );
50
+ };
51
+ ```
52
+
53
+ ## References
54
+
55
+ - See `@packages/core` for base query utilities
56
+
@@ -0,0 +1,301 @@
1
+ import { beforeEach, describe, expect, it } from "bun:test";
2
+ import type { CardId, PlayerId, ZoneId } from "@drmxrcy/tcg-core";
3
+ import { isConditionMet } from "../condition-resolver";
4
+ import "../conditions/index"; // Register all
5
+ import type { CardInstance, CardRegistry } from "@drmxrcy/tcg-core";
6
+ import type {
7
+ Condition,
8
+ HasNamedCharacterCondition,
9
+ LorcanaCardDefinition,
10
+ TurnCondition,
11
+ UsedShiftCondition,
12
+ } from "@drmxrcy/tcg-lorcana-types";
13
+ import {
14
+ createDefaultCardMeta,
15
+ createInitialLorcanaState,
16
+ type LorcanaCardMeta,
17
+ type LorcanaGameState,
18
+ } from "../../types/game-state";
19
+
20
+ describe("Condition Resolver", () => {
21
+ let state: LorcanaGameState;
22
+ let registry: CardRegistry<LorcanaCardDefinition>;
23
+ let sourceCard: CardInstance<LorcanaCardMeta>;
24
+
25
+ beforeEach(() => {
26
+ state = createInitialLorcanaState(
27
+ "player1" as PlayerId,
28
+ "player2" as PlayerId,
29
+ "player1" as PlayerId,
30
+ );
31
+ // Explicitly seed lore scores for comparison tests
32
+ state.external.loreScores = {
33
+ player1: 10,
34
+ player2: 5,
35
+ } as Record<PlayerId, number>;
36
+
37
+ registry = {
38
+ getCard: (id: string) => {
39
+ if (id === "def-elsa")
40
+ return {
41
+ id: "def-elsa",
42
+ name: "Elsa",
43
+ fullName: "Elsa - Snow Queen",
44
+ cardType: "character",
45
+ inkType: ["amethyst"],
46
+ cost: 3,
47
+ inkable: true,
48
+ set: "1",
49
+ } as LorcanaCardDefinition;
50
+ return undefined;
51
+ },
52
+ hasCard: () => true,
53
+ getAllCards: () => [],
54
+ } as any;
55
+
56
+ sourceCard = {
57
+ id: "card-1" as CardId,
58
+ definitionId: "def-elsa",
59
+ owner: "player1" as PlayerId,
60
+ controller: "player1" as PlayerId,
61
+ zone: "play" as ZoneId,
62
+ tapped: false,
63
+ flipped: false,
64
+ revealed: false,
65
+ phased: false,
66
+ state: "ready",
67
+ damage: 0,
68
+ isDrying: true,
69
+ } as any;
70
+
71
+ state.internal.cards["card-1"] = sourceCard;
72
+ });
73
+
74
+ describe("Basic Conditions", () => {
75
+ it("should check turn correctly", () => {
76
+ state.external.activePlayerId = "player1" as PlayerId;
77
+ const cond: TurnCondition = { type: "turn", whose: "your" };
78
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(true);
79
+
80
+ state.external.activePlayerId = "player2" as PlayerId;
81
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(false);
82
+ });
83
+
84
+ it("should check exerted/ready", () => {
85
+ sourceCard.state = "ready";
86
+ expect(
87
+ isConditionMet({ type: "is-ready" }, sourceCard, state, registry),
88
+ ).toBe(true);
89
+ expect(
90
+ isConditionMet({ type: "is-exerted" }, sourceCard, state, registry),
91
+ ).toBe(false);
92
+
93
+ sourceCard.state = "exerted";
94
+ expect(
95
+ isConditionMet({ type: "is-ready" }, sourceCard, state, registry),
96
+ ).toBe(false);
97
+ expect(
98
+ isConditionMet({ type: "is-exerted" }, sourceCard, state, registry),
99
+ ).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe("Resolution Conditions", () => {
104
+ it("should check bodyguard context", () => {
105
+ const cond: Condition = { type: "resolution", value: "bodyguard" };
106
+
107
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(false);
108
+
109
+ const context = { resolutionContext: "bodyguard" } as any;
110
+ expect(isConditionMet(cond, sourceCard, state, registry, context)).toBe(
111
+ true,
112
+ );
113
+ });
114
+
115
+ it("should check generic shift usage via stack", () => {
116
+ const cond: UsedShiftCondition = { type: "used-shift" };
117
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(false);
118
+
119
+ sourceCard.stackPosition = {
120
+ isUnder: false,
121
+ cardsUnderneath: ["card-under-1" as CardId],
122
+ topCardId: "card-1" as CardId,
123
+ };
124
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(true);
125
+ });
126
+ });
127
+
128
+ describe("Existence Conditions", () => {
129
+ it("should find named character", () => {
130
+ const cond: HasNamedCharacterCondition = {
131
+ type: "has-named-character",
132
+ name: "Elsa",
133
+ controller: "you",
134
+ };
135
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(true);
136
+
137
+ state.internal.cards = {}; // empty
138
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(false);
139
+ });
140
+ });
141
+
142
+ describe("Logical Conditions", () => {
143
+ it("should handle AND logic", () => {
144
+ const cond: Condition = {
145
+ type: "and",
146
+ conditions: [{ type: "is-ready" }, { type: "no-damage" }],
147
+ };
148
+
149
+ sourceCard.state = "ready";
150
+ sourceCard.damage = 0;
151
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(true);
152
+
153
+ sourceCard.damage = 1;
154
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(false);
155
+ });
156
+
157
+ it("should handle nested NOT logic", () => {
158
+ const cond: Condition = {
159
+ type: "not",
160
+ condition: { type: "is-exerted" },
161
+ };
162
+ sourceCard.state = "ready";
163
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(true);
164
+ });
165
+
166
+ it("should handle OR logic", () => {
167
+ const cond: Condition = {
168
+ type: "or",
169
+ conditions: [
170
+ { type: "is-exerted" }, // False
171
+ { type: "no-damage" }, // True
172
+ ],
173
+ };
174
+
175
+ sourceCard.state = "ready";
176
+ sourceCard.damage = 0;
177
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe("Comparison Conditions", () => {
182
+ it("should compare lore scores", () => {
183
+ const condition: Condition = {
184
+ type: "comparison",
185
+ left: { type: "lore", controller: "you" },
186
+ comparison: "gt" as any,
187
+ right: { type: "lore", controller: "opponent" },
188
+ };
189
+
190
+ expect(isConditionMet(condition, sourceCard, state, registry)).toBe(true);
191
+
192
+ (state.external.loreScores as any).player2 = 15;
193
+ expect(isConditionMet(condition, sourceCard, state, registry)).toBe(
194
+ false,
195
+ );
196
+ });
197
+ });
198
+
199
+ describe("History Conditions", () => {
200
+ it("should check if event happened this turn", () => {
201
+ state.external.turnHistory = [
202
+ {
203
+ type: "played-song",
204
+ controllerId: "player1" as PlayerId,
205
+ count: 1,
206
+ },
207
+ ];
208
+
209
+ const condition: Condition = {
210
+ type: "this-turn-happened",
211
+ event: "played-song",
212
+ who: "you",
213
+ };
214
+
215
+ expect(isConditionMet(condition, sourceCard, state, registry)).toBe(true);
216
+ });
217
+
218
+ it("should count events this turn", () => {
219
+ state.external.turnHistory = [
220
+ {
221
+ type: "played-action",
222
+ controllerId: "player1" as PlayerId,
223
+ count: 1,
224
+ },
225
+ {
226
+ type: "played-action",
227
+ controllerId: "player1" as PlayerId,
228
+ count: 1,
229
+ },
230
+ ];
231
+
232
+ const condition: Condition = {
233
+ type: "this-turn-count",
234
+ event: "played-action",
235
+ who: "you",
236
+ comparison: "gte" as any,
237
+ count: 2,
238
+ };
239
+
240
+ expect(isConditionMet(condition, sourceCard, state, registry)).toBe(true);
241
+ });
242
+ });
243
+
244
+ describe("Zone Conditions", () => {
245
+ it("should check if character is at location", () => {
246
+ sourceCard.atLocationId = "loc-1" as CardId;
247
+
248
+ const condition: Condition = {
249
+ type: "at-location",
250
+ };
251
+
252
+ expect(isConditionMet(condition, sourceCard, state, registry)).toBe(true);
253
+
254
+ sourceCard.atLocationId = undefined;
255
+ expect(isConditionMet(condition, sourceCard, state, registry)).toBe(
256
+ false,
257
+ );
258
+ });
259
+
260
+ it("should check general zone content", () => {
261
+ const discardCard: CardInstance<LorcanaCardMeta> = {
262
+ id: "card-discard" as CardId,
263
+ definitionId: "def-elsa",
264
+ owner: "player1" as PlayerId,
265
+ controller: "player1" as PlayerId,
266
+ zone: "discard" as ZoneId,
267
+ tapped: false,
268
+ flipped: false,
269
+ revealed: false,
270
+ phased: false,
271
+ ...createDefaultCardMeta(),
272
+ };
273
+ state.internal.cards["card-discard"] = discardCard;
274
+
275
+ const condition: Condition = {
276
+ type: "zone",
277
+ zone: "discard",
278
+ controller: "you",
279
+ hasCards: true,
280
+ };
281
+
282
+ expect(isConditionMet(condition, sourceCard, state, registry)).toBe(true);
283
+ });
284
+ });
285
+
286
+ describe("Card State Conditions", () => {
287
+ it("should check for card under", () => {
288
+ const cond: Condition = { type: "has-card-under" };
289
+
290
+ sourceCard.stackPosition = undefined;
291
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(false);
292
+
293
+ sourceCard.stackPosition = {
294
+ isUnder: false,
295
+ cardsUnderneath: ["card-under" as CardId],
296
+ topCardId: "card-1" as CardId,
297
+ };
298
+ expect(isConditionMet(cond, sourceCard, state, registry)).toBe(true);
299
+ });
300
+ });
301
+ });
@@ -0,0 +1,70 @@
1
+ import type { CardInstance, CardRegistry } from "@drmxrcy/tcg-core";
2
+ import type { Condition, LorcanaCardDefinition } from "@drmxrcy/tcg-lorcana-types";
3
+ import type { LorcanaContext } from "../targeting/lorcana-target-dsl";
4
+ import type { LorcanaCardMeta, LorcanaGameState } from "../types/game-state";
5
+
6
+ /**
7
+ * Context for condition evaluation
8
+ */
9
+ export interface ConditionEvaluationContext {
10
+ state: LorcanaGameState;
11
+ registry: CardRegistry<LorcanaCardDefinition>;
12
+ context?: LorcanaContext;
13
+ }
14
+
15
+ /**
16
+ * Handler for a specific condition type
17
+ */
18
+ export interface ConditionHandler<T extends Condition = Condition> {
19
+ /**
20
+ * Evaluate the condition
21
+ */
22
+ evaluate: (
23
+ condition: T,
24
+ sourceCard: CardInstance<LorcanaCardMeta>,
25
+ ctx: ConditionEvaluationContext,
26
+ ) => boolean;
27
+
28
+ /**
29
+ * Complexity rank for sorting (lower = evaluated first)
30
+ *
31
+ * Suggestions:
32
+ * 0-10: Simple property checks (exerted, ready)
33
+ * 11-20: Turn/Phase checks
34
+ * 21-40: Count checks (resources)
35
+ * 41-60: Simple Filters (has card named X)
36
+ * 61-90: Complex Filters / Queries
37
+ * 99+: Deep recursion / expensive checks
38
+ */
39
+ complexity: number;
40
+ }
41
+
42
+ /**
43
+ * Registry for condition handlers
44
+ */
45
+ class ConditionRegistry {
46
+ private handlers = new Map<Condition["type"], ConditionHandler<any>>();
47
+
48
+ /**
49
+ * Register a new condition handler
50
+ */
51
+ register<T extends Condition>(type: T["type"], handler: ConditionHandler<T>) {
52
+ this.handlers.set(type, handler);
53
+ }
54
+
55
+ /**
56
+ * Get handler for a condition type
57
+ */
58
+ get<T extends Condition>(type: T["type"]): ConditionHandler<T> | undefined {
59
+ return this.handlers.get(type) as ConditionHandler<T> | undefined;
60
+ }
61
+
62
+ /**
63
+ * Check if a handler exists for a type
64
+ */
65
+ has(type: string): boolean {
66
+ return this.handlers.has(type as Condition["type"]);
67
+ }
68
+ }
69
+
70
+ export const conditionRegistry = new ConditionRegistry();