@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,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
+ }