@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,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();
|