@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,42 @@
1
+ import { createMove } from "@drmxrcy/tcg-core";
2
+ import { useLorcanaOps } from "../../../operations";
3
+ import type {
4
+ LorcanaCardMeta,
5
+ LorcanaGameState,
6
+ LorcanaMoveParams,
7
+ } from "../../../types";
8
+ import { and, cardInPlay, isMainPhase } from "../../../validators";
9
+
10
+ /**
11
+ * Move Character to Location
12
+ *
13
+ * Rule 6.5: Characters can move to locations
14
+ *
15
+ * Requirements:
16
+ * - Character must be in play
17
+ * - Location must be in play
18
+ * - Location must have available slots
19
+ * - Character must meet location requirements
20
+ *
21
+ * Effects:
22
+ * - Character gains location bonuses
23
+ * - Character counts toward location conditions
24
+ */
25
+ export const moveCharacterToLocation = createMove<
26
+ LorcanaGameState,
27
+ LorcanaMoveParams,
28
+ "moveCharacterToLocation",
29
+ LorcanaCardMeta
30
+ >({
31
+ condition: and(
32
+ isMainPhase(),
33
+ (state, context) => cardInPlay(context.params.characterId)(state, context),
34
+ (state, context) => cardInPlay(context.params.locationId)(state, context),
35
+ ),
36
+ reducer: (_draft, context) => {
37
+ const { characterId, locationId } = context.params;
38
+ const ops = useLorcanaOps(context);
39
+
40
+ ops.moveToLocation(characterId, locationId);
41
+ },
42
+ });
@@ -0,0 +1,462 @@
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: Put a Card Into The Inkwell", () => {
10
+ let testEngine: LorcanaTestEngine;
11
+
12
+ beforeEach(() => {
13
+ testEngine = new LorcanaTestEngine(
14
+ { hand: 7, deck: 10, inkwell: 0 },
15
+ { hand: 7, deck: 10, inkwell: 0 },
16
+ { skipPreGame: false },
17
+ );
18
+
19
+ // Complete pre-game setup to get to main phase
20
+ const ctx = testEngine.getCtx();
21
+ const choosingPlayer = ctx.choosingFirstPlayer;
22
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
23
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
24
+
25
+ // Complete mulligans for both players
26
+ testEngine.changeActivePlayer(PLAYER_ONE);
27
+ testEngine.alterHand([]);
28
+ testEngine.changeActivePlayer(PLAYER_TWO);
29
+ testEngine.alterHand([]);
30
+
31
+ // After mulligans, game is in mainGame segment, turn 2, player_two's turn
32
+ // Need to pass turns to get to a stable state
33
+
34
+ // Player two takes their turn (beginning -> main -> end -> next turn)
35
+ testEngine.changeActivePlayer(PLAYER_TWO);
36
+ testEngine.passTurn(); // beginning -> main
37
+ testEngine.passTurn(); // main -> end -> turn 3 beginning -> main (player_one)
38
+
39
+ // Player one takes their turn
40
+ testEngine.changeActivePlayer(PLAYER_ONE);
41
+ testEngine.passTurn(); // main -> end -> turn 4 beginning -> main (player_two)
42
+
43
+ // Back to player two
44
+ testEngine.changeActivePlayer(PLAYER_TWO);
45
+ testEngine.passTurn(); // main -> end -> turn 5 beginning -> main (player_one)
46
+
47
+ // Now on player_one's turn in main phase
48
+ testEngine.changeActivePlayer(PLAYER_ONE);
49
+ // Now we're in main phase and can ink cards
50
+ });
51
+
52
+ afterEach(() => {
53
+ testEngine.dispose();
54
+ });
55
+
56
+ // ========== Basic Behavior Tests ==========
57
+
58
+ describe("Basic Inking Behavior", () => {
59
+ it("should successfully ink a card from hand", () => {
60
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
61
+ const cardToInk = hand[0];
62
+ const initialHandSize = hand.length;
63
+
64
+ expect(initialHandSize).toBeGreaterThan(0);
65
+ expect(testEngine.getZone("inkwell", PLAYER_ONE).length).toBe(0);
66
+
67
+ // Ink the card
68
+ testEngine.putCardInInkwell(cardToInk);
69
+
70
+ // Verify card moved zones
71
+ const newHand = testEngine.getZone("hand", PLAYER_ONE);
72
+ const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
73
+
74
+ expect(newHand.length).toBe(initialHandSize - 1);
75
+ expect(newHand).not.toContain(cardToInk);
76
+ expect(inkwell.length).toBe(1);
77
+ expect(inkwell).toContain(cardToInk);
78
+ });
79
+
80
+ it("should mark hasInked tracker after inking", () => {
81
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
82
+ const cardToInk = hand[0];
83
+
84
+ // Initially should not be marked (may be undefined or false)
85
+ const ctx = testEngine.getCtx();
86
+ const initialInked = ctx.trackers?.check(
87
+ "hasInked",
88
+ createPlayerId(PLAYER_ONE),
89
+ );
90
+ expect(initialInked).toBeFalsy(); // undefined or false
91
+
92
+ // Ink the card
93
+ testEngine.putCardInInkwell(cardToInk);
94
+
95
+ // Should now be marked
96
+ const newCtx = testEngine.getCtx();
97
+ expect(
98
+ newCtx.trackers?.check("hasInked", createPlayerId(PLAYER_ONE)),
99
+ ).toBe(true);
100
+ });
101
+
102
+ it("should allow inking different cards in different turns", () => {
103
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
104
+ const firstCard = hand[0];
105
+
106
+ // Ink first card
107
+ testEngine.putCardInInkwell(firstCard);
108
+
109
+ // Pass turn and come back to player one
110
+ testEngine.passTurn(); // Pass to player two
111
+ testEngine.passTurn(); // Pass back to player one
112
+
113
+ const newHand = testEngine.getZone("hand", PLAYER_ONE);
114
+ const secondCard = newHand[0];
115
+
116
+ // Should be able to ink again
117
+ testEngine.putCardInInkwell(secondCard);
118
+
119
+ const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
120
+ expect(inkwell.length).toBe(2);
121
+ expect(inkwell).toContain(firstCard);
122
+ expect(inkwell).toContain(secondCard);
123
+ });
124
+ });
125
+
126
+ // ========== Once-Per-Turn Validation ==========
127
+
128
+ describe("Once-Per-Turn Validation", () => {
129
+ it("should reject second ink attempt in same turn", () => {
130
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
131
+ const firstCard = hand[0];
132
+ const secondCard = hand[1];
133
+
134
+ // First ink succeeds
135
+ testEngine.putCardInInkwell(firstCard);
136
+
137
+ // Second ink should fail
138
+ const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
139
+ playerId: createPlayerId(PLAYER_ONE),
140
+ params: {
141
+ cardId: secondCard,
142
+ },
143
+ });
144
+
145
+ expect(result.success).toBe(false);
146
+ // The error should indicate the action was already used
147
+ if (!result.success) {
148
+ // Accept either specific or generic error code
149
+ expect(
150
+ result.errorCode === "ALREADY_USED_ACTION" ||
151
+ result.errorCode === "CONDITION_FAILED",
152
+ ).toBe(true);
153
+ }
154
+
155
+ // Verify second card still in hand
156
+ const newHand = testEngine.getZone("hand", PLAYER_ONE);
157
+ expect(newHand).toContain(secondCard);
158
+
159
+ // Verify only one card in inkwell
160
+ const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
161
+ expect(inkwell.length).toBe(1);
162
+ });
163
+
164
+ it("should allow inking after turn passes", () => {
165
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
166
+ const firstCard = hand[0];
167
+
168
+ // Ink first card
169
+ testEngine.putCardInInkwell(firstCard);
170
+
171
+ // Pass turn and come back to player one
172
+ testEngine.passTurn(); // Pass to player two
173
+ testEngine.passTurn(); // Pass back to player one
174
+ const newHand = testEngine.getZone("hand", PLAYER_ONE);
175
+ const secondCard = newHand[0];
176
+
177
+ // Should succeed
178
+ testEngine.putCardInInkwell(secondCard);
179
+
180
+ const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
181
+ expect(inkwell.length).toBe(2);
182
+ });
183
+ });
184
+
185
+ // ========== Card Location Validation ==========
186
+
187
+ describe("Card Location Validation", () => {
188
+ it("should reject cards not in hand - card in deck", () => {
189
+ const deck = testEngine.getZone("deck", PLAYER_ONE);
190
+ const cardInDeck = deck[0];
191
+
192
+ const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
193
+ playerId: createPlayerId(PLAYER_ONE),
194
+ params: {
195
+ cardId: cardInDeck,
196
+ },
197
+ });
198
+
199
+ expect(result.success).toBe(false);
200
+ // Card should still be in deck
201
+ expect(testEngine.getZone("deck", PLAYER_ONE)).toContain(cardInDeck);
202
+ });
203
+
204
+ it("should reject cards not in hand - card in play", () => {
205
+ // Set up with a card in play
206
+ const testEngineWithPlay = new LorcanaTestEngine(
207
+ { hand: 7, deck: 10, play: 1 },
208
+ { hand: 7, deck: 10 },
209
+ { skipPreGame: false },
210
+ );
211
+
212
+ // Complete setup
213
+ const ctx = testEngineWithPlay.getCtx();
214
+ testEngineWithPlay.changeActivePlayer(
215
+ ctx.choosingFirstPlayer || PLAYER_ONE,
216
+ );
217
+ testEngineWithPlay.chooseWhoGoesFirst(PLAYER_ONE);
218
+ testEngineWithPlay.changeActivePlayer(PLAYER_ONE);
219
+ testEngineWithPlay.alterHand([]);
220
+ testEngineWithPlay.changeActivePlayer(PLAYER_TWO);
221
+ testEngineWithPlay.alterHand([]);
222
+ testEngineWithPlay.changeActivePlayer(PLAYER_ONE);
223
+
224
+ const playZone = testEngineWithPlay.getZone("play", PLAYER_ONE);
225
+ const cardInPlay = playZone[0];
226
+
227
+ const result = testEngineWithPlay.engine.executeMove(
228
+ "putACardIntoTheInkwell",
229
+ {
230
+ playerId: createPlayerId(PLAYER_ONE),
231
+ params: {
232
+ cardId: cardInPlay,
233
+ },
234
+ },
235
+ );
236
+
237
+ expect(result.success).toBe(false);
238
+
239
+ testEngineWithPlay.dispose();
240
+ });
241
+
242
+ it("should reject invalid card IDs", () => {
243
+ const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
244
+ playerId: createPlayerId(PLAYER_ONE),
245
+ params: {
246
+ cardId: "invalid-card-id-12345",
247
+ },
248
+ });
249
+
250
+ expect(result.success).toBe(false);
251
+ });
252
+
253
+ it("should reject opponent's cards", () => {
254
+ const opponentHand = testEngine.getZone("hand", PLAYER_TWO);
255
+ const opponentCard = opponentHand[0];
256
+
257
+ const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
258
+ playerId: createPlayerId(PLAYER_ONE),
259
+ params: {
260
+ cardId: opponentCard,
261
+ },
262
+ });
263
+
264
+ expect(result.success).toBe(false);
265
+
266
+ // Card should still be in opponent's hand
267
+ expect(testEngine.getZone("hand", PLAYER_TWO)).toContain(opponentCard);
268
+ });
269
+
270
+ it("should reject cards already in inkwell", () => {
271
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
272
+ const cardToInk = hand[0];
273
+
274
+ // Ink the card first time
275
+ testEngine.putCardInInkwell(cardToInk);
276
+
277
+ // Pass turn to reset hasInked tracker
278
+ testEngine.passTurn();
279
+ testEngine.passTurn();
280
+
281
+ // Try to ink the same card again (now it's in inkwell)
282
+ const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
283
+ playerId: createPlayerId(PLAYER_ONE),
284
+ params: {
285
+ cardId: cardToInk,
286
+ },
287
+ });
288
+
289
+ expect(result.success).toBe(false);
290
+ });
291
+ });
292
+
293
+ // ========== Priority Tests ==========
294
+
295
+ describe("Priority and Turn Order", () => {
296
+ it("should allow both players to ink on their respective turns", () => {
297
+ // Player one inks
298
+ const p1Hand = testEngine.getZone("hand", PLAYER_ONE);
299
+ const p1Card = p1Hand[0];
300
+ testEngine.putCardInInkwell(p1Card);
301
+
302
+ // Pass to player two
303
+ testEngine.passTurn();
304
+
305
+ // Player two inks
306
+ testEngine.changeActivePlayer(PLAYER_TWO);
307
+ const p2Hand = testEngine.getZone("hand", PLAYER_TWO);
308
+ const p2Card = p2Hand[0];
309
+ testEngine.putCardInInkwell(p2Card);
310
+
311
+ // Verify both inkwells
312
+ expect(testEngine.getZone("inkwell", PLAYER_ONE)).toContain(p1Card);
313
+ expect(testEngine.getZone("inkwell", PLAYER_TWO)).toContain(p2Card);
314
+ });
315
+ });
316
+
317
+ // ========== Edge Cases ==========
318
+
319
+ describe("Edge Cases", () => {
320
+ it("should handle empty hand gracefully", () => {
321
+ const emptyHandEngine = new LorcanaTestEngine(
322
+ { hand: 0, deck: 10 },
323
+ { hand: 7, deck: 10 },
324
+ { skipPreGame: false },
325
+ );
326
+
327
+ // Complete setup
328
+ const ctx = emptyHandEngine.getCtx();
329
+ emptyHandEngine.changeActivePlayer(ctx.choosingFirstPlayer || PLAYER_ONE);
330
+ emptyHandEngine.chooseWhoGoesFirst(PLAYER_ONE);
331
+ emptyHandEngine.changeActivePlayer(PLAYER_ONE);
332
+ emptyHandEngine.alterHand([]);
333
+ emptyHandEngine.changeActivePlayer(PLAYER_TWO);
334
+ emptyHandEngine.alterHand([]);
335
+ emptyHandEngine.changeActivePlayer(PLAYER_ONE);
336
+
337
+ // Try to ink with no cards in hand
338
+ const result = emptyHandEngine.engine.executeMove(
339
+ "putACardIntoTheInkwell",
340
+ {
341
+ playerId: createPlayerId(PLAYER_ONE),
342
+ params: {
343
+ cardId: "any-card-id",
344
+ },
345
+ },
346
+ );
347
+
348
+ expect(result.success).toBe(false);
349
+
350
+ emptyHandEngine.dispose();
351
+ });
352
+
353
+ it("should accumulate cards in inkwell over multiple turns", () => {
354
+ const cardsInked: string[] = [];
355
+
356
+ // Ink a card each turn for 3 turns
357
+ for (let i = 0; i < 3; i++) {
358
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
359
+ if (hand.length > 0) {
360
+ const cardToInk = hand[0];
361
+ cardsInked.push(cardToInk);
362
+
363
+ testEngine.putCardInInkwell(cardToInk);
364
+ }
365
+
366
+ // Pass both players' turns
367
+ testEngine.passTurn();
368
+ testEngine.passTurn();
369
+ }
370
+
371
+ // Verify all cards are in inkwell
372
+ const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
373
+ expect(inkwell.length).toBe(3);
374
+
375
+ for (const card of cardsInked) {
376
+ expect(inkwell).toContain(card);
377
+ }
378
+ });
379
+
380
+ it("should verify inkwell is a public zone", () => {
381
+ // This is verified by the zone configuration
382
+ // Just ensure inking works and cards are visible
383
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
384
+ const cardToInk = hand[0];
385
+
386
+ testEngine.putCardInInkwell(cardToInk);
387
+
388
+ // Both players should be able to see inkwell (public zone)
389
+ const p1Inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
390
+ const p2View = testEngine.getZone("inkwell", PLAYER_ONE); // Same zone
391
+
392
+ expect(p1Inkwell).toEqual(p2View);
393
+ expect(p1Inkwell).toContain(cardToInk);
394
+ });
395
+ });
396
+
397
+ // ========== Integration Tests ==========
398
+
399
+ describe("Integration with Game Flow", () => {
400
+ it("should reset hasInked tracker at start of turn", () => {
401
+ const hand = testEngine.getZone("hand", PLAYER_ONE);
402
+ const firstCard = hand[0];
403
+
404
+ // Ink a card
405
+ testEngine.putCardInInkwell(firstCard);
406
+
407
+ // Verify tracker is marked
408
+ let ctx = testEngine.getCtx();
409
+ expect(ctx.trackers?.check("hasInked", createPlayerId(PLAYER_ONE))).toBe(
410
+ true,
411
+ );
412
+
413
+ // Pass turn and come back
414
+ testEngine.passTurn();
415
+ testEngine.passTurn();
416
+
417
+ // Tracker should be reset (undefined or false)
418
+ ctx = testEngine.getCtx();
419
+ const trackerValue = ctx.trackers?.check(
420
+ "hasInked",
421
+ createPlayerId(PLAYER_ONE),
422
+ );
423
+ expect(trackerValue).toBeFalsy(); // undefined or false
424
+
425
+ // Should be able to ink again
426
+ const newHand = testEngine.getZone("hand", PLAYER_ONE);
427
+ if (newHand.length > 0) {
428
+ const secondCard = newHand[0];
429
+ testEngine.putCardInInkwell(secondCard);
430
+
431
+ const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
432
+ expect(inkwell.length).toBe(2);
433
+ }
434
+ });
435
+
436
+ it("should work correctly in a realistic game scenario", () => {
437
+ // Simulate several turns of gameplay with inking
438
+ for (let turn = 0; turn < 3; turn++) {
439
+ // Player one's turn
440
+ const p1Hand = testEngine.getZone("hand", PLAYER_ONE);
441
+ if (p1Hand.length > 0) {
442
+ testEngine.putCardInInkwell(p1Hand[0]);
443
+ }
444
+ testEngine.passTurn();
445
+
446
+ // Player two's turn
447
+ testEngine.changeActivePlayer(PLAYER_TWO);
448
+ const p2Hand = testEngine.getZone("hand", PLAYER_TWO);
449
+ if (p2Hand.length > 0) {
450
+ testEngine.putCardInInkwell(p2Hand[0]);
451
+ }
452
+ testEngine.passTurn();
453
+
454
+ testEngine.changeActivePlayer(PLAYER_ONE);
455
+ }
456
+
457
+ // Verify both players have accumulated ink
458
+ expect(testEngine.getZone("inkwell", PLAYER_ONE).length).toBe(3);
459
+ expect(testEngine.getZone("inkwell", PLAYER_TWO).length).toBe(3);
460
+ });
461
+ });
462
+ });
@@ -0,0 +1,51 @@
1
+ import { createMove, type ZoneId } from "@drmxrcy/tcg-core";
2
+ import type {
3
+ LorcanaCardMeta,
4
+ LorcanaGameState,
5
+ LorcanaMoveParams,
6
+ } from "../../../types";
7
+ import {
8
+ and,
9
+ cardInHand,
10
+ cardOwnedByPlayer,
11
+ hasNotUsedAction,
12
+ isMainPhase,
13
+ } from "../../../validators";
14
+
15
+ /**
16
+ * Put a Card Into The Inkwell
17
+ *
18
+ * Rule 4.3.3: Once per turn, put an inkable card into inkwell
19
+ *
20
+ * Conditions:
21
+ * - Must be in Main phase
22
+ * - Card must be in hand
23
+ * - Card must be owned by current player
24
+ * - Player hasn't inked this turn
25
+ */
26
+ export const putACardIntoTheInkwell = createMove<
27
+ LorcanaGameState,
28
+ LorcanaMoveParams,
29
+ "putACardIntoTheInkwell",
30
+ LorcanaCardMeta
31
+ >({
32
+ condition: and(
33
+ isMainPhase(),
34
+ (state, context) => cardInHand(context.params.cardId)(state, context),
35
+ (state, context) =>
36
+ cardOwnedByPlayer(context.params.cardId)(state, context),
37
+ hasNotUsedAction("hasInked"),
38
+ ),
39
+ reducer: (_draft, context) => {
40
+ const { cardId } = context.params;
41
+
42
+ // Move card to inkwell
43
+ context.zones.moveCard({
44
+ cardId,
45
+ targetZoneId: "inkwell" as ZoneId,
46
+ });
47
+
48
+ // Mark action as used
49
+ context.trackers?.mark("hasInked", context.playerId);
50
+ },
51
+ });