@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,534 @@
1
+ /**
2
+ * Spec 1: Foundation & Types Test Suite
3
+ *
4
+ * Tests for deck validation and card type guards per Lorcana rules.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import type { InkType, LorcanaCardDefinition } from "@drmxrcy/tcg-lorcana-types";
9
+ import {
10
+ getAllKeywords,
11
+ getFullName,
12
+ getKeywordValue,
13
+ getShiftCost,
14
+ getSingerValue,
15
+ hasKeyword,
16
+ hasShift,
17
+ isAction,
18
+ isCharacter,
19
+ isItem,
20
+ isLocation,
21
+ isSong,
22
+ } from "../card-utils";
23
+ import {
24
+ getCardCounts,
25
+ getDeckStats,
26
+ getUniqueInkTypes,
27
+ validateDeck,
28
+ } from "../deck-validation";
29
+
30
+ // Helper to create mock cards
31
+ function createMockCard(
32
+ overrides: Partial<LorcanaCardDefinition> = {},
33
+ ): LorcanaCardDefinition {
34
+ return {
35
+ id: `card-${Math.random().toString(36).slice(2)}`,
36
+ name: "Test Card",
37
+ version: "Test Version",
38
+ fullName: "Test Card - Test Version",
39
+ inkType: ["amber"],
40
+ cost: 3,
41
+ inkable: true,
42
+ cardType: "character",
43
+ strength: 2,
44
+ willpower: 3,
45
+ lore: 1,
46
+ set: "TFC",
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ // Helper to create a valid 60-card deck with given ink types
52
+ function createValidDeck(
53
+ inkType: InkType[] = ["amber"],
54
+ secondInkType?: InkType,
55
+ ): LorcanaCardDefinition[] {
56
+ const cards: LorcanaCardDefinition[] = [];
57
+ const halfSize = secondInkType ? 30 : 60;
58
+
59
+ for (let i = 0; i < halfSize; i++) {
60
+ cards.push(
61
+ createMockCard({
62
+ id: `card-${i}`,
63
+ name: `Character ${i % 15}`,
64
+ version: `Version ${Math.floor(i / 15)}`,
65
+ fullName: `Character ${i % 15} - Version ${Math.floor(i / 15)}`,
66
+ inkType: inkType,
67
+ }),
68
+ );
69
+ }
70
+
71
+ if (secondInkType) {
72
+ for (let i = 0; i < 30; i++) {
73
+ cards.push(
74
+ createMockCard({
75
+ id: `card-${i + 30}`,
76
+ name: `Character ${(i % 15) + 15}`,
77
+ version: `Version ${Math.floor(i / 15)}`,
78
+ fullName: `Character ${(i % 15) + 15} - Version ${Math.floor(i / 15)}`,
79
+ inkType: [secondInkType],
80
+ }),
81
+ );
82
+ }
83
+ }
84
+
85
+ return cards;
86
+ }
87
+
88
+ describe("Spec 1: Foundation & Types", () => {
89
+ describe("Deck Validation (Rule 2.1)", () => {
90
+ it("rejects deck with fewer than 60 cards (Rule 2.1.1.1)", () => {
91
+ const deck = createValidDeck().slice(0, 59);
92
+ const result = validateDeck(deck);
93
+
94
+ expect(result.valid).toBe(false);
95
+ expect(result.errors).toHaveLength(1);
96
+ expect(result.errors[0].type).toBe("TOO_FEW_CARDS");
97
+ if (result.errors[0].type === "TOO_FEW_CARDS") {
98
+ expect(result.errors[0].count).toBe(59);
99
+ expect(result.errors[0].minimum).toBe(60);
100
+ }
101
+ });
102
+
103
+ it("accepts deck with exactly 60 cards", () => {
104
+ const deck = createValidDeck();
105
+ const result = validateDeck(deck);
106
+
107
+ expect(result.valid).toBe(true);
108
+ expect(result.errors).toHaveLength(0);
109
+ });
110
+
111
+ it("accepts deck with more than 60 cards", () => {
112
+ const deck = [...createValidDeck(), createMockCard({ id: "extra-1" })];
113
+ const result = validateDeck(deck);
114
+
115
+ expect(result.valid).toBe(true);
116
+ expect(result.errors).toHaveLength(0);
117
+ });
118
+
119
+ it("rejects deck with 3+ ink types (Rule 2.1.1.2)", () => {
120
+ const deck: LorcanaCardDefinition[] = [];
121
+
122
+ // Add 20 cards of each of 3 ink types
123
+ for (let i = 0; i < 20; i++) {
124
+ deck.push(
125
+ createMockCard({
126
+ id: `amber-${i}`,
127
+ inkType: ["amber"],
128
+ fullName: `Amber ${i}`,
129
+ }),
130
+ );
131
+ deck.push(
132
+ createMockCard({
133
+ id: `ruby-${i}`,
134
+ inkType: ["ruby"],
135
+ fullName: `Ruby ${i}`,
136
+ }),
137
+ );
138
+ deck.push(
139
+ createMockCard({
140
+ id: `sapphire-${i}`,
141
+ inkType: ["sapphire"],
142
+ fullName: `Sapphire ${i}`,
143
+ }),
144
+ );
145
+ }
146
+
147
+ const result = validateDeck(deck);
148
+
149
+ expect(result.valid).toBe(false);
150
+ expect(result.errors.some((e) => e.type === "TOO_MANY_INK_TYPES")).toBe(
151
+ true,
152
+ );
153
+ });
154
+
155
+ it("accepts mono-ink deck", () => {
156
+ const deck = createValidDeck(["amber"]);
157
+ const result = validateDeck(deck);
158
+
159
+ expect(result.valid).toBe(true);
160
+ expect(getUniqueInkTypes(deck)).toEqual(["amber"]);
161
+ });
162
+
163
+ it("accepts dual-ink deck", () => {
164
+ const deck = createValidDeck(["amber"], "ruby");
165
+ const result = validateDeck(deck);
166
+
167
+ expect(result.valid).toBe(true);
168
+ expect(getUniqueInkTypes(deck).sort()).toEqual(["amber", "ruby"]);
169
+ });
170
+
171
+ it("rejects deck with 5+ copies of same full name (Rule 2.1.1.3)", () => {
172
+ const deck = createValidDeck();
173
+ // Replace 5 cards with same full name
174
+ for (let i = 0; i < 5; i++) {
175
+ deck[i] = createMockCard({
176
+ id: `elsa-${i}`,
177
+ name: "Elsa",
178
+ version: "Ice Queen",
179
+ fullName: "Elsa - Ice Queen",
180
+ });
181
+ }
182
+
183
+ const result = validateDeck(deck);
184
+
185
+ expect(result.valid).toBe(false);
186
+ expect(result.errors.some((e) => e.type === "TOO_MANY_COPIES")).toBe(
187
+ true,
188
+ );
189
+ const copyError = result.errors.find((e) => e.type === "TOO_MANY_COPIES");
190
+ if (copyError && copyError.type === "TOO_MANY_COPIES") {
191
+ expect(copyError.fullName).toBe("Elsa - Ice Queen");
192
+ expect(copyError.count).toBe(5);
193
+ }
194
+ });
195
+
196
+ it("accepts 4 copies of same full name", () => {
197
+ const deck = createValidDeck();
198
+ // Replace 4 cards with same full name
199
+ for (let i = 0; i < 4; i++) {
200
+ deck[i] = createMockCard({
201
+ id: `elsa-${i}`,
202
+ name: "Elsa",
203
+ version: "Ice Queen",
204
+ fullName: "Elsa - Ice Queen",
205
+ });
206
+ }
207
+
208
+ const result = validateDeck(deck);
209
+ const copyErrors = result.errors.filter(
210
+ (e) => e.type === "TOO_MANY_COPIES",
211
+ );
212
+
213
+ expect(copyErrors).toHaveLength(0);
214
+ });
215
+
216
+ it("treats different versions as different cards", () => {
217
+ const deck = createValidDeck();
218
+ // Replace 4 cards with "Elsa - Ice Queen"
219
+ for (let i = 0; i < 4; i++) {
220
+ deck[i] = createMockCard({
221
+ id: `elsa-ice-${i}`,
222
+ name: "Elsa",
223
+ version: "Ice Queen",
224
+ fullName: "Elsa - Ice Queen",
225
+ });
226
+ }
227
+ // Replace 4 more cards with "Elsa - Snow Queen"
228
+ for (let i = 4; i < 8; i++) {
229
+ deck[i] = createMockCard({
230
+ id: `elsa-snow-${i}`,
231
+ name: "Elsa",
232
+ version: "Snow Queen",
233
+ fullName: "Elsa - Snow Queen",
234
+ });
235
+ }
236
+
237
+ const result = validateDeck(deck);
238
+ const copyErrors = result.errors.filter(
239
+ (e) => e.type === "TOO_MANY_COPIES",
240
+ );
241
+
242
+ expect(copyErrors).toHaveLength(0);
243
+ });
244
+
245
+ it("allows unlimited copies when cardCopyLimit is 'no-limit'", () => {
246
+ const deck = createValidDeck();
247
+ // Replace 10 cards with Microbots (no limit)
248
+ for (let i = 0; i < 10; i++) {
249
+ deck[i] = createMockCard({
250
+ id: `microbots-${i}`,
251
+ name: "Microbots",
252
+ version: "Tiny Helpers",
253
+ fullName: "Microbots - Tiny Helpers",
254
+ cardCopyLimit: "no-limit",
255
+ });
256
+ }
257
+
258
+ const result = validateDeck(deck);
259
+ const copyErrors = result.errors.filter(
260
+ (e) => e.type === "TOO_MANY_COPIES",
261
+ );
262
+
263
+ expect(copyErrors).toHaveLength(0);
264
+ });
265
+
266
+ it("respects custom cardCopyLimit of 2 (Perfect Pair rule)", () => {
267
+ const deck = createValidDeck();
268
+ // Replace 3 cards with The Glass Slipper (limit 2)
269
+ for (let i = 0; i < 3; i++) {
270
+ deck[i] = createMockCard({
271
+ id: `glass-slipper-${i}`,
272
+ name: "The Glass Slipper",
273
+ version: "Perfect Fit",
274
+ fullName: "The Glass Slipper - Perfect Fit",
275
+ cardCopyLimit: 2,
276
+ });
277
+ }
278
+
279
+ const result = validateDeck(deck);
280
+
281
+ expect(result.valid).toBe(false);
282
+ expect(result.errors.some((e) => e.type === "TOO_MANY_COPIES")).toBe(
283
+ true,
284
+ );
285
+ const copyError = result.errors.find((e) => e.type === "TOO_MANY_COPIES");
286
+ if (copyError && copyError.type === "TOO_MANY_COPIES") {
287
+ expect(copyError.fullName).toBe("The Glass Slipper - Perfect Fit");
288
+ expect(copyError.count).toBe(3);
289
+ expect(copyError.maximum).toBe(2);
290
+ }
291
+ });
292
+
293
+ it("accepts exactly 2 copies when cardCopyLimit is 2", () => {
294
+ const deck = createValidDeck();
295
+ // Replace 2 cards with The Glass Slipper (limit 2)
296
+ for (let i = 0; i < 2; i++) {
297
+ deck[i] = createMockCard({
298
+ id: `glass-slipper-${i}`,
299
+ name: "The Glass Slipper",
300
+ version: "Perfect Fit",
301
+ fullName: "The Glass Slipper - Perfect Fit",
302
+ cardCopyLimit: 2,
303
+ });
304
+ }
305
+
306
+ const result = validateDeck(deck);
307
+ const copyErrors = result.errors.filter(
308
+ (e) =>
309
+ e.type === "TOO_MANY_COPIES" &&
310
+ e.fullName === "The Glass Slipper - Perfect Fit",
311
+ );
312
+
313
+ expect(copyErrors).toHaveLength(0);
314
+ });
315
+ });
316
+
317
+ describe("Card Type Guards (Section 6)", () => {
318
+ it("identifies character by cardType (Rule 6.1.2)", () => {
319
+ const card = createMockCard({
320
+ cardType: "character",
321
+ strength: 3,
322
+ willpower: 4,
323
+ });
324
+
325
+ expect(isCharacter(card)).toBe(true);
326
+ expect(isAction(card)).toBe(false);
327
+ expect(isItem(card)).toBe(false);
328
+ expect(isLocation(card)).toBe(false);
329
+ });
330
+
331
+ it("identifies action by cardType (Rule 6.3.1)", () => {
332
+ const card = createMockCard({
333
+ cardType: "action",
334
+ strength: undefined,
335
+ willpower: undefined,
336
+ });
337
+
338
+ expect(isAction(card)).toBe(true);
339
+ expect(isCharacter(card)).toBe(false);
340
+ expect(isSong(card)).toBe(false);
341
+ });
342
+
343
+ it("identifies song by actionSubtype (Rule 6.3.3)", () => {
344
+ const card = createMockCard({
345
+ cardType: "action",
346
+ actionSubtype: "song",
347
+ strength: undefined,
348
+ willpower: undefined,
349
+ });
350
+
351
+ expect(isSong(card)).toBe(true);
352
+ expect(isAction(card)).toBe(true);
353
+ });
354
+
355
+ it("identifies item by cardType (Rule 6.4.1)", () => {
356
+ const card = createMockCard({
357
+ cardType: "item",
358
+ strength: undefined,
359
+ willpower: undefined,
360
+ });
361
+
362
+ expect(isItem(card)).toBe(true);
363
+ });
364
+
365
+ it("identifies location by cardType (Rule 6.5.1)", () => {
366
+ const card = createMockCard({
367
+ cardType: "location",
368
+ strength: undefined,
369
+ willpower: 5,
370
+ lore: 2,
371
+ moveCost: 1,
372
+ });
373
+
374
+ expect(isLocation(card)).toBe(true);
375
+ });
376
+ });
377
+
378
+ describe("Card Anatomy (Rule 6.2)", () => {
379
+ it("generates full name from name + version", () => {
380
+ const card = createMockCard({
381
+ name: "Elsa",
382
+ version: "Ice Queen",
383
+ fullName: "Elsa - Ice Queen",
384
+ });
385
+
386
+ expect(getFullName(card)).toBe("Elsa - Ice Queen");
387
+ });
388
+
389
+ it("handles dual-ink cards (Rule 6.2.3.1)", () => {
390
+ const card = createMockCard({
391
+ inkType: ["amber", "ruby"],
392
+ });
393
+
394
+ expect(Array.isArray(card.inkType)).toBe(true);
395
+ if (Array.isArray(card.inkType)) {
396
+ expect(card.inkType).toContain("amber");
397
+ expect(card.inkType).toContain("ruby");
398
+ }
399
+ });
400
+
401
+ it("handles cards with two names using ampersand (Rule 6.2.4.1)", () => {
402
+ const card = createMockCard({
403
+ name: "Flotsam & Jetsam",
404
+ version: "Slippery Eels",
405
+ fullName: "Flotsam & Jetsam - Slippery Eels",
406
+ });
407
+
408
+ expect(card.name).toBe("Flotsam & Jetsam");
409
+ expect(card.name.includes(" & ")).toBe(true);
410
+ });
411
+ });
412
+
413
+ describe("Keywords", () => {
414
+ it("detects simple keywords (Bodyguard, Evasive, etc.)", () => {
415
+ const card = createMockCard({
416
+ abilities: [
417
+ {
418
+ type: "keyword",
419
+ keyword: "Bodyguard",
420
+ id: "ab1",
421
+ text: "Bodyguard",
422
+ },
423
+ { type: "keyword", keyword: "Ward", id: "ab2", text: "Ward" },
424
+ ],
425
+ });
426
+
427
+ expect(hasKeyword(card, "Bodyguard")).toBe(true);
428
+ expect(hasKeyword(card, "Ward")).toBe(true);
429
+ expect(hasKeyword(card, "Rush")).toBe(false);
430
+ });
431
+
432
+ it("extracts value from parameterized keywords (Challenger +2)", () => {
433
+ const card = createMockCard({
434
+ abilities: [
435
+ {
436
+ type: "keyword",
437
+ keyword: "Challenger",
438
+ value: 2,
439
+ id: "ab1",
440
+ text: "Challenger +2",
441
+ },
442
+ ],
443
+ });
444
+
445
+ expect(getKeywordValue(card, "Challenger")).toBe(2);
446
+ expect(getKeywordValue(card, "Resist")).toBe(null);
447
+ });
448
+
449
+ it("handles multiple keywords on same card", () => {
450
+ const card = createMockCard({
451
+ abilities: [
452
+ {
453
+ type: "keyword",
454
+ keyword: "Bodyguard",
455
+ id: "ab1",
456
+ text: "Bodyguard",
457
+ },
458
+ {
459
+ type: "keyword",
460
+ keyword: "Challenger",
461
+ value: 2,
462
+ id: "ab2",
463
+ text: "Challenger +2",
464
+ },
465
+ {
466
+ type: "keyword",
467
+ keyword: "Resist",
468
+ value: 1,
469
+ id: "ab3",
470
+ text: "Resist +1",
471
+ },
472
+ ],
473
+ });
474
+
475
+ const keywords = getAllKeywords(card);
476
+ expect(keywords).toHaveLength(3);
477
+ });
478
+
479
+ it("detects Shift keyword and extracts cost", () => {
480
+ const card = createMockCard({
481
+ abilities: [
482
+ {
483
+ type: "keyword",
484
+ keyword: "Shift",
485
+ cost: { ink: 4 },
486
+ shiftTarget: "Elsa",
487
+ id: "ab1",
488
+ text: "Shift 4",
489
+ },
490
+ ],
491
+ });
492
+
493
+ expect(hasShift(card)).toBe(true);
494
+ expect(getShiftCost(card)).toBe(4);
495
+ });
496
+
497
+ it("detects Singer keyword and extracts value", () => {
498
+ const card = createMockCard({
499
+ abilities: [
500
+ {
501
+ type: "keyword",
502
+ keyword: "Singer",
503
+ value: 5,
504
+ id: "ab1",
505
+ text: "Singer 5",
506
+ },
507
+ ],
508
+ });
509
+
510
+ expect(getSingerValue(card)).toBe(5);
511
+ });
512
+ });
513
+
514
+ describe("Deck Statistics", () => {
515
+ it("calculates deck statistics correctly", () => {
516
+ const deck = createValidDeck(["amber"], "ruby");
517
+ const stats = getDeckStats(deck);
518
+
519
+ expect(stats.totalCards).toBe(60);
520
+ expect(stats.inkTypes.sort()).toEqual(["amber", "ruby"]);
521
+ expect(stats.cardTypeBreakdown.characters).toBe(60);
522
+ });
523
+
524
+ it("counts card copies correctly", () => {
525
+ const deck = createValidDeck();
526
+ const counts = getCardCounts(deck);
527
+
528
+ // Each unique fullName should have at most 4 copies
529
+ for (const [, count] of counts) {
530
+ expect(count).toBeLessThanOrEqual(4);
531
+ }
532
+ });
533
+ });
534
+ });