@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,395 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { createPlayerId } from "@drmxrcy/tcg-core";
3
+ import {
4
+ LorcanaTestEngine,
5
+ PLAYER_ONE,
6
+ PLAYER_TWO,
7
+ } from "../../../testing/lorcana-test-engine";
8
+
9
+ describe("Move: Alter Hand (Mulligan)", () => {
10
+ let testEngine: LorcanaTestEngine;
11
+
12
+ beforeEach(() => {
13
+ testEngine = new LorcanaTestEngine(
14
+ { hand: 7, deck: 10 },
15
+ { hand: 7, deck: 10 },
16
+ { skipPreGame: false },
17
+ );
18
+
19
+ // Choose first player to get to mulligan phase
20
+ const ctx = testEngine.getCtx();
21
+ const choosingPlayer = ctx.choosingFirstPlayer;
22
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
23
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
24
+
25
+ expect(testEngine.getGameSegment()).toBe("startingAGame");
26
+ expect(testEngine.getGamePhase()).toBe("mulligan");
27
+ });
28
+
29
+ afterEach(() => {
30
+ testEngine.dispose();
31
+ });
32
+
33
+ // ========== Basic Behavior Tests ==========
34
+
35
+ describe("Basic Mulligan Behavior", () => {
36
+ it("should allow keeping all cards (empty mulligan)", () => {
37
+ const initialHand = [...testEngine.getZone("hand", PLAYER_ONE)];
38
+ expect(initialHand.length).toBe(7);
39
+
40
+ // Mulligan with empty array (keep all cards)
41
+ testEngine.changeActivePlayer(PLAYER_ONE);
42
+ testEngine.alterHand([]);
43
+
44
+ const finalHand = testEngine.getZone("hand", PLAYER_ONE);
45
+ expect(finalHand.length).toBe(7);
46
+
47
+ // Should have same cards
48
+ for (const cardId of initialHand) {
49
+ expect(finalHand).toContain(cardId);
50
+ }
51
+
52
+ // Player should be marked as mulliganed
53
+ const ctx = testEngine.getCtx();
54
+ expect(ctx.pendingMulligan).not.toContain(PLAYER_ONE);
55
+ });
56
+
57
+ it("should mulligan whole hand (all 7 cards)", () => {
58
+ const initialHand = [...testEngine.getZone("hand", PLAYER_ONE)];
59
+ const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
60
+
61
+ expect(initialHand.length).toBe(7);
62
+ expect(initialDeck.length).toBe(10);
63
+
64
+ testEngine.changeActivePlayer(PLAYER_ONE);
65
+ testEngine.alterHand(initialHand);
66
+
67
+ const finalHand = testEngine.getZone("hand", PLAYER_ONE);
68
+ const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
69
+
70
+ // Should still have 7 cards in hand
71
+ expect(finalHand.length).toBe(7);
72
+
73
+ // None of the original hand cards should be in new hand
74
+ for (const cardId of initialHand) {
75
+ expect(finalHand).not.toContain(cardId);
76
+ }
77
+
78
+ // Original hand cards should be in deck (shuffled, so anywhere in deck)
79
+ for (const cardId of initialHand) {
80
+ expect(finalDeck).toContain(cardId);
81
+ }
82
+
83
+ // Deck should still have 10 cards (7 returned + 7 drawn from top = 10)
84
+ expect(finalDeck.length).toBe(10);
85
+ });
86
+
87
+ it("should mulligan partial hand (3 cards)", () => {
88
+ const initialHand = [...testEngine.getZone("hand", PLAYER_ONE)];
89
+ const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
90
+
91
+ const cardsToMulligan = initialHand.slice(0, 3);
92
+ const cardsToKeep = initialHand.slice(3);
93
+
94
+ testEngine.changeActivePlayer(PLAYER_ONE);
95
+ testEngine.alterHand(cardsToMulligan);
96
+
97
+ const finalHand = testEngine.getZone("hand", PLAYER_ONE);
98
+ const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
99
+
100
+ // Should still have 7 cards
101
+ expect(finalHand.length).toBe(7);
102
+
103
+ // Kept cards should still be in hand
104
+ for (const cardId of cardsToKeep) {
105
+ expect(finalHand).toContain(cardId);
106
+ }
107
+
108
+ // Mulliganed cards should NOT be in hand
109
+ for (const cardId of cardsToMulligan) {
110
+ expect(finalHand).not.toContain(cardId);
111
+ }
112
+
113
+ // Mulliganed cards should be in deck (shuffled, so anywhere in deck)
114
+ for (const cardId of cardsToMulligan) {
115
+ expect(finalDeck).toContain(cardId);
116
+ }
117
+
118
+ // New cards should be from top of initial deck
119
+ const newCards = finalHand.filter((id) => !initialHand.includes(id));
120
+ expect(newCards.length).toBe(3);
121
+
122
+ const topOfInitialDeck = initialDeck.slice(0, 3);
123
+ for (const cardId of topOfInitialDeck) {
124
+ expect(finalHand).toContain(cardId);
125
+ }
126
+ });
127
+ });
128
+
129
+ // ========== Priority & Turn Order Tests ==========
130
+
131
+ describe("Priority and Turn Order", () => {
132
+ it("should pass priority to next player after mulligan", () => {
133
+ // Player one has priority first
134
+ expect(testEngine.getPriorityPlayers()).toContain(PLAYER_ONE);
135
+
136
+ testEngine.changeActivePlayer(PLAYER_ONE);
137
+ testEngine.alterHand([]);
138
+
139
+ // Priority should switch to player two
140
+ expect(testEngine.getPriorityPlayers()).toContain(PLAYER_TWO);
141
+ expect(testEngine.getPriorityPlayers()).not.toContain(PLAYER_ONE);
142
+ });
143
+
144
+ it("should transition to main game when all players mulligan", () => {
145
+ // Player one mulligans
146
+ testEngine.changeActivePlayer(PLAYER_ONE);
147
+ testEngine.alterHand([]);
148
+
149
+ expect(testEngine.getGamePhase()).toBe("mulligan");
150
+
151
+ // Player two mulligans (last player)
152
+ testEngine.changeActivePlayer(PLAYER_TWO);
153
+ testEngine.alterHand([]);
154
+
155
+ // After last mulligan, pending list should be empty
156
+ const ctx = testEngine.getCtx();
157
+ expect(ctx.pendingMulligan).toHaveLength(0);
158
+
159
+ // Note: Segment transition happens via flow manager's endIf check
160
+ // which is triggered by endPhase(). This works in real gameplay.
161
+ });
162
+
163
+ it("should respect priority - can't mulligan out of turn", () => {
164
+ // Player one has priority
165
+ expect(testEngine.getPriorityPlayers()).toContain(PLAYER_ONE);
166
+
167
+ // Try to mulligan as player two (not their turn)
168
+ testEngine.changeActivePlayer(PLAYER_TWO);
169
+
170
+ const result = testEngine.engine.executeMove("alterHand", {
171
+ playerId: createPlayerId(PLAYER_TWO),
172
+ params: {
173
+ playerId: createPlayerId(PLAYER_TWO),
174
+ cardsToMulligan: [],
175
+ },
176
+ });
177
+
178
+ expect(result.success).toBe(false);
179
+ if (!result.success) {
180
+ expect(result.errorCode).toBe("NOT_PRIORITY_PLAYER");
181
+ expect(result.error).toContain("can mulligan right now");
182
+ }
183
+ });
184
+ });
185
+
186
+ // ========== Edge Case Tests ==========
187
+
188
+ describe("Edge Cases - Invalid Card IDs", () => {
189
+ it("should reject invalid card IDs", () => {
190
+ testEngine.changeActivePlayer(PLAYER_ONE);
191
+
192
+ const result = testEngine.engine.executeMove("alterHand", {
193
+ playerId: createPlayerId(PLAYER_ONE),
194
+ params: {
195
+ playerId: createPlayerId(PLAYER_ONE),
196
+ cardsToMulligan: ["invalid-card-id-12345"],
197
+ },
198
+ });
199
+
200
+ expect(result.success).toBe(false);
201
+ if (!result.success) {
202
+ expect(result.errorCode).toBe("INVALID_CARD_ID");
203
+ expect(result.error).toContain("invalid-card-id-12345");
204
+ expect(result.error).toContain("does not exist");
205
+ }
206
+ });
207
+
208
+ it("should reject cards not in hand", () => {
209
+ testEngine.changeActivePlayer(PLAYER_ONE);
210
+
211
+ const deckCards = testEngine.getZone("deck", PLAYER_ONE);
212
+ const cardInDeck = deckCards[0];
213
+
214
+ const result = testEngine.engine.executeMove("alterHand", {
215
+ playerId: createPlayerId(PLAYER_ONE),
216
+ params: {
217
+ playerId: createPlayerId(PLAYER_ONE),
218
+ cardsToMulligan: [cardInDeck],
219
+ },
220
+ });
221
+
222
+ expect(result.success).toBe(false);
223
+ if (!result.success) {
224
+ expect(result.errorCode).toBe("CARD_NOT_IN_HAND");
225
+ expect(result.error).toContain("not in your hand");
226
+ expect(result.error).toContain("deck");
227
+ }
228
+ });
229
+
230
+ it("should reject opponent's cards", () => {
231
+ testEngine.changeActivePlayer(PLAYER_ONE);
232
+
233
+ const opponentHand = testEngine.getZone("hand", PLAYER_TWO);
234
+ const opponentCard = opponentHand[0];
235
+
236
+ const result = testEngine.engine.executeMove("alterHand", {
237
+ playerId: createPlayerId(PLAYER_ONE),
238
+ params: {
239
+ playerId: createPlayerId(PLAYER_ONE),
240
+ cardsToMulligan: [opponentCard],
241
+ },
242
+ });
243
+
244
+ expect(result.success).toBe(false);
245
+ if (!result.success) {
246
+ expect(result.errorCode).toBe("CARD_NOT_IN_HAND");
247
+ expect(result.error).toContain("not in your hand");
248
+ }
249
+ });
250
+
251
+ it("should reject more cards than in hand", () => {
252
+ testEngine.changeActivePlayer(PLAYER_ONE);
253
+
254
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
255
+ const tooManyCards = [...hand, ...hand]; // Duplicate to get 14 cards
256
+
257
+ const result = testEngine.engine.executeMove("alterHand", {
258
+ playerId: createPlayerId(PLAYER_ONE),
259
+ params: {
260
+ playerId: createPlayerId(PLAYER_ONE),
261
+ cardsToMulligan: tooManyCards,
262
+ },
263
+ });
264
+
265
+ expect(result.success).toBe(false);
266
+ if (!result.success) {
267
+ expect(result.errorCode).toBe("TOO_MANY_CARDS");
268
+ expect(result.error).toContain("14 cards");
269
+ expect(result.error).toContain("only has 7");
270
+ }
271
+ });
272
+ });
273
+
274
+ describe("Edge Cases - Phase and State Validation", () => {
275
+ it("should reject mulligan after mulligan phase ends", () => {
276
+ // Complete mulligans
277
+ testEngine.changeActivePlayer(PLAYER_ONE);
278
+ testEngine.alterHand([]);
279
+ testEngine.changeActivePlayer(PLAYER_TWO);
280
+ testEngine.alterHand([]);
281
+
282
+ // All players have mulliganed
283
+ const ctx = testEngine.getCtx();
284
+ expect(ctx.pendingMulligan).toHaveLength(0);
285
+
286
+ // Try to mulligan again after mulligan phase
287
+ testEngine.changeActivePlayer(PLAYER_ONE);
288
+
289
+ const result = testEngine.engine.executeMove("alterHand", {
290
+ playerId: createPlayerId(PLAYER_ONE),
291
+ params: {
292
+ playerId: createPlayerId(PLAYER_ONE),
293
+ cardsToMulligan: [],
294
+ },
295
+ });
296
+
297
+ expect(result.success).toBe(false);
298
+ if (!result.success) {
299
+ // Phase has ended (no longer in mulligan phase)
300
+ // After phase transition fix, game correctly advances to next phase
301
+ expect(result.errorCode).toBe("WRONG_PHASE");
302
+ expect(result.error).toContain("phase");
303
+ }
304
+ });
305
+
306
+ it("should reject mulliganing twice", () => {
307
+ // First mulligan
308
+ testEngine.changeActivePlayer(PLAYER_ONE);
309
+ testEngine.alterHand([]);
310
+
311
+ const ctx = testEngine.getCtx();
312
+ expect(ctx.pendingMulligan).not.toContain(PLAYER_ONE);
313
+
314
+ // Try to mulligan again - should fail because not in pending list
315
+ // (Priority has passed to PLAYER_TWO, so this will fail on priority check)
316
+ const result = testEngine.engine.executeMove("alterHand", {
317
+ playerId: createPlayerId(PLAYER_ONE),
318
+ params: {
319
+ playerId: createPlayerId(PLAYER_ONE),
320
+ cardsToMulligan: [],
321
+ },
322
+ });
323
+
324
+ expect(result.success).toBe(false);
325
+ if (!result.success) {
326
+ // Will fail on already-mulliganed check (checked before priority)
327
+ expect(result.errorCode).toBe("ALREADY_MULLIGANED");
328
+ expect(result.error).toContain("already mulliganed");
329
+ }
330
+ });
331
+ });
332
+
333
+ // ========== Shuffle Behavior Tests ==========
334
+
335
+ describe("Shuffle Behavior", () => {
336
+ it("should NOT shuffle when keeping all cards", () => {
337
+ const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
338
+
339
+ testEngine.changeActivePlayer(PLAYER_ONE);
340
+ testEngine.alterHand([]); // Keep all cards
341
+
342
+ const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
343
+
344
+ // Deck should be unchanged (no shuffle, no draws)
345
+ expect(finalDeck).toEqual(initialDeck);
346
+ });
347
+
348
+ it("should shuffle when returning cards", () => {
349
+ const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
350
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
351
+
352
+ testEngine.changeActivePlayer(PLAYER_ONE);
353
+ testEngine.alterHand([hand[0]]); // Return 1 card
354
+
355
+ const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
356
+
357
+ // Deck should be shuffled (very unlikely to be in same order)
358
+ // We can't test randomness perfectly, but we can check deck was modified
359
+ // Note: With small deck size, there's a tiny chance they're the same
360
+ // This is acceptable for testing
361
+ expect(finalDeck.length).toBe(initialDeck.length);
362
+ expect(finalDeck).toContain(hand[0]); // Returned card is in deck
363
+ });
364
+ });
365
+
366
+ // ========== Cards Go to Bottom Tests ==========
367
+
368
+ describe("Cards Go to Bottom of Deck (Lorcana-Specific)", () => {
369
+ it("should place returned cards at bottom before shuffle", () => {
370
+ const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
371
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
372
+ const cardsToReturn = hand.slice(0, 3);
373
+
374
+ // We can't fully test this after shuffle, but we can verify:
375
+ // 1. Cards are in deck
376
+ // 2. Deck has correct size
377
+ // 3. New hand doesn't contain returned cards
378
+
379
+ testEngine.changeActivePlayer(PLAYER_ONE);
380
+ testEngine.alterHand(cardsToReturn);
381
+
382
+ const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
383
+ const finalHand = testEngine.getZone("hand", PLAYER_ONE);
384
+
385
+ // Returned cards should be somewhere in deck
386
+ for (const cardId of cardsToReturn) {
387
+ expect(finalDeck).toContain(cardId);
388
+ expect(finalHand).not.toContain(cardId);
389
+ }
390
+
391
+ // Deck size should be correct
392
+ expect(finalDeck.length).toBe(initialDeck.length);
393
+ });
394
+ });
395
+ });
@@ -0,0 +1,210 @@
1
+ import {
2
+ type CardId,
3
+ type ConditionFailure,
4
+ createMove,
5
+ type PlayerId,
6
+ type ZoneId,
7
+ } from "@drmxrcy/tcg-core";
8
+ import type {
9
+ LorcanaCardMeta,
10
+ LorcanaGameState,
11
+ LorcanaMoveParams,
12
+ } from "../../../types";
13
+
14
+ /**
15
+ * Alter Hand Move (Mulligan)
16
+ *
17
+ * Rule 3.1.6: Players may mulligan by putting cards on bottom of deck
18
+ *
19
+ * Lorcana-specific mulligan process:
20
+ * 1. Step 1 (Rule 3.1.6.1): Put selected cards on BOTTOM of deck (not shuffled in)
21
+ * 2. Step 2 (Rule 3.1.6.2): Draw until player has 7 cards
22
+ * 3. Step 4 (Rule 3.1.6.4): Shuffle deck ONLY if 1+ cards were returned
23
+ * 4. Priority passes to next player who needs to mulligan
24
+ * 5. When all done, transition to main game
25
+ */
26
+ export const alterHand = createMove<
27
+ LorcanaGameState,
28
+ LorcanaMoveParams,
29
+ "alterHand",
30
+ LorcanaCardMeta
31
+ >({
32
+ // Enumerator: Returns targeting constraints for UI/AI
33
+ // UI will present card selection interface: "Select 0-7 cards from your hand to mulligan"
34
+ // AI can enumerate all valid combinations based on these constraints
35
+ enumerator: (state, context) => {
36
+ // Get cards in hand for validation constraints
37
+ const handCards =
38
+ context.zones?.getCardsInZone("hand" as ZoneId, context.playerId) || [];
39
+
40
+ // Return single parameter set with targeting information
41
+ // The targeting system will handle enumerating card combinations
42
+ return [
43
+ {
44
+ playerId: context.playerId,
45
+ cardsToMulligan: [], // Default: keep all cards
46
+ // Include validation constraints for UI/AI
47
+ validation: {
48
+ maxCards: Math.min(7, handCards.length),
49
+ validCards: handCards,
50
+ },
51
+ // TODO: Integrate with targeting system DSL
52
+ // target: {
53
+ // filter: {
54
+ // zone: "hand" as ZoneId,
55
+ // owner: context.playerId
56
+ // },
57
+ // count: { min: 0, max: 7 }
58
+ // }
59
+ },
60
+ ];
61
+ },
62
+
63
+ condition: (state, context): true | ConditionFailure => {
64
+ const { playerId, cardsToMulligan } = context.params;
65
+
66
+ // 1. Check we're in the correct phase
67
+ if (context.flow?.currentPhase !== "mulligan") {
68
+ return {
69
+ reason: `Cannot mulligan during ${context.flow?.currentPhase || "unknown"} phase. Must be in mulligan phase.`,
70
+ errorCode: "WRONG_PHASE",
71
+ context: {
72
+ currentPhase: context.flow?.currentPhase,
73
+ requiredPhase: "mulligan",
74
+ },
75
+ };
76
+ }
77
+
78
+ // 2. Check player is in pending mulligan list
79
+ const pendingMulligan = context.game.getPendingMulligan();
80
+ if (!pendingMulligan.includes(playerId)) {
81
+ return {
82
+ reason: `Player ${String(playerId)} has already mulliganed or is not eligible to mulligan.`,
83
+ errorCode: "ALREADY_MULLIGANED",
84
+ context: {
85
+ playerId: String(playerId),
86
+ pendingPlayers: pendingMulligan.map((p) => String(p)),
87
+ },
88
+ };
89
+ }
90
+
91
+ // 3. Check player has priority (is current player)
92
+ const currentPlayer = context.flow?.currentPlayer;
93
+ if (currentPlayer !== playerId) {
94
+ return {
95
+ reason: `Only ${String(currentPlayer)} can mulligan right now. You are ${String(playerId)}.`,
96
+ errorCode: "NOT_PRIORITY_PLAYER",
97
+ context: {
98
+ currentPlayer: String(currentPlayer),
99
+ executingPlayer: String(playerId),
100
+ },
101
+ };
102
+ }
103
+
104
+ // 4. Validate all card IDs are valid
105
+ for (const cardId of cardsToMulligan) {
106
+ const cardZone = context.zones.getCardZone(cardId);
107
+ if (cardZone === undefined) {
108
+ return {
109
+ reason: `Invalid card ID: ${cardId}. Card does not exist in any zone.`,
110
+ errorCode: "INVALID_CARD_ID",
111
+ context: {
112
+ cardId,
113
+ },
114
+ };
115
+ }
116
+ }
117
+
118
+ // 5. Validate all cards are in player's hand
119
+ const handCards = context.zones.getCardsInZone("hand" as ZoneId, playerId);
120
+ for (const cardId of cardsToMulligan) {
121
+ if (!handCards.includes(cardId)) {
122
+ const cardZone = context.zones.getCardZone(cardId);
123
+ const cardOwner = context.cards.getCardOwner(cardId);
124
+
125
+ return {
126
+ reason: `Card ${cardId} is not in your hand. It's in ${cardZone || "unknown zone"} owned by ${cardOwner || "unknown"}.`,
127
+ errorCode: "CARD_NOT_IN_HAND",
128
+ context: {
129
+ cardId,
130
+ cardZone,
131
+ cardOwner: String(cardOwner),
132
+ playerId: String(playerId),
133
+ },
134
+ };
135
+ }
136
+ }
137
+
138
+ // 6. Validate cards to mulligan don't exceed hand size
139
+ if (cardsToMulligan.length > handCards.length) {
140
+ return {
141
+ reason: `Cannot mulligan ${cardsToMulligan.length} cards when hand only has ${handCards.length} cards.`,
142
+ errorCode: "TOO_MANY_CARDS",
143
+ context: {
144
+ requested: cardsToMulligan.length,
145
+ handSize: handCards.length,
146
+ },
147
+ };
148
+ }
149
+
150
+ return true;
151
+ },
152
+
153
+ reducer: (draft, context) => {
154
+ const { playerId, cardsToMulligan } = context.params;
155
+
156
+ // Rule 3.1.6.1: Put selected cards on BOTTOM of deck (not shuffled in yet)
157
+ if (cardsToMulligan.length > 0) {
158
+ for (const cardId of cardsToMulligan) {
159
+ context.zones.moveCard({
160
+ cardId,
161
+ targetZoneId: "deck" as ZoneId,
162
+ position: "bottom", // Lorcana-specific: cards go to bottom
163
+ });
164
+ }
165
+ }
166
+
167
+ // Rule 3.1.6.2: Draw until player has 7 cards
168
+ const currentHandSize = context.zones.getCardsInZone(
169
+ "hand" as ZoneId,
170
+ playerId,
171
+ ).length;
172
+ const cardsToDraw = 7 - currentHandSize;
173
+
174
+ if (cardsToDraw > 0) {
175
+ const drawnCards = context.zones.drawCards({
176
+ from: "deck" as ZoneId,
177
+ to: "hand" as ZoneId,
178
+ count: cardsToDraw,
179
+ playerId,
180
+ });
181
+
182
+ // Validate that we drew enough cards (deck exhaustion check)
183
+ if (drawnCards.length < cardsToDraw) {
184
+ throw new Error(
185
+ `Cannot complete mulligan: deck exhausted. Needed to draw ${cardsToDraw} cards but only drew ${drawnCards.length}. This violates Lorcana Rule 3.1.6.2 (must have exactly 7 cards after mulligan).`,
186
+ );
187
+ }
188
+ }
189
+
190
+ // Rule 3.1.6.4: Shuffle deck ONLY if 1 or more cards were altered
191
+ if (cardsToMulligan.length > 0) {
192
+ context.zones.shuffleZone("deck" as ZoneId, playerId);
193
+ }
194
+
195
+ // Remove player from pending mulligan list
196
+ context.game.removePendingMulligan(playerId);
197
+
198
+ // Switch priority to the next pending player
199
+ const pendingMulligan = context.game.getPendingMulligan();
200
+
201
+ if (pendingMulligan.length > 0) {
202
+ if (context.flow?.setCurrentPlayer) {
203
+ // Set priority to the next player who needs to mulligan
204
+ context.flow.setCurrentPlayer(pendingMulligan[0]);
205
+ }
206
+ }
207
+ // When all players complete mulligan (pending list empty), flow manager
208
+ // will auto-transition via its endIf condition on next move attempt or flow check
209
+ },
210
+ });