@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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec 2: Zones & Card States Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Tests for zone configuration and card state management per Lorcana rules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import type { CardId } from "@drmxrcy/tcg-core";
|
|
9
|
+
import {
|
|
10
|
+
addDamage,
|
|
11
|
+
type CardInstanceState,
|
|
12
|
+
clearDrying,
|
|
13
|
+
createCardInstanceState,
|
|
14
|
+
createStack,
|
|
15
|
+
exertCard,
|
|
16
|
+
getDamage,
|
|
17
|
+
getStackCardIds,
|
|
18
|
+
isDamaged,
|
|
19
|
+
isDry,
|
|
20
|
+
isDrying,
|
|
21
|
+
isExerted,
|
|
22
|
+
isInStack,
|
|
23
|
+
isReady,
|
|
24
|
+
isTopOfStack,
|
|
25
|
+
isUnderCard,
|
|
26
|
+
readyCard,
|
|
27
|
+
removeDamage,
|
|
28
|
+
setDrying,
|
|
29
|
+
} from "../zones/card-state";
|
|
30
|
+
import {
|
|
31
|
+
areCardsVisibleIn,
|
|
32
|
+
getZoneConfig,
|
|
33
|
+
isLorcanaZoneId,
|
|
34
|
+
isZoneVisibleTo,
|
|
35
|
+
LORCANA_ZONES,
|
|
36
|
+
type LorcanaZoneId,
|
|
37
|
+
} from "../zones/zone-config";
|
|
38
|
+
|
|
39
|
+
// Helper to create card IDs
|
|
40
|
+
const cardId = (id: string): CardId => id as CardId;
|
|
41
|
+
|
|
42
|
+
describe("Spec 2: Zones & Card States", () => {
|
|
43
|
+
describe("Zone Visibility (Rule 8.1)", () => {
|
|
44
|
+
it("deck is private - only owner can see contents (Rule 8.2)", () => {
|
|
45
|
+
const config = getZoneConfig("deck");
|
|
46
|
+
expect(config.visibility).toBe("private");
|
|
47
|
+
expect(config.faceDown).toBe(true);
|
|
48
|
+
expect(config.ordered).toBe(true);
|
|
49
|
+
|
|
50
|
+
// Owner can see
|
|
51
|
+
expect(isZoneVisibleTo("deck", "player1", "player1")).toBe(true);
|
|
52
|
+
// Opponent cannot see
|
|
53
|
+
expect(isZoneVisibleTo("deck", "player1", "player2")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("hand is private - only owner can see contents (Rule 8.3)", () => {
|
|
57
|
+
const config = getZoneConfig("hand");
|
|
58
|
+
expect(config.visibility).toBe("private");
|
|
59
|
+
|
|
60
|
+
expect(isZoneVisibleTo("hand", "player1", "player1")).toBe(true);
|
|
61
|
+
expect(isZoneVisibleTo("hand", "player1", "player2")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("play is public - all players see cards (Rule 8.4)", () => {
|
|
65
|
+
const config = getZoneConfig("play");
|
|
66
|
+
expect(config.visibility).toBe("public");
|
|
67
|
+
expect(config.faceDown).toBe(false);
|
|
68
|
+
|
|
69
|
+
expect(isZoneVisibleTo("play", "player1", "player1")).toBe(true);
|
|
70
|
+
expect(isZoneVisibleTo("play", "player1", "player2")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("inkwell count is public, card identity is hidden (Rule 8.5)", () => {
|
|
74
|
+
const config = getZoneConfig("inkwell");
|
|
75
|
+
expect(config.visibility).toBe("hidden_identity");
|
|
76
|
+
expect(config.faceDown).toBe(true);
|
|
77
|
+
|
|
78
|
+
// Card identities are not visible
|
|
79
|
+
expect(areCardsVisibleIn("inkwell", "player1", "player1")).toBe(false);
|
|
80
|
+
expect(areCardsVisibleIn("inkwell", "player1", "player2")).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("discard is public - all players see all cards (Rule 8.6)", () => {
|
|
84
|
+
const config = getZoneConfig("discard");
|
|
85
|
+
expect(config.visibility).toBe("public");
|
|
86
|
+
expect(config.faceDown).toBe(false);
|
|
87
|
+
|
|
88
|
+
expect(areCardsVisibleIn("discard", "player1", "player1")).toBe(true);
|
|
89
|
+
expect(areCardsVisibleIn("discard", "player1", "player2")).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("Zone Ordering", () => {
|
|
94
|
+
it("deck is ordered - has top and bottom (Rule 8.2)", () => {
|
|
95
|
+
const config = getZoneConfig("deck");
|
|
96
|
+
expect(config.ordered).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("other zones are unordered", () => {
|
|
100
|
+
expect(getZoneConfig("hand").ordered).toBe(false);
|
|
101
|
+
expect(getZoneConfig("play").ordered).toBe(false);
|
|
102
|
+
expect(getZoneConfig("inkwell").ordered).toBe(false);
|
|
103
|
+
expect(getZoneConfig("discard").ordered).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("Zone ID Validation", () => {
|
|
108
|
+
it("validates correct zone IDs", () => {
|
|
109
|
+
expect(isLorcanaZoneId("deck")).toBe(true);
|
|
110
|
+
expect(isLorcanaZoneId("hand")).toBe(true);
|
|
111
|
+
expect(isLorcanaZoneId("play")).toBe(true);
|
|
112
|
+
expect(isLorcanaZoneId("inkwell")).toBe(true);
|
|
113
|
+
expect(isLorcanaZoneId("discard")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects invalid zone IDs", () => {
|
|
117
|
+
expect(isLorcanaZoneId("graveyard")).toBe(false);
|
|
118
|
+
expect(isLorcanaZoneId("exile")).toBe(false);
|
|
119
|
+
expect(isLorcanaZoneId("")).toBe(false);
|
|
120
|
+
expect(isLorcanaZoneId(123)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("Card States (Rule 5.1)", () => {
|
|
125
|
+
it("cards enter play ready (Rule 5.1.1)", () => {
|
|
126
|
+
const state = createCardInstanceState(cardId("card-1"));
|
|
127
|
+
expect(isReady(state)).toBe(true);
|
|
128
|
+
expect(isExerted(state)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("exerting turns card sideways (Rule 5.1.2)", () => {
|
|
132
|
+
const state = createCardInstanceState(cardId("card-1"));
|
|
133
|
+
const exertedState = exertCard(state);
|
|
134
|
+
|
|
135
|
+
expect(isExerted(exertedState)).toBe(true);
|
|
136
|
+
expect(isReady(exertedState)).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("readying returns card to upright position", () => {
|
|
140
|
+
const state = exertCard(createCardInstanceState(cardId("card-1")));
|
|
141
|
+
const readiedState = readyCard(state);
|
|
142
|
+
|
|
143
|
+
expect(isReady(readiedState)).toBe(true);
|
|
144
|
+
expect(isExerted(readiedState)).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("damaged cards have 1+ damage counters (Rule 5.1.3)", () => {
|
|
148
|
+
const state = createCardInstanceState(cardId("card-1"));
|
|
149
|
+
const damagedState = addDamage(state, 2);
|
|
150
|
+
|
|
151
|
+
expect(isDamaged(damagedState)).toBe(true);
|
|
152
|
+
expect(getDamage(damagedState)).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("undamaged cards have 0 damage (Rule 5.1.4)", () => {
|
|
156
|
+
const state = createCardInstanceState(cardId("card-1"));
|
|
157
|
+
|
|
158
|
+
expect(isDamaged(state)).toBe(false);
|
|
159
|
+
expect(getDamage(state)).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("damage can be removed", () => {
|
|
163
|
+
const state = addDamage(createCardInstanceState(cardId("card-1")), 5);
|
|
164
|
+
const healedState = removeDamage(state, 3);
|
|
165
|
+
|
|
166
|
+
expect(getDamage(healedState)).toBe(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("damage cannot go below 0", () => {
|
|
170
|
+
const state = addDamage(createCardInstanceState(cardId("card-1")), 2);
|
|
171
|
+
const overHealedState = removeDamage(state, 10);
|
|
172
|
+
|
|
173
|
+
expect(getDamage(overHealedState)).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("Drying State", () => {
|
|
178
|
+
it("characters are drying when they enter play", () => {
|
|
179
|
+
const state = createCardInstanceState(cardId("card-1"));
|
|
180
|
+
|
|
181
|
+
expect(isDrying(state)).toBe(true);
|
|
182
|
+
expect(isDry(state)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("characters can be set to dry (clear drying)", () => {
|
|
186
|
+
const state = createCardInstanceState(cardId("card-1"));
|
|
187
|
+
const dryState = clearDrying(state);
|
|
188
|
+
|
|
189
|
+
expect(isDrying(dryState)).toBe(false);
|
|
190
|
+
expect(isDry(dryState)).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("can create non-drying cards (e.g., items)", () => {
|
|
194
|
+
const state = createCardInstanceState(cardId("item-1"), {
|
|
195
|
+
isDrying: false,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(isDrying(state)).toBe(false);
|
|
199
|
+
expect(isDry(state)).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("setDrying can change drying state", () => {
|
|
203
|
+
const state = createCardInstanceState(cardId("card-1"));
|
|
204
|
+
const dryState = setDrying(state, false);
|
|
205
|
+
const dryingAgain = setDrying(dryState, true);
|
|
206
|
+
|
|
207
|
+
expect(isDrying(dryState)).toBe(false);
|
|
208
|
+
expect(isDrying(dryingAgain)).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("Stacks (Rule 5.1.5-5.1.7)", () => {
|
|
213
|
+
it("shifting creates a stack", () => {
|
|
214
|
+
const topState = createCardInstanceState(cardId("shifted-card"));
|
|
215
|
+
const underState = createCardInstanceState(cardId("original-card"));
|
|
216
|
+
|
|
217
|
+
const { topCard, underneathCard } = createStack(topState, underState);
|
|
218
|
+
|
|
219
|
+
expect(isInStack(topCard)).toBe(true);
|
|
220
|
+
expect(isInStack(underneathCard)).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("only top card is accessible in stack (Rule 5.1.5)", () => {
|
|
224
|
+
const topState = createCardInstanceState(cardId("shifted-card"));
|
|
225
|
+
const underState = createCardInstanceState(cardId("original-card"));
|
|
226
|
+
|
|
227
|
+
const { topCard, underneathCard } = createStack(topState, underState);
|
|
228
|
+
|
|
229
|
+
expect(isTopOfStack(topCard)).toBe(true);
|
|
230
|
+
expect(isTopOfStack(underneathCard)).toBe(false);
|
|
231
|
+
expect(isUnderCard(underneathCard)).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("damage carries over from underneath character (Rule 10.8.x)", () => {
|
|
235
|
+
const topState = createCardInstanceState(cardId("shifted-card"));
|
|
236
|
+
const underState = addDamage(
|
|
237
|
+
createCardInstanceState(cardId("original-card")),
|
|
238
|
+
3,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const { topCard } = createStack(topState, underState);
|
|
242
|
+
|
|
243
|
+
expect(getDamage(topCard)).toBe(3);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("shifted character is ready (Rule 10.8.x)", () => {
|
|
247
|
+
const topState = createCardInstanceState(cardId("shifted-card"));
|
|
248
|
+
const underState = exertCard(
|
|
249
|
+
createCardInstanceState(cardId("original-card")),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const { topCard } = createStack(topState, underState);
|
|
253
|
+
|
|
254
|
+
expect(isReady(topCard)).toBe(true);
|
|
255
|
+
expect(isDrying(topCard)).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("getStackCardIds returns all cards in stack", () => {
|
|
259
|
+
const topState = createCardInstanceState(cardId("shifted-card"));
|
|
260
|
+
const underState = createCardInstanceState(cardId("original-card"));
|
|
261
|
+
|
|
262
|
+
const { topCard } = createStack(topState, underState);
|
|
263
|
+
const stackIds = getStackCardIds(topCard);
|
|
264
|
+
|
|
265
|
+
expect(stackIds).toContain(cardId("shifted-card"));
|
|
266
|
+
expect(stackIds).toContain(cardId("original-card"));
|
|
267
|
+
expect(stackIds).toHaveLength(2);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("cards not in stack return only themselves", () => {
|
|
271
|
+
const state = createCardInstanceState(cardId("solo-card"));
|
|
272
|
+
const stackIds = getStackCardIds(state);
|
|
273
|
+
|
|
274
|
+
expect(stackIds).toEqual([cardId("solo-card")]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("Zone Configuration Access", () => {
|
|
279
|
+
it("all zone configs are accessible", () => {
|
|
280
|
+
const zones: LorcanaZoneId[] = [
|
|
281
|
+
"deck",
|
|
282
|
+
"hand",
|
|
283
|
+
"play",
|
|
284
|
+
"inkwell",
|
|
285
|
+
"discard",
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
for (const zone of zones) {
|
|
289
|
+
const config = LORCANA_ZONES[zone];
|
|
290
|
+
expect(config).toBeDefined();
|
|
291
|
+
expect(config.id).toBe(zone);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card Utilities
|
|
3
|
+
*
|
|
4
|
+
* Type guards and utility functions for working with Lorcana cards.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AbilityDefinition,
|
|
9
|
+
KeywordAbility,
|
|
10
|
+
KeywordAbilityDefinition,
|
|
11
|
+
LorcanaCardDefinition,
|
|
12
|
+
ParameterizedKeywordType,
|
|
13
|
+
} from "@drmxrcy/tcg-lorcana-types";
|
|
14
|
+
import {
|
|
15
|
+
getFullName as cardGetFullName,
|
|
16
|
+
isParameterizedKeywordAbility,
|
|
17
|
+
isShiftKeywordAbility,
|
|
18
|
+
isValueKeywordAbility,
|
|
19
|
+
} from "@drmxrcy/tcg-lorcana-types";
|
|
20
|
+
|
|
21
|
+
// Re-export getFullName for convenience
|
|
22
|
+
export const getFullName = cardGetFullName;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a card is a character (Rule 6.1.2)
|
|
26
|
+
* Characters have strength and willpower
|
|
27
|
+
*/
|
|
28
|
+
export function isCharacter(card: LorcanaCardDefinition): boolean {
|
|
29
|
+
return card.cardType === "character";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a card is an action (Rule 6.3.1)
|
|
34
|
+
*/
|
|
35
|
+
export function isAction(card: LorcanaCardDefinition): boolean {
|
|
36
|
+
return card.cardType === "action";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a card is a song (Rule 6.3.3)
|
|
41
|
+
* Songs are actions with the "song" subtype
|
|
42
|
+
*/
|
|
43
|
+
export function isSong(card: LorcanaCardDefinition): boolean {
|
|
44
|
+
return card.cardType === "action" && card.actionSubtype === "song";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a card is an item (Rule 6.4.1)
|
|
49
|
+
*/
|
|
50
|
+
export function isItem(card: LorcanaCardDefinition): boolean {
|
|
51
|
+
return card.cardType === "item";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a card is a location (Rule 6.5.1)
|
|
56
|
+
*/
|
|
57
|
+
export function isLocation(card: LorcanaCardDefinition): boolean {
|
|
58
|
+
return card.cardType === "location";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Keyword Utilities (Updated for AbilityDefinition)
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Helper to get all keyword abilities from a card
|
|
67
|
+
*/
|
|
68
|
+
function getKeywordAbilities(
|
|
69
|
+
card: LorcanaCardDefinition,
|
|
70
|
+
): KeywordAbilityDefinition[] {
|
|
71
|
+
if (!card.abilities) return [];
|
|
72
|
+
return card.abilities.filter(
|
|
73
|
+
(a): a is KeywordAbilityDefinition => a.type === "keyword",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if a card has a specific keyword
|
|
79
|
+
*/
|
|
80
|
+
export function hasKeyword(
|
|
81
|
+
card: LorcanaCardDefinition,
|
|
82
|
+
keyword: string,
|
|
83
|
+
): boolean {
|
|
84
|
+
return getKeywordAbilities(card).some((k) => k.keyword === keyword);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the value of a parameterized keyword (Challenger, Resist)
|
|
89
|
+
* Returns null if the keyword is not present
|
|
90
|
+
*/
|
|
91
|
+
export function getKeywordValue(
|
|
92
|
+
card: LorcanaCardDefinition,
|
|
93
|
+
keyword: ParameterizedKeywordType,
|
|
94
|
+
): number | null {
|
|
95
|
+
const kw = getKeywordAbilities(card).find(
|
|
96
|
+
(k) => k.keyword === keyword && isParameterizedKeywordAbility(k),
|
|
97
|
+
);
|
|
98
|
+
if (!(kw && isParameterizedKeywordAbility(kw))) return null;
|
|
99
|
+
return kw.value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get the total stacked value of a parameterized keyword
|
|
104
|
+
*/
|
|
105
|
+
export function getTotalKeyword(
|
|
106
|
+
card: LorcanaCardDefinition,
|
|
107
|
+
keyword: ParameterizedKeywordType,
|
|
108
|
+
): number {
|
|
109
|
+
return getKeywordAbilities(card)
|
|
110
|
+
.filter((k) => k.keyword === keyword && isParameterizedKeywordAbility(k))
|
|
111
|
+
.reduce((sum, k) => {
|
|
112
|
+
if (isParameterizedKeywordAbility(k)) {
|
|
113
|
+
return sum + k.value;
|
|
114
|
+
}
|
|
115
|
+
return sum;
|
|
116
|
+
}, 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get all keywords on a card (as KeywordAbility objects)
|
|
121
|
+
*/
|
|
122
|
+
export function getAllKeywords(
|
|
123
|
+
card: LorcanaCardDefinition,
|
|
124
|
+
): KeywordAbilityDefinition[] {
|
|
125
|
+
return getKeywordAbilities(card);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if a card has Shift keyword
|
|
130
|
+
*/
|
|
131
|
+
export function hasShift(card: LorcanaCardDefinition): boolean {
|
|
132
|
+
return hasKeyword(card, "Shift");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the Shift cost if present
|
|
137
|
+
* Note: Returns number if ink cost, or null. Logic simplified for ink cost.
|
|
138
|
+
* If cost is complex, this might need update, but for now assuming ink cost.
|
|
139
|
+
*/
|
|
140
|
+
export function getShiftCost(card: LorcanaCardDefinition): number | null {
|
|
141
|
+
const shift = getKeywordAbilities(card).find(
|
|
142
|
+
(k) => k.keyword === "Shift" && isShiftKeywordAbility(k),
|
|
143
|
+
);
|
|
144
|
+
if (!(shift && isShiftKeywordAbility(shift))) return null;
|
|
145
|
+
|
|
146
|
+
// Assuming 'ink' cost for now as per previous types
|
|
147
|
+
if ("ink" in shift.cost) {
|
|
148
|
+
return shift.cost.ink ?? null;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get the Shift target name if present
|
|
155
|
+
*/
|
|
156
|
+
export function getShiftTargetName(card: LorcanaCardDefinition): string | null {
|
|
157
|
+
const shift = getKeywordAbilities(card).find(
|
|
158
|
+
(k) => k.keyword === "Shift" && isShiftKeywordAbility(k),
|
|
159
|
+
);
|
|
160
|
+
if (!(shift && isShiftKeywordAbility(shift))) return null;
|
|
161
|
+
return shift.shiftTarget ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get the Singer value if present
|
|
166
|
+
*/
|
|
167
|
+
export function getSingerValue(card: LorcanaCardDefinition): number | null {
|
|
168
|
+
const singer = getKeywordAbilities(card).find(
|
|
169
|
+
(k) => k.keyword === "Singer" && isValueKeywordAbility(k),
|
|
170
|
+
);
|
|
171
|
+
if (!(singer && isValueKeywordAbility(singer))) return null;
|
|
172
|
+
return singer.value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get the Sing Together value if present
|
|
177
|
+
*/
|
|
178
|
+
export function getSingTogetherValue(
|
|
179
|
+
card: LorcanaCardDefinition,
|
|
180
|
+
): number | null {
|
|
181
|
+
const singTogether = getKeywordAbilities(card).find(
|
|
182
|
+
(k) => k.keyword === "SingTogether" && isValueKeywordAbility(k),
|
|
183
|
+
);
|
|
184
|
+
if (!(singTogether && isValueKeywordAbility(singTogether))) return null;
|
|
185
|
+
return singTogether.value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if a character has Bodyguard
|
|
190
|
+
*/
|
|
191
|
+
export function hasBodyguard(card: LorcanaCardDefinition): boolean {
|
|
192
|
+
return hasKeyword(card, "Bodyguard");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if a character has Evasive
|
|
197
|
+
*/
|
|
198
|
+
export function hasEvasive(card: LorcanaCardDefinition): boolean {
|
|
199
|
+
return hasKeyword(card, "Evasive");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check if a character has Reckless
|
|
204
|
+
*/
|
|
205
|
+
export function hasReckless(card: LorcanaCardDefinition): boolean {
|
|
206
|
+
return hasKeyword(card, "Reckless");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check if a character has Rush
|
|
211
|
+
*/
|
|
212
|
+
export function hasRush(card: LorcanaCardDefinition): boolean {
|
|
213
|
+
return hasKeyword(card, "Rush");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check if a character has Ward
|
|
218
|
+
*/
|
|
219
|
+
export function hasWard(card: LorcanaCardDefinition): boolean {
|
|
220
|
+
return hasKeyword(card, "Ward");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check if a character has Vanish
|
|
225
|
+
*/
|
|
226
|
+
export function hasVanish(card: LorcanaCardDefinition): boolean {
|
|
227
|
+
return hasKeyword(card, "Vanish");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Check if a card can be put into the inkwell (has inkwell symbol)
|
|
232
|
+
*/
|
|
233
|
+
export function canInk(card: LorcanaCardDefinition): boolean {
|
|
234
|
+
return card.inkable;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if a card can quest (is a character without Reckless)
|
|
239
|
+
*/
|
|
240
|
+
export function canQuest(card: LorcanaCardDefinition): boolean {
|
|
241
|
+
return isCharacter(card) && !hasReckless(card);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get the lore value of a card (for questing)
|
|
246
|
+
*/
|
|
247
|
+
export function getLoreValue(card: LorcanaCardDefinition): number {
|
|
248
|
+
return card.lore ?? 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get the strength of a character
|
|
253
|
+
*/
|
|
254
|
+
export function getStrength(card: LorcanaCardDefinition): number {
|
|
255
|
+
return card.strength ?? 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get the willpower of a card (character or location)
|
|
260
|
+
*/
|
|
261
|
+
export function getWillpower(card: LorcanaCardDefinition): number {
|
|
262
|
+
return card.willpower ?? 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get the move cost of a location
|
|
267
|
+
*/
|
|
268
|
+
export function getMoveCost(card: LorcanaCardDefinition): number | null {
|
|
269
|
+
return isLocation(card) ? (card.moveCost ?? null) : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Check if two cards have the same name (ignoring version)
|
|
274
|
+
* Used for Shift targeting
|
|
275
|
+
*/
|
|
276
|
+
export function hasSameName(
|
|
277
|
+
card1: LorcanaCardDefinition,
|
|
278
|
+
card2: LorcanaCardDefinition,
|
|
279
|
+
): boolean {
|
|
280
|
+
return card1.name === card2.name;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check if a card has a two-part name with ampersand (Rule 6.2.4.1)
|
|
285
|
+
* e.g., "Flotsam & Jetsam"
|
|
286
|
+
*/
|
|
287
|
+
export function hasAmpersandName(card: LorcanaCardDefinition): boolean {
|
|
288
|
+
return card.name.includes(" & ");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get both name parts for cards with ampersand (Rule 6.2.4.1)
|
|
293
|
+
* Returns null if card doesn't have ampersand name
|
|
294
|
+
*/
|
|
295
|
+
export function getAmpersandNames(
|
|
296
|
+
card: LorcanaCardDefinition,
|
|
297
|
+
): [string, string] | null {
|
|
298
|
+
if (!hasAmpersandName(card)) return null;
|
|
299
|
+
const parts = card.name.split(" & ");
|
|
300
|
+
if (parts.length !== 2) return null;
|
|
301
|
+
return [parts[0], parts[1]];
|
|
302
|
+
}
|