@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,450 @@
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: Choose First Player", () => {
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
+ expect(testEngine.getGameSegment()).toBe("startingAGame");
20
+ expect(testEngine.getGamePhase()).toBe("chooseFirstPlayer");
21
+ });
22
+
23
+ afterEach(() => {
24
+ testEngine.dispose();
25
+ });
26
+
27
+ it("Choosing player_one as first player", () => {
28
+ const ctx = testEngine.getCtx();
29
+ const choosingPlayer = ctx.choosingFirstPlayer;
30
+
31
+ // Execute the move as the designated choosing player
32
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
33
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
34
+ const updatedCtx = testEngine.getCtx();
35
+
36
+ // Verify OTP is set
37
+ expect(updatedCtx.otp).toBe(PLAYER_ONE);
38
+
39
+ // Verify pending mulligan is set for both players
40
+ expect(updatedCtx.pendingMulligan?.length).toBe(2);
41
+ expect(updatedCtx.pendingMulligan?.map((p) => String(p))).toContain(
42
+ PLAYER_ONE,
43
+ );
44
+ expect(updatedCtx.pendingMulligan?.map((p) => String(p))).toContain(
45
+ PLAYER_TWO,
46
+ );
47
+
48
+ // Verify phase transition occurred
49
+ expect(testEngine.getGamePhase()).toBe("mulligan");
50
+ });
51
+
52
+ it("Choosing player_two as first player", () => {
53
+ const ctx = testEngine.getCtx();
54
+ const choosingPlayer = ctx.choosingFirstPlayer;
55
+
56
+ // Execute the move as the designated choosing player
57
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
58
+ testEngine.chooseWhoGoesFirst(PLAYER_TWO);
59
+ const updatedCtx = testEngine.getCtx();
60
+
61
+ // Verify OTP is set
62
+ expect(updatedCtx.otp).toBe(PLAYER_TWO);
63
+
64
+ // Verify pending mulligan is set for both players
65
+ expect(updatedCtx.pendingMulligan?.length).toBe(2);
66
+ expect(updatedCtx.pendingMulligan?.map((p) => String(p))).toContain(
67
+ PLAYER_ONE,
68
+ );
69
+ expect(updatedCtx.pendingMulligan?.map((p) => String(p))).toContain(
70
+ PLAYER_TWO,
71
+ );
72
+
73
+ // Verify phase transition occurred
74
+ expect(testEngine.getGamePhase()).toBe("mulligan");
75
+ });
76
+
77
+ it("Only the designated player can choose first player", () => {
78
+ // Rule 3.1.2: First, use a method for randomly determining WHO CHOOSES who is the starting player
79
+ // One player is randomly designated to make the choice (like winning a coin flip)
80
+
81
+ const ctx = testEngine.getCtx();
82
+ const choosingPlayer = ctx.choosingFirstPlayer;
83
+
84
+ // Verify that a choosing player was randomly designated
85
+ expect(choosingPlayer).toBeDefined();
86
+ expect([PLAYER_ONE, PLAYER_TWO]).toContain(choosingPlayer!);
87
+
88
+ // The designated player should be able to choose
89
+ if (choosingPlayer === PLAYER_ONE) {
90
+ testEngine.changeActivePlayer(PLAYER_ONE);
91
+ testEngine.chooseWhoGoesFirst(PLAYER_TWO); // Can choose either player
92
+ expect(testEngine.getCtx().otp).toBe(PLAYER_TWO);
93
+ } else {
94
+ testEngine.changeActivePlayer(PLAYER_TWO);
95
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE); // Can choose either player
96
+ expect(testEngine.getCtx().otp).toBe(PLAYER_ONE);
97
+ }
98
+
99
+ expect(testEngine.getGamePhase()).toBe("mulligan");
100
+ });
101
+
102
+ it("Non-designated player cannot choose first player", () => {
103
+ // Check who is the designated chooser
104
+ const ctx = testEngine.getCtx();
105
+ const choosingPlayer = ctx.choosingFirstPlayer;
106
+ const otherPlayer = choosingPlayer === PLAYER_ONE ? PLAYER_TWO : PLAYER_ONE;
107
+
108
+ // Try to have the OTHER player choose - should fail
109
+ testEngine.changeActivePlayer(otherPlayer);
110
+ const result = testEngine.engine.executeMove("chooseWhoGoesFirstMove", {
111
+ playerId: createPlayerId(otherPlayer),
112
+ params: { playerId: createPlayerId(PLAYER_ONE) },
113
+ });
114
+
115
+ // Should fail with detailed error information
116
+ expect(result.success).toBe(false);
117
+ if (!result.success) {
118
+ expect(result.errorCode).toBe("NOT_CHOOSING_PLAYER");
119
+ expect(result.error).toContain("can choose the first player");
120
+ expect(result.error).toContain(String(choosingPlayer));
121
+ expect(result.error).toContain(String(otherPlayer));
122
+ expect(result.errorContext?.choosingPlayer).toBe(choosingPlayer);
123
+ expect(result.errorContext?.executingPlayer).toBe(otherPlayer);
124
+ }
125
+
126
+ // Verify OTP was not set
127
+ expect(testEngine.getCtx().otp).toBeUndefined();
128
+ expect(testEngine.getGamePhase()).toBe("chooseFirstPlayer");
129
+ });
130
+
131
+ // ========== Turn Player and Priority Tests ==========
132
+
133
+ it("should have no turn player but priority during chooseFirstPlayer phase", () => {
134
+ // Before choosing, turn player should be undefined
135
+ expect(testEngine.getTurnPlayer()).toBeUndefined();
136
+
137
+ // But priority should be the choosingFirstPlayer
138
+ const ctx = testEngine.getCtx();
139
+ expect(testEngine.getPriorityPlayers()).toContain(ctx.choosingFirstPlayer!);
140
+ });
141
+
142
+ it("should set turn player after OTP is chosen", () => {
143
+ const ctx = testEngine.getCtx();
144
+ const choosingPlayer = ctx.choosingFirstPlayer;
145
+
146
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
147
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
148
+
149
+ // After choosing, turn player should be set to OTP
150
+ expect(testEngine.getTurnPlayer()).toBe(PLAYER_ONE);
151
+
152
+ // And priority should also be OTP (for mulligan)
153
+ expect(testEngine.getPriorityPlayers()).toContain(PLAYER_ONE);
154
+ });
155
+
156
+ it("should switch priority after each player mulligans", () => {
157
+ const ctx = testEngine.getCtx();
158
+ const choosingPlayer = ctx.choosingFirstPlayer;
159
+
160
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
161
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
162
+
163
+ // OTP has priority first
164
+ expect(testEngine.getPriorityPlayers()).toContain(PLAYER_ONE);
165
+
166
+ testEngine.changeActivePlayer(PLAYER_ONE);
167
+ testEngine.alterHand([]);
168
+
169
+ // After OTP mulligans, priority switches to other player
170
+ expect(testEngine.getPriorityPlayers()).toContain(PLAYER_TWO);
171
+ expect(testEngine.getPriorityPlayers()).not.toContain(PLAYER_ONE);
172
+ });
173
+
174
+ // ========== Invalid Move Tests ==========
175
+
176
+ it("should reject invalid player ID", () => {
177
+ // Get the designated chooser so we can execute as them
178
+ const ctx = testEngine.getCtx();
179
+ const choosingPlayer = ctx.choosingFirstPlayer;
180
+
181
+ // Attempt to choose an invalid player ID as the designated chooser
182
+ const result = testEngine.engine.executeMove("chooseWhoGoesFirstMove", {
183
+ playerId: createPlayerId(choosingPlayer || PLAYER_ONE),
184
+ params: { playerId: createPlayerId("invalid_player_id") },
185
+ });
186
+
187
+ // Should fail with detailed error information
188
+ expect(result.success).toBe(false);
189
+ if (!result.success) {
190
+ expect(result.errorCode).toBe("INVALID_PLAYER_ID");
191
+ expect(result.error).toContain("Invalid player ID");
192
+ expect(result.error).toContain("invalid_player_id");
193
+ expect(result.errorContext?.playerId).toBe("invalid_player_id");
194
+ expect(result.errorContext?.validPlayers).toEqual([
195
+ PLAYER_ONE,
196
+ PLAYER_TWO,
197
+ ]);
198
+ }
199
+
200
+ // Verify OTP was not set
201
+ const updatedCtx = testEngine.getCtx();
202
+ expect(updatedCtx.otp).toBeUndefined();
203
+ expect(testEngine.getGamePhase()).toBe("chooseFirstPlayer");
204
+ });
205
+
206
+ it("should reject choosing first player twice", () => {
207
+ // Get the designated chooser
208
+ const ctx = testEngine.getCtx();
209
+ const choosingPlayer = ctx.choosingFirstPlayer;
210
+
211
+ // Choose first player successfully as the designated chooser
212
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
213
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
214
+ const ctxAfterFirst = testEngine.getCtx();
215
+ expect(ctxAfterFirst.otp).toBe(PLAYER_ONE);
216
+ expect(testEngine.getGamePhase()).toBe("mulligan");
217
+
218
+ // Try to choose again - should fail
219
+ // Note: We need to be in chooseFirstPlayer phase, but we already transitioned to mulligan
220
+ // So this test verifies that once OTP is set, it can't be changed
221
+ // We'll need to manually set phase back for this specific test scenario
222
+
223
+ // For now, let's test by creating a new engine and trying to choose twice before phase transition
224
+ const testEngine2 = new LorcanaTestEngine(
225
+ { hand: 7, deck: 10 },
226
+ { hand: 7, deck: 10 },
227
+ { skipPreGame: false },
228
+ );
229
+
230
+ // Get the designated chooser for the new engine
231
+ const ctx2 = testEngine2.getCtx();
232
+ const choosingPlayer2 = ctx2.choosingFirstPlayer;
233
+
234
+ // Execute the move as the designated chooser
235
+ const result = testEngine2.engine.executeMove("chooseWhoGoesFirstMove", {
236
+ playerId: createPlayerId(choosingPlayer2 || PLAYER_ONE),
237
+ params: { playerId: createPlayerId(PLAYER_ONE) },
238
+ });
239
+ expect(result.success).toBe(true);
240
+
241
+ // Now try to choose again - should fail because phase has changed to mulligan
242
+ // Note: The move transitions to mulligan phase after successful execution,
243
+ // so the wrong phase error takes precedence over OTP already being set
244
+ const result2 = testEngine2.engine.executeMove("chooseWhoGoesFirstMove", {
245
+ playerId: createPlayerId(choosingPlayer2 || PLAYER_ONE),
246
+ params: { playerId: createPlayerId(PLAYER_TWO) },
247
+ });
248
+
249
+ expect(result2.success).toBe(false);
250
+ if (!result2.success) {
251
+ expect(result2.errorCode).toBe("WRONG_PHASE");
252
+ expect(result2.error).toContain(
253
+ "Cannot choose first player during mulligan phase",
254
+ );
255
+ expect(result2.errorContext?.currentPhase).toBe("mulligan");
256
+ }
257
+
258
+ testEngine2.dispose();
259
+ });
260
+
261
+ it("should reject choosing first player during wrong phase", () => {
262
+ // Get the designated chooser
263
+ const ctx = testEngine.getCtx();
264
+ const choosingPlayer = ctx.choosingFirstPlayer;
265
+
266
+ // Choose first player successfully - this transitions to mulligan phase
267
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
268
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
269
+ expect(testEngine.getGamePhase()).toBe("mulligan");
270
+
271
+ // Try to choose first player again while in mulligan phase
272
+ const result = testEngine.engine.executeMove("chooseWhoGoesFirstMove", {
273
+ playerId: createPlayerId(choosingPlayer || PLAYER_ONE),
274
+ params: { playerId: createPlayerId(PLAYER_TWO) },
275
+ });
276
+
277
+ // Should fail with detailed error about wrong phase
278
+ expect(result.success).toBe(false);
279
+ if (!result.success) {
280
+ expect(result.errorCode).toBe("WRONG_PHASE");
281
+ expect(result.error).toContain(
282
+ "Cannot choose first player during mulligan phase",
283
+ );
284
+ expect(result.error).toContain("Must be in chooseFirstPlayer phase");
285
+ expect(result.errorContext?.currentPhase).toBe("mulligan");
286
+ expect(result.errorContext?.requiredPhase).toBe("chooseFirstPlayer");
287
+ }
288
+
289
+ // Verify OTP hasn't changed
290
+ const updatedCtx = testEngine.getCtx();
291
+ expect(updatedCtx.otp).toBe(PLAYER_ONE); // Still player_one, not player_two
292
+ });
293
+
294
+ // ========== Move Enumeration Tests ==========
295
+
296
+ describe("Move Enumeration", () => {
297
+ it("should list chooseWhoGoesFirstMove as available for designated player", () => {
298
+ const ctx = testEngine.getCtx();
299
+ const choosingPlayer = ctx.choosingFirstPlayer;
300
+
301
+ // Get available moves for the designated chooser
302
+ const availableMoves = testEngine.getAvailableMoves(
303
+ choosingPlayer || PLAYER_ONE,
304
+ );
305
+
306
+ // Should include chooseWhoGoesFirstMove
307
+ expect(availableMoves).toContain("chooseWhoGoesFirstMove");
308
+ });
309
+
310
+ it("should NOT list chooseWhoGoesFirstMove for non-designated player", () => {
311
+ const ctx = testEngine.getCtx();
312
+ const choosingPlayer = ctx.choosingFirstPlayer;
313
+ const otherPlayer =
314
+ choosingPlayer === PLAYER_ONE ? PLAYER_TWO : PLAYER_ONE;
315
+
316
+ // Get available moves for the OTHER player
317
+ const availableMoves = testEngine.getAvailableMoves(otherPlayer);
318
+
319
+ // Should NOT include chooseWhoGoesFirstMove
320
+ expect(availableMoves).not.toContain("chooseWhoGoesFirstMove");
321
+
322
+ // Should be empty (no moves available during this phase)
323
+ expect(availableMoves).toEqual([]);
324
+ });
325
+
326
+ it("should enumerate valid parameters for chooseWhoGoesFirstMove", () => {
327
+ const ctx = testEngine.getCtx();
328
+ const choosingPlayer = ctx.choosingFirstPlayer;
329
+
330
+ // Enumerate parameters
331
+ const params = testEngine.enumerateMoveParameters(
332
+ "chooseWhoGoesFirstMove",
333
+ choosingPlayer || PLAYER_ONE,
334
+ );
335
+
336
+ // Should have valid combinations for both players
337
+ expect(params).not.toBeNull();
338
+ expect(params?.validCombinations).toHaveLength(2);
339
+
340
+ // Should include both player IDs
341
+ const playerIds = params?.validCombinations.map((c: any) => c.playerId);
342
+ expect(playerIds).toContain(PLAYER_ONE);
343
+ expect(playerIds).toContain(PLAYER_TWO);
344
+
345
+ // Should have parameter info
346
+ expect(params?.parameterInfo.playerId).toBeDefined();
347
+ expect(params?.parameterInfo.playerId.type).toBe("playerId");
348
+ expect(params?.parameterInfo.playerId.validValues).toEqual([
349
+ PLAYER_ONE,
350
+ PLAYER_TWO,
351
+ ]);
352
+ });
353
+
354
+ it("should NOT enumerate parameters for non-designated player", () => {
355
+ const ctx = testEngine.getCtx();
356
+ const choosingPlayer = ctx.choosingFirstPlayer;
357
+ const otherPlayer =
358
+ choosingPlayer === PLAYER_ONE ? PLAYER_TWO : PLAYER_ONE;
359
+
360
+ // Try to enumerate parameters as other player
361
+ const params = testEngine.enumerateMoveParameters(
362
+ "chooseWhoGoesFirstMove",
363
+ otherPlayer,
364
+ );
365
+
366
+ // Should return null (move not available)
367
+ expect(params).toBeNull();
368
+ });
369
+
370
+ it("should provide detailed move information", () => {
371
+ const ctx = testEngine.getCtx();
372
+ const choosingPlayer = ctx.choosingFirstPlayer;
373
+
374
+ // Get detailed move info
375
+ const moves = testEngine.getAvailableMovesDetailed(
376
+ choosingPlayer || PLAYER_ONE,
377
+ );
378
+
379
+ // Should have one move
380
+ expect(moves).toHaveLength(1);
381
+
382
+ const moveInfo = moves[0];
383
+ expect(moveInfo.moveId).toBe("chooseWhoGoesFirstMove");
384
+ expect(moveInfo.displayName).toBe("Choose First Player");
385
+ expect(moveInfo.description).toContain("first");
386
+ expect(moveInfo.paramSchema).toBeDefined();
387
+ expect(moveInfo.paramSchema?.required).toHaveLength(1);
388
+ });
389
+
390
+ it("should explain why non-designated player cannot choose", () => {
391
+ const ctx = testEngine.getCtx();
392
+ const choosingPlayer = ctx.choosingFirstPlayer;
393
+ const otherPlayer =
394
+ choosingPlayer === PLAYER_ONE ? PLAYER_TWO : PLAYER_ONE;
395
+
396
+ // Try to execute as other player
397
+ const error = testEngine.whyCannotExecuteMove("chooseWhoGoesFirstMove", {
398
+ playerId: createPlayerId(otherPlayer),
399
+ params: { playerId: createPlayerId(PLAYER_ONE) },
400
+ });
401
+
402
+ // Should have error
403
+ expect(error).not.toBeNull();
404
+ expect(error?.errorCode).toBe("NOT_CHOOSING_PLAYER");
405
+ expect(error?.reason).toContain("can choose the first player");
406
+ expect(error?.context?.choosingPlayer).toBe(choosingPlayer);
407
+ });
408
+
409
+ it("should remove chooseWhoGoesFirstMove after OTP is set", () => {
410
+ const ctx = testEngine.getCtx();
411
+ const choosingPlayer = ctx.choosingFirstPlayer;
412
+
413
+ // Choose first player
414
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
415
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
416
+
417
+ // Get available moves for OTP (who has priority in mulligan phase)
418
+ const otpMoves = testEngine.getAvailableMoves(PLAYER_ONE);
419
+
420
+ expect(otpMoves).not.toContain("chooseWhoGoesFirstMove");
421
+
422
+ // OTP should have alterHand available (mulligan phase, has priority)
423
+ expect(otpMoves).toContain("alterHand");
424
+ });
425
+
426
+ it("should transition to mulligan moves after choosing first player", () => {
427
+ const ctx = testEngine.getCtx();
428
+ const choosingPlayer = ctx.choosingFirstPlayer;
429
+
430
+ // Before choosing, should have chooseWhoGoesFirstMove
431
+ let availableMoves = testEngine.getAvailableMoves(
432
+ choosingPlayer || PLAYER_ONE,
433
+ );
434
+ expect(availableMoves).toContain("chooseWhoGoesFirstMove");
435
+
436
+ // Choose first player
437
+ testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
438
+ testEngine.chooseWhoGoesFirst(PLAYER_ONE);
439
+
440
+ // After choosing, OTP should have alterHand available (has priority)
441
+ availableMoves = testEngine.getAvailableMoves(PLAYER_ONE);
442
+ expect(availableMoves).toContain("alterHand");
443
+
444
+ // Other player should NOT have alterHand yet (doesn't have priority)
445
+ // Priority passes to them after OTP completes their mulligan
446
+ availableMoves = testEngine.getAvailableMoves(PLAYER_TWO);
447
+ expect(availableMoves).not.toContain("alterHand");
448
+ });
449
+ });
450
+ });
@@ -0,0 +1,105 @@
1
+ import { type ConditionFailure, createMove, type PlayerId } from "@drmxrcy/tcg-core";
2
+ import type {
3
+ LorcanaCardMeta,
4
+ LorcanaGameState,
5
+ LorcanaMoveParams,
6
+ } from "../../../types";
7
+
8
+ /**
9
+ * Choose Who Goes First Move
10
+ *
11
+ * Rule 3.1.1: First player determined randomly
12
+ *
13
+ * This move:
14
+ * - Marks the chosen player as OTP (On The Play)
15
+ * - Initializes pending mulligan list with all players
16
+ *
17
+ * The engine handles:
18
+ * - Setting activePlayer
19
+ * - Initializing turn counter
20
+ * - Transitioning to first phase
21
+ *
22
+ * Validation:
23
+ * - Player ID must be valid (exists in game)
24
+ * - OTP must not be already set (can't choose twice)
25
+ * - Must be in chooseFirstPlayer phase
26
+ */
27
+ export const chooseWhoGoesFirstMove = createMove<
28
+ LorcanaGameState,
29
+ LorcanaMoveParams,
30
+ "chooseWhoGoesFirstMove",
31
+ LorcanaCardMeta
32
+ >({
33
+ condition: (state, context): true | ConditionFailure => {
34
+ const { playerId } = context.params;
35
+
36
+ // 1. Check we're in the correct phase (most fundamental constraint)
37
+ if (context.flow?.currentPhase !== "chooseFirstPlayer") {
38
+ return {
39
+ reason: `Cannot choose first player during ${context.flow?.currentPhase || "unknown"} phase. Must be in chooseFirstPlayer phase.`,
40
+ errorCode: "WRONG_PHASE",
41
+ context: {
42
+ currentPhase: context.flow?.currentPhase,
43
+ requiredPhase: "chooseFirstPlayer",
44
+ },
45
+ };
46
+ }
47
+
48
+ // 2. Check that the executing player is the one designated to choose
49
+ // Rule 3.1.2: One player is randomly determined to choose who is the starting player
50
+ const choosingPlayer = context.game.getChoosingFirstPlayer();
51
+ if (choosingPlayer && context.playerId !== choosingPlayer) {
52
+ return {
53
+ reason: `Only ${String(choosingPlayer)} can choose the first player. You are ${String(context.playerId)}.`,
54
+ errorCode: "NOT_CHOOSING_PLAYER",
55
+ context: {
56
+ choosingPlayer: String(choosingPlayer),
57
+ executingPlayer: String(context.playerId),
58
+ },
59
+ };
60
+ }
61
+
62
+ // 3. Check OTP hasn't been set yet (prevent choosing twice)
63
+ const currentOTP = context.game.getOTP();
64
+ if (currentOTP) {
65
+ return {
66
+ reason: "First player has already been chosen",
67
+ errorCode: "FIRST_PLAYER_ALREADY_CHOSEN",
68
+ context: {
69
+ currentOTP: String(currentOTP),
70
+ },
71
+ };
72
+ }
73
+
74
+ // 4. Validate player exists in the game
75
+ const validPlayers = Object.keys(state.external.loreScores) as PlayerId[];
76
+ if (!validPlayers.includes(playerId)) {
77
+ return {
78
+ reason: `Invalid player ID: ${playerId}. Valid players: ${validPlayers.join(", ")}`,
79
+ errorCode: "INVALID_PLAYER_ID",
80
+ context: {
81
+ playerId: String(playerId),
82
+ validPlayers: validPlayers.map((p) => String(p)),
83
+ },
84
+ };
85
+ }
86
+
87
+ return true;
88
+ },
89
+
90
+ reducer: (draft, context) => {
91
+ const { playerId } = context.params;
92
+
93
+ context.game.setOTP(playerId);
94
+
95
+ // All players can mulligan after first player is chosen
96
+ // Get all player IDs from the game state
97
+ context.game.setPendingMulligan(
98
+ Object.keys(draft.external.loreScores) as PlayerId[],
99
+ );
100
+
101
+ if (context.flow) {
102
+ context.flow.endPhase("chooseFirstPlayer");
103
+ }
104
+ },
105
+ });
@@ -0,0 +1,37 @@
1
+ import { createMove, type ZoneId } from "@drmxrcy/tcg-core";
2
+ import type {
3
+ LorcanaCardMeta,
4
+ LorcanaGameState,
5
+ LorcanaMoveParams,
6
+ } from "../../../types";
7
+
8
+ /**
9
+ * Draw Cards Move
10
+ *
11
+ * Utility move for drawing cards from deck to hand.
12
+ * Used by:
13
+ * - Beginning phase (draw for turn)
14
+ * - Card effects that draw cards
15
+ * - Testing/debugging
16
+ */
17
+ export const drawCards = createMove<
18
+ LorcanaGameState,
19
+ LorcanaMoveParams,
20
+ "drawCards",
21
+ LorcanaCardMeta
22
+ >({
23
+ condition: (_state, context) => {
24
+ // Not available during chooseFirstPlayer phase
25
+ return context.flow?.currentPhase !== "chooseFirstPlayer";
26
+ },
27
+ reducer: (_draft, context) => {
28
+ const { playerId, count } = context.params;
29
+
30
+ context.zones.drawCards({
31
+ from: "deck" as ZoneId,
32
+ to: "hand" as ZoneId,
33
+ count,
34
+ playerId,
35
+ });
36
+ },
37
+ });
@@ -0,0 +1,47 @@
1
+ import { createMove, type ZoneId } from "@drmxrcy/tcg-core";
2
+ import { useLorcanaOps } from "../../../operations";
3
+ import type {
4
+ LorcanaCardMeta,
5
+ LorcanaGameState,
6
+ LorcanaMoveParams,
7
+ } from "../../../types";
8
+ import { and, cardInHand, isMainPhase } from "../../../validators";
9
+
10
+ /**
11
+ * Sing Together Move (Legacy)
12
+ *
13
+ * Rule 10.10: Multiple characters exert to sing together
14
+ *
15
+ * Note: Prefer using playCard with "singTogether" cost parameter instead.
16
+ * This move is kept for backward compatibility.
17
+ *
18
+ * Requirements:
19
+ * - Song must be in hand
20
+ * - All singers must be ready (not exerted)
21
+ * - Combined ink values of singers must meet or exceed song cost
22
+ */
23
+ export const singTogether = createMove<
24
+ LorcanaGameState,
25
+ LorcanaMoveParams,
26
+ "singTogether",
27
+ LorcanaCardMeta
28
+ >({
29
+ condition: and(isMainPhase(), (state, context) =>
30
+ cardInHand(context.params.songId)(state, context),
31
+ ),
32
+ reducer: (draft, context) => {
33
+ const { singersIds, songId } = context.params;
34
+ const ops = useLorcanaOps(context);
35
+
36
+ // Exert all singers
37
+ for (const singerId of singersIds) {
38
+ ops.exertCard(singerId);
39
+ }
40
+
41
+ // Play song to discard
42
+ context.zones.moveCard({
43
+ cardId: songId,
44
+ targetZoneId: "discard" as ZoneId,
45
+ });
46
+ },
47
+ });
@@ -0,0 +1,56 @@
1
+ import { createMove, type ZoneId } from "@drmxrcy/tcg-core";
2
+ import { useLorcanaOps } from "../../../operations";
3
+ import type {
4
+ LorcanaCardMeta,
5
+ LorcanaGameState,
6
+ LorcanaMoveParams,
7
+ } from "../../../types";
8
+ import {
9
+ and,
10
+ cardInHand,
11
+ cardInPlay,
12
+ cardOwnedByPlayer,
13
+ isMainPhase,
14
+ } from "../../../validators";
15
+
16
+ /**
17
+ * Sing Move (Legacy)
18
+ *
19
+ * Rule 6.3.3: Exert character to play song for free
20
+ *
21
+ * Note: Prefer using playCard with "sing" cost parameter instead.
22
+ * This move is kept for backward compatibility.
23
+ *
24
+ * Requirements:
25
+ * - Song must be in hand
26
+ * - Singer must be in play
27
+ * - Singer must be owned by current player
28
+ * - Singer must be able to sing (ready, not drying, meets cost requirement)
29
+ */
30
+ export const sing = createMove<
31
+ LorcanaGameState,
32
+ LorcanaMoveParams,
33
+ "sing",
34
+ LorcanaCardMeta
35
+ >({
36
+ condition: and(
37
+ isMainPhase(),
38
+ (state, context) => cardInHand(context.params.songId)(state, context),
39
+ (state, context) => cardInPlay(context.params.singerId)(state, context),
40
+ (state, context) =>
41
+ cardOwnedByPlayer(context.params.singerId)(state, context),
42
+ ),
43
+ reducer: (draft, context) => {
44
+ const { singerId, songId } = context.params;
45
+ const ops = useLorcanaOps(context);
46
+
47
+ // Exert singer
48
+ ops.exertCard(singerId);
49
+
50
+ // Play song to discard (actions go to discard)
51
+ context.zones.moveCard({
52
+ cardId: songId,
53
+ targetZoneId: "discard" as ZoneId,
54
+ });
55
+ },
56
+ });