@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,296 @@
1
+ # Cards
2
+
3
+ This directory contains card definitions, abilities, and card-related functionality for Lorcana.
4
+
5
+ ## Structure
6
+
7
+ ### Card Definitions
8
+
9
+ - **`card-definitions/`** - Card data organized by set
10
+ - `set-001/` - The First Chapter cards
11
+ - `set-002/` - Rise of the Floodborn cards
12
+ - `set-003/` - Into the Inklands cards
13
+ - `index.ts` - Card registry aggregating all sets
14
+
15
+ ### Abilities
16
+
17
+ - **`abilities/`** - Card ability definitions
18
+ - `keywords/` - Keyword abilities (Bodyguard, Challenger, Evasive, etc.)
19
+ - `triggered/` - Triggered abilities (When/Whenever)
20
+ - `activated/` - Activated abilities (tap to activate)
21
+ - `static/` - Static abilities (always active)
22
+ - `index.ts` - Ability registry
23
+
24
+ ### Card Types
25
+
26
+ - **`card-types.ts`** - Lorcana-specific card type definitions
27
+ - **`card-instance.ts`** - Runtime card instance management
28
+ - **`card-queries.ts`** - Helper functions for querying cards
29
+ - **`index.ts`** - Public exports
30
+
31
+ ## Purpose
32
+
33
+ This directory defines all Lorcana cards and their abilities, structured to work with the `@drmxrcy/tcg-core` card system.
34
+
35
+ ## Card Definition Pattern
36
+
37
+ Cards are defined declaratively:
38
+
39
+ ```typescript
40
+ import type { CardDefinition } from "@drmxrcy/tcg-core";
41
+ import type { LorcanaCard } from "../card-types";
42
+
43
+ export const mickeyMouseTrueFriend: CardDefinition<LorcanaCard> = {
44
+ // Core properties
45
+ id: "001-001",
46
+ name: "Mickey Mouse - True Friend",
47
+ set: "001",
48
+ rarity: "legendary",
49
+ inkable: true,
50
+
51
+ // Lorcana-specific properties
52
+ cost: 8,
53
+ inkCost: 8,
54
+ type: "character",
55
+ color: "amber",
56
+
57
+ // Character properties
58
+ strength: 4,
59
+ willpower: 6,
60
+ loreValue: 3,
61
+
62
+ // Abilities
63
+ abilities: [
64
+ {
65
+ type: "keyword",
66
+ keyword: "challenger",
67
+ value: 5,
68
+ },
69
+ {
70
+ type: "triggered",
71
+ trigger: "whenPlayed",
72
+ effect: "drawCards",
73
+ amount: 2,
74
+ },
75
+ ],
76
+
77
+ // Flavor text
78
+ flavorText: "A friend in need is a friend indeed.",
79
+ };
80
+ ```
81
+
82
+ ## Card Types
83
+
84
+ Lorcana has several card types:
85
+
86
+ ```typescript
87
+ export type LorcanaCardType =
88
+ | "character"
89
+ | "action"
90
+ | "item"
91
+ | "location"
92
+ | "song";
93
+
94
+ export type LorcanaCard = {
95
+ // Base card properties
96
+ id: CardId;
97
+ name: string;
98
+ type: LorcanaCardType;
99
+ cost: number;
100
+ inkCost: number;
101
+ color: LorcanaColor;
102
+ inkable: boolean;
103
+ rarity: LorcanaRarity;
104
+ set: string;
105
+
106
+ // Character-specific
107
+ strength?: number;
108
+ willpower?: number;
109
+ loreValue?: number;
110
+
111
+ // Abilities
112
+ abilities: LorcanaAbility[];
113
+
114
+ // Text
115
+ text?: string;
116
+ flavorText?: string;
117
+ };
118
+ ```
119
+
120
+ ## Keyword Abilities
121
+
122
+ Keyword abilities are standardized mechanics:
123
+
124
+ ```typescript
125
+ // Bodyguard - Must be challenged before other characters
126
+ export const bodyguardAbility: KeywordAbility = {
127
+ keyword: "bodyguard",
128
+
129
+ // Modify challenge validation
130
+ modifyValidation: (state, context) => {
131
+ // Implementation: prevent challenging other characters
132
+ // if a bodyguard is available
133
+ },
134
+ };
135
+
136
+ // Challenger +N - Gets +N strength when challenging
137
+ export const challengerAbility: KeywordAbility = {
138
+ keyword: "challenger",
139
+
140
+ // Modify challenge damage
141
+ modifyDamage: (state, cardId, baseValue) => {
142
+ const card = getCard(state, cardId);
143
+ const challengerBonus = getKeywordValue(card, "challenger");
144
+ return baseValue + challengerBonus;
145
+ },
146
+ };
147
+ ```
148
+
149
+ ## Triggered Abilities
150
+
151
+ Abilities that trigger on events:
152
+
153
+ ```typescript
154
+ export type TriggeredAbility = {
155
+ type: "triggered";
156
+ trigger: TriggerTiming;
157
+ condition?: AbilityCondition;
158
+ effect: AbilityEffect;
159
+ target?: TargetDefinition;
160
+ };
161
+
162
+ // Example: "When you play this character, draw 2 cards"
163
+ const drawOnPlayAbility: TriggeredAbility = {
164
+ type: "triggered",
165
+ trigger: "whenPlayed",
166
+ effect: {
167
+ type: "drawCards",
168
+ amount: 2,
169
+ },
170
+ };
171
+ ```
172
+
173
+ ## Activated Abilities
174
+
175
+ Abilities players can choose to activate:
176
+
177
+ ```typescript
178
+ export type ActivatedAbility = {
179
+ type: "activated";
180
+ cost?: AbilityCost;
181
+ effect: AbilityEffect;
182
+ target?: TargetDefinition;
183
+ restrictions?: AbilityRestriction[];
184
+ };
185
+
186
+ // Example: "Exert - Draw a card"
187
+ const exertToDrawAbility: ActivatedAbility = {
188
+ type: "activated",
189
+ cost: {
190
+ type: "exert",
191
+ },
192
+ effect: {
193
+ type: "drawCards",
194
+ amount: 1,
195
+ },
196
+ };
197
+ ```
198
+
199
+ ## Card Registry
200
+
201
+ All cards registered for lookup:
202
+
203
+ ```typescript
204
+ // card-definitions/index.ts
205
+ import * as set001 from "./set-001";
206
+ import * as set002 from "./set-002";
207
+
208
+ export const allCards = {
209
+ ...set001.cards,
210
+ ...set002.cards,
211
+ };
212
+
213
+ export const getCardDefinition = (cardId: CardId): CardDefinition => {
214
+ return allCards[cardId];
215
+ };
216
+ ```
217
+
218
+ ## Card Instances vs Definitions
219
+
220
+ - **CardDefinition**: The template (what the card does)
221
+ - **CardInstance**: The runtime instance (current state)
222
+
223
+ ```typescript
224
+ // Definition (static)
225
+ const mickeyDefinition: CardDefinition = {
226
+ id: "001-001",
227
+ name: "Mickey Mouse",
228
+ strength: 4,
229
+ willpower: 6,
230
+ };
231
+
232
+ // Instance (dynamic)
233
+ const mickeyInstance: CardInstance = {
234
+ definitionId: "001-001",
235
+ instanceId: "game1-card-123",
236
+ ownerId: "player1",
237
+ zone: "play",
238
+
239
+ // Current state
240
+ damage: 2,
241
+ exerted: true,
242
+ playedThisTurn: true,
243
+ };
244
+ ```
245
+
246
+ ## Card Queries
247
+
248
+ Helper functions for common card operations:
249
+
250
+ ```typescript
251
+ export const getCardsInZone = (
252
+ state: LorcanaState,
253
+ zone: ZoneId,
254
+ playerId: PlayerId
255
+ ): CardInstance[] => {
256
+ return state.zones[zone][playerId].map(
257
+ cardId => state.cards[cardId]
258
+ );
259
+ };
260
+
261
+ export const getReadyCharacters = (
262
+ state: LorcanaState,
263
+ playerId: PlayerId
264
+ ): CardInstance[] => {
265
+ return getCardsInZone(state, "play", playerId).filter(
266
+ card => card.type === "character" && !card.exerted
267
+ );
268
+ };
269
+ ```
270
+
271
+ ## Testing Cards
272
+
273
+ Card abilities are tested through integration tests:
274
+
275
+ ```typescript
276
+ describe("Mickey Mouse - True Friend", () => {
277
+ it("draws 2 cards when played", () => {
278
+ const engine = createTestEngine();
279
+ const initialHandSize = getHandSize(engine.getState(), "player1");
280
+
281
+ engine.executeMove("playCard", {
282
+ playerId: "player1",
283
+ params: { cardId: "mickey-true-friend" },
284
+ });
285
+
286
+ const finalHandSize = getHandSize(engine.getState(), "player1");
287
+ expect(finalHandSize).toBe(initialHandSize + 2 - 1); // +2 draw, -1 played
288
+ });
289
+ });
290
+ ```
291
+
292
+ ## References
293
+
294
+ - See `@packages/core/src/cards/` for base card system
295
+ - See `@packages/core/ENGINE_INTEGRATION.md` for card integration guide
296
+
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Lorcana Ability System
3
+ *
4
+ * Provides a comprehensive, serializable type system for representing
5
+ * all Lorcana card abilities.
6
+ *
7
+ * ## Overview
8
+ *
9
+ * Lorcana abilities are composed of:
10
+ * - **Effects**: Atomic game actions (draw, damage, gain lore, etc.)
11
+ * - **Triggers**: Events that cause abilities to fire
12
+ * - **Conditions**: Requirements that must be met
13
+ * - **Costs**: What must be paid to activate abilities
14
+ * - **Targets**: Who/what is affected
15
+ *
16
+ * ## Ability Types
17
+ *
18
+ * - **Keyword**: Simple abilities like Rush, Ward, Challenger +X
19
+ * - **Triggered**: Fire when events occur (When/Whenever/At)
20
+ * - **Activated**: Player chooses to use by paying cost
21
+ * - **Static**: Always active, modify game state
22
+ *
23
+ * ## Usage
24
+ *
25
+ * ```typescript
26
+ * import {
27
+ * Ability,
28
+ * keyword,
29
+ * triggered,
30
+ * COMMON_TRIGGERS,
31
+ * } from "@lorcana/cards/abilities";
32
+ *
33
+ * // Simple keyword
34
+ * const rush: Ability = keyword("Rush");
35
+ *
36
+ * // Challenger +3
37
+ * const challengerAbility: Ability = challenger(3);
38
+ *
39
+ * // "When you play this character, draw 2 cards"
40
+ * const drawOnPlay: Ability = triggered(
41
+ * COMMON_TRIGGERS.WHEN_PLAY_SELF,
42
+ * { type: "draw", amount: 2, target: "CONTROLLER" }
43
+ * );
44
+ * ```
45
+ *
46
+ * ## Design Principles
47
+ *
48
+ * 1. **Serializable**: All types can be serialized to JSON
49
+ * 2. **Composable**: Effects combine into complex abilities
50
+ * 3. **Type-safe**: Discriminated unions with type guards
51
+ * 4. **Executable**: Structure is directly usable by game engine
52
+ *
53
+ * @module abilities
54
+ */
55
+
56
+ // Re-export all types from lorcana-types
57
+ export * from "@drmxrcy/tcg-lorcana-types/abilities";
58
+
59
+ // ============================================================================
60
+ // Version Info
61
+ // ============================================================================
62
+
63
+ /**
64
+ * Version of the ability type system
65
+ * Increment when making breaking changes
66
+ */
67
+ export const ABILITY_TYPES_VERSION = "1.0.0";
68
+
69
+ // ============================================================================
70
+ // Quick Reference: Common Ability Patterns
71
+ // ============================================================================
72
+
73
+ /**
74
+ * Example abilities for reference and testing
75
+ */
76
+ export const EXAMPLE_ABILITIES = {
77
+ /**
78
+ * Simple Rush keyword
79
+ */
80
+ rush: {
81
+ type: "keyword",
82
+ keyword: "Rush",
83
+ },
84
+
85
+ /**
86
+ * Challenger +3
87
+ */
88
+ challengerPlus3: {
89
+ type: "keyword",
90
+ keyword: "Challenger",
91
+ value: 3,
92
+ },
93
+
94
+ /**
95
+ * Resist +2
96
+ */
97
+ resistPlus2: {
98
+ type: "keyword",
99
+ keyword: "Resist",
100
+ value: 2,
101
+ },
102
+
103
+ /**
104
+ * Shift 5 (can Shift onto any matching character)
105
+ */
106
+ shift5: {
107
+ type: "keyword",
108
+ keyword: "Shift",
109
+ shiftCost: 5,
110
+ },
111
+
112
+ /**
113
+ * "When you play this character, draw 2 cards"
114
+ */
115
+ drawOnPlay: {
116
+ type: "triggered",
117
+ trigger: { event: "play", timing: "when", on: "SELF" },
118
+ effect: { type: "draw", amount: 2, target: "CONTROLLER" },
119
+ },
120
+
121
+ /**
122
+ * "Whenever this character quests, gain 1 lore"
123
+ */
124
+ gainLoreOnQuest: {
125
+ type: "triggered",
126
+ trigger: { event: "quest", timing: "whenever", on: "SELF" },
127
+ effect: { type: "gain-lore", amount: 1 },
128
+ },
129
+
130
+ /**
131
+ * "{E} - Draw a card"
132
+ */
133
+ exertToDraw: {
134
+ type: "activated",
135
+ cost: { exert: true },
136
+ effect: { type: "draw", amount: 1, target: "CONTROLLER" },
137
+ },
138
+
139
+ /**
140
+ * "{E}, 2 {I} - Deal 3 damage to chosen character"
141
+ */
142
+ exertInkToDamage: {
143
+ type: "activated",
144
+ cost: { exert: true, ink: 2 },
145
+ effect: { type: "deal-damage", amount: 3, target: "CHOSEN_CHARACTER" },
146
+ },
147
+
148
+ /**
149
+ * "Your characters gain Ward"
150
+ */
151
+ yourCharactersGainWard: {
152
+ type: "static",
153
+ effect: {
154
+ type: "gain-keyword",
155
+ keyword: "Ward",
156
+ target: "YOUR_CHARACTERS",
157
+ duration: "while-condition",
158
+ },
159
+ },
160
+
161
+ /**
162
+ * "While this character has no damage, he gets +2 Strength"
163
+ */
164
+ bonusWhileNoDamage: {
165
+ type: "static",
166
+ condition: { type: "no-damage" },
167
+ effect: {
168
+ type: "modify-stat",
169
+ stat: "strength",
170
+ modifier: 2,
171
+ target: "SELF",
172
+ duration: "while-condition",
173
+ },
174
+ },
175
+ } as const;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Cards Module
3
+ *
4
+ * Card definitions, abilities, and card-related functionality for Lorcana.
5
+ *
6
+ * @module cards
7
+ */
8
+
9
+ // Export ability types and utilities
10
+ export * from "./abilities";
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Deck Validation (Rule 2.1)
3
+ *
4
+ * Validates a deck against Lorcana deck building rules:
5
+ * - Minimum 60 cards (Rule 2.1.1.1)
6
+ * - Maximum 2 ink types (Rule 2.1.1.2)
7
+ * - Maximum 4 copies per full name (Rule 2.1.1.3)
8
+ */
9
+
10
+ import type {
11
+ DeckStats,
12
+ DeckValidationError,
13
+ DeckValidationResult,
14
+ InkType,
15
+ LorcanaCardDefinition,
16
+ } from "@drmxrcy/tcg-lorcana-types";
17
+ import {
18
+ getFullName,
19
+ getInkTypes,
20
+ MAX_COPIES_PER_CARD,
21
+ MAX_INK_TYPES,
22
+ MIN_DECK_SIZE,
23
+ } from "@drmxrcy/tcg-lorcana-types";
24
+
25
+ /**
26
+ * Validate a deck against Lorcana rules
27
+ *
28
+ * @param cards - Array of card definitions in the deck
29
+ * @returns Validation result with any errors
30
+ */
31
+ export function validateDeck(
32
+ cards: LorcanaCardDefinition[],
33
+ ): DeckValidationResult {
34
+ const errors: DeckValidationError[] = [];
35
+
36
+ // Rule 2.1.1.1: Minimum 60 cards
37
+ if (cards.length < MIN_DECK_SIZE) {
38
+ errors.push({
39
+ type: "TOO_FEW_CARDS",
40
+ count: cards.length,
41
+ minimum: MIN_DECK_SIZE,
42
+ });
43
+ }
44
+
45
+ // Rule 2.1.1.2: Maximum 2 ink types
46
+ const inkTypes = getUniqueInkTypes(cards);
47
+ if (inkTypes.length > MAX_INK_TYPES) {
48
+ errors.push({
49
+ type: "TOO_MANY_INK_TYPES",
50
+ inkTypes,
51
+ maximum: MAX_INK_TYPES,
52
+ });
53
+ }
54
+
55
+ // Rule 2.1.1.3: Maximum copies per full name (default 4, can be overridden)
56
+ const cardCounts = getCardCounts(cards);
57
+ const copyLimits = getCardCopyLimits(cards);
58
+
59
+ for (const [fullName, count] of cardCounts.entries()) {
60
+ const limit = copyLimits.get(fullName);
61
+
62
+ // Skip validation for cards with no limit
63
+ if (limit === "no-limit") {
64
+ continue;
65
+ }
66
+
67
+ const maxCopies = limit ?? MAX_COPIES_PER_CARD;
68
+ if (count > maxCopies) {
69
+ errors.push({
70
+ type: "TOO_MANY_COPIES",
71
+ fullName,
72
+ count,
73
+ maximum: maxCopies,
74
+ });
75
+ }
76
+ }
77
+
78
+ return {
79
+ valid: errors.length === 0,
80
+ errors,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Get all unique ink types in a deck
86
+ */
87
+ export function getUniqueInkTypes(cards: LorcanaCardDefinition[]): InkType[] {
88
+ const inkSet = new Set<InkType>();
89
+ for (const card of cards) {
90
+ for (const ink of getInkTypes(card)) {
91
+ inkSet.add(ink);
92
+ }
93
+ }
94
+ return Array.from(inkSet);
95
+ }
96
+
97
+ /**
98
+ * Count cards by full name
99
+ */
100
+ export function getCardCounts(
101
+ cards: LorcanaCardDefinition[],
102
+ ): Map<string, number> {
103
+ const counts = new Map<string, number>();
104
+ for (const card of cards) {
105
+ const fullName = getFullName(card);
106
+ counts.set(fullName, (counts.get(fullName) ?? 0) + 1);
107
+ }
108
+ return counts;
109
+ }
110
+
111
+ /**
112
+ * Get card copy limits by full name
113
+ * Returns undefined for default limit (4), number for custom limit, or "no-limit" for unlimited
114
+ */
115
+ export function getCardCopyLimits(
116
+ cards: LorcanaCardDefinition[],
117
+ ): Map<string, number | "no-limit" | undefined> {
118
+ const limits = new Map<string, number | "no-limit" | undefined>();
119
+ for (const card of cards) {
120
+ const fullName = getFullName(card);
121
+ if (card.cardCopyLimit !== undefined && !limits.has(fullName)) {
122
+ limits.set(fullName, card.cardCopyLimit);
123
+ }
124
+ }
125
+ return limits;
126
+ }
127
+
128
+ /**
129
+ * Calculate deck statistics
130
+ */
131
+ export function getDeckStats(cards: LorcanaCardDefinition[]): DeckStats {
132
+ const cardCounts = getCardCounts(cards);
133
+ const inkTypes = getUniqueInkTypes(cards);
134
+
135
+ const cardTypeBreakdown = {
136
+ characters: 0,
137
+ actions: 0,
138
+ items: 0,
139
+ locations: 0,
140
+ };
141
+
142
+ let inkableCards = 0;
143
+ let totalCost = 0;
144
+
145
+ for (const card of cards) {
146
+ switch (card.cardType) {
147
+ case "character":
148
+ cardTypeBreakdown.characters++;
149
+ break;
150
+ case "action":
151
+ cardTypeBreakdown.actions++;
152
+ break;
153
+ case "item":
154
+ cardTypeBreakdown.items++;
155
+ break;
156
+ case "location":
157
+ cardTypeBreakdown.locations++;
158
+ break;
159
+ }
160
+
161
+ if (card.inkable) {
162
+ inkableCards++;
163
+ }
164
+ totalCost += card.cost;
165
+ }
166
+
167
+ return {
168
+ totalCards: cards.length,
169
+ inkTypes,
170
+ cardCounts,
171
+ cardTypeBreakdown,
172
+ inkableCards,
173
+ averageCost: cards.length > 0 ? totalCost / cards.length : 0,
174
+ };
175
+ }