@drmxrcy/tcg-core 0.0.0-202602060542

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 (157) hide show
  1. package/README.md +882 -0
  2. package/package.json +58 -0
  3. package/src/__tests__/alpha-clash-engine-definition.test.ts +319 -0
  4. package/src/__tests__/createMockAlphaClashGame.ts +462 -0
  5. package/src/__tests__/createMockGrandArchiveGame.ts +373 -0
  6. package/src/__tests__/createMockGundamGame.ts +379 -0
  7. package/src/__tests__/createMockLorcanaGame.ts +328 -0
  8. package/src/__tests__/createMockOnePieceGame.ts +429 -0
  9. package/src/__tests__/createMockRiftboundGame.ts +462 -0
  10. package/src/__tests__/grand-archive-engine-definition.test.ts +118 -0
  11. package/src/__tests__/gundam-engine-definition.test.ts +110 -0
  12. package/src/__tests__/integration-complete-game.test.ts +508 -0
  13. package/src/__tests__/integration-network-sync.test.ts +469 -0
  14. package/src/__tests__/lorcana-engine-definition.test.ts +100 -0
  15. package/src/__tests__/move-enumeration.test.ts +725 -0
  16. package/src/__tests__/multiplayer-engine.test.ts +555 -0
  17. package/src/__tests__/one-piece-engine-definition.test.ts +114 -0
  18. package/src/__tests__/riftbound-engine-definition.test.ts +124 -0
  19. package/src/actions/action-definition.test.ts +201 -0
  20. package/src/actions/action-definition.ts +122 -0
  21. package/src/actions/action-timing.test.ts +490 -0
  22. package/src/actions/action-timing.ts +257 -0
  23. package/src/cards/card-definition.test.ts +268 -0
  24. package/src/cards/card-definition.ts +27 -0
  25. package/src/cards/card-instance.test.ts +422 -0
  26. package/src/cards/card-instance.ts +49 -0
  27. package/src/cards/computed-properties.test.ts +530 -0
  28. package/src/cards/computed-properties.ts +84 -0
  29. package/src/cards/conditional-modifiers.test.ts +390 -0
  30. package/src/cards/modifiers.test.ts +286 -0
  31. package/src/cards/modifiers.ts +51 -0
  32. package/src/engine/MULTIPLAYER.md +425 -0
  33. package/src/engine/__tests__/rule-engine-flow.test.ts +348 -0
  34. package/src/engine/__tests__/rule-engine-history.test.ts +535 -0
  35. package/src/engine/__tests__/rule-engine-moves.test.ts +488 -0
  36. package/src/engine/__tests__/rule-engine.test.ts +366 -0
  37. package/src/engine/index.ts +14 -0
  38. package/src/engine/multiplayer-engine.example.ts +571 -0
  39. package/src/engine/multiplayer-engine.ts +409 -0
  40. package/src/engine/rule-engine.test.ts +286 -0
  41. package/src/engine/rule-engine.ts +1539 -0
  42. package/src/engine/tracker-system.ts +172 -0
  43. package/src/examples/__tests__/coin-flip-game.test.ts +641 -0
  44. package/src/filtering/card-filter.test.ts +230 -0
  45. package/src/filtering/card-filter.ts +91 -0
  46. package/src/filtering/card-query.test.ts +901 -0
  47. package/src/filtering/card-query.ts +273 -0
  48. package/src/filtering/filter-matching.test.ts +944 -0
  49. package/src/filtering/filter-matching.ts +315 -0
  50. package/src/flow/SERIALIZATION.md +428 -0
  51. package/src/flow/__tests__/flow-definition.test.ts +427 -0
  52. package/src/flow/__tests__/flow-manager.test.ts +756 -0
  53. package/src/flow/__tests__/flow-serialization.test.ts +565 -0
  54. package/src/flow/flow-definition.ts +453 -0
  55. package/src/flow/flow-manager.ts +1044 -0
  56. package/src/flow/index.ts +35 -0
  57. package/src/game-definition/__tests__/game-definition-validation.test.ts +359 -0
  58. package/src/game-definition/__tests__/game-definition.test.ts +291 -0
  59. package/src/game-definition/__tests__/move-definitions.test.ts +328 -0
  60. package/src/game-definition/game-definition.ts +261 -0
  61. package/src/game-definition/index.ts +28 -0
  62. package/src/game-definition/move-definitions.ts +188 -0
  63. package/src/game-definition/validation.ts +183 -0
  64. package/src/history/history-manager.test.ts +497 -0
  65. package/src/history/history-manager.ts +312 -0
  66. package/src/history/history-operations.ts +122 -0
  67. package/src/history/index.ts +9 -0
  68. package/src/history/types.ts +255 -0
  69. package/src/index.ts +32 -0
  70. package/src/logging/index.ts +27 -0
  71. package/src/logging/log-formatter.ts +187 -0
  72. package/src/logging/logger.ts +276 -0
  73. package/src/logging/types.ts +148 -0
  74. package/src/moves/create-move.test.ts +331 -0
  75. package/src/moves/create-move.ts +64 -0
  76. package/src/moves/move-enumeration.ts +228 -0
  77. package/src/moves/move-executor.test.ts +431 -0
  78. package/src/moves/move-executor.ts +195 -0
  79. package/src/moves/move-system.test.ts +380 -0
  80. package/src/moves/move-system.ts +463 -0
  81. package/src/moves/standard-moves.ts +231 -0
  82. package/src/operations/card-operations.test.ts +236 -0
  83. package/src/operations/card-operations.ts +116 -0
  84. package/src/operations/card-registry-impl.test.ts +251 -0
  85. package/src/operations/card-registry-impl.ts +70 -0
  86. package/src/operations/card-registry.test.ts +234 -0
  87. package/src/operations/card-registry.ts +106 -0
  88. package/src/operations/counter-operations.ts +152 -0
  89. package/src/operations/game-operations.test.ts +280 -0
  90. package/src/operations/game-operations.ts +140 -0
  91. package/src/operations/index.ts +24 -0
  92. package/src/operations/operations-impl.test.ts +354 -0
  93. package/src/operations/operations-impl.ts +468 -0
  94. package/src/operations/zone-operations.test.ts +295 -0
  95. package/src/operations/zone-operations.ts +223 -0
  96. package/src/rng/seeded-rng.test.ts +339 -0
  97. package/src/rng/seeded-rng.ts +123 -0
  98. package/src/targeting/index.ts +48 -0
  99. package/src/targeting/target-definition.test.ts +273 -0
  100. package/src/targeting/target-definition.ts +37 -0
  101. package/src/targeting/target-dsl.ts +279 -0
  102. package/src/targeting/target-resolver.ts +486 -0
  103. package/src/targeting/target-validation.test.ts +994 -0
  104. package/src/targeting/target-validation.ts +286 -0
  105. package/src/telemetry/events.ts +202 -0
  106. package/src/telemetry/index.ts +21 -0
  107. package/src/telemetry/telemetry-manager.ts +127 -0
  108. package/src/telemetry/types.ts +68 -0
  109. package/src/testing/__tests__/testing-utilities-integration.test.ts +161 -0
  110. package/src/testing/index.ts +88 -0
  111. package/src/testing/test-assertions.test.ts +341 -0
  112. package/src/testing/test-assertions.ts +256 -0
  113. package/src/testing/test-card-factory.test.ts +228 -0
  114. package/src/testing/test-card-factory.ts +111 -0
  115. package/src/testing/test-context-factory.ts +187 -0
  116. package/src/testing/test-end-assertions.test.ts +262 -0
  117. package/src/testing/test-end-assertions.ts +95 -0
  118. package/src/testing/test-engine-builder.test.ts +389 -0
  119. package/src/testing/test-engine-builder.ts +46 -0
  120. package/src/testing/test-flow-assertions.test.ts +284 -0
  121. package/src/testing/test-flow-assertions.ts +115 -0
  122. package/src/testing/test-player-builder.test.ts +132 -0
  123. package/src/testing/test-player-builder.ts +46 -0
  124. package/src/testing/test-replay-assertions.test.ts +356 -0
  125. package/src/testing/test-replay-assertions.ts +164 -0
  126. package/src/testing/test-rng-helpers.test.ts +260 -0
  127. package/src/testing/test-rng-helpers.ts +190 -0
  128. package/src/testing/test-state-builder.test.ts +373 -0
  129. package/src/testing/test-state-builder.ts +99 -0
  130. package/src/testing/test-zone-factory.test.ts +295 -0
  131. package/src/testing/test-zone-factory.ts +224 -0
  132. package/src/types/branded-utils.ts +54 -0
  133. package/src/types/branded.test.ts +175 -0
  134. package/src/types/branded.ts +33 -0
  135. package/src/types/index.ts +8 -0
  136. package/src/types/state.test.ts +198 -0
  137. package/src/types/state.ts +154 -0
  138. package/src/validation/card-type-guards.test.ts +242 -0
  139. package/src/validation/card-type-guards.ts +179 -0
  140. package/src/validation/index.ts +40 -0
  141. package/src/validation/schema-builders.test.ts +403 -0
  142. package/src/validation/schema-builders.ts +345 -0
  143. package/src/validation/type-guard-builder.test.ts +216 -0
  144. package/src/validation/type-guard-builder.ts +109 -0
  145. package/src/validation/validator-builder.test.ts +375 -0
  146. package/src/validation/validator-builder.ts +273 -0
  147. package/src/zones/index.ts +28 -0
  148. package/src/zones/zone-factory.test.ts +183 -0
  149. package/src/zones/zone-factory.ts +44 -0
  150. package/src/zones/zone-operations.test.ts +800 -0
  151. package/src/zones/zone-operations.ts +306 -0
  152. package/src/zones/zone-state-helpers.test.ts +337 -0
  153. package/src/zones/zone-state-helpers.ts +128 -0
  154. package/src/zones/zone-visibility.test.ts +156 -0
  155. package/src/zones/zone-visibility.ts +36 -0
  156. package/src/zones/zone.test.ts +186 -0
  157. package/src/zones/zone.ts +66 -0
@@ -0,0 +1,994 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { CardInstance } from "../cards/card-instance";
3
+ import type { Modifier } from "../cards/modifiers";
4
+ import { createCardRegistry } from "../operations/card-registry-impl";
5
+ import { createCardId, createPlayerId, createZoneId } from "../types";
6
+ import type { TargetDefinition } from "./target-definition";
7
+ import {
8
+ enumerateTargetCombinations,
9
+ getLegalTargets,
10
+ isLegalTarget,
11
+ validateTargetSelection,
12
+ } from "./target-validation";
13
+
14
+ type TestGameState = {
15
+ cards: Record<string, CardInstance<{ modifiers: Modifier[] }>>;
16
+ };
17
+
18
+ describe("Target Validation", () => {
19
+ const registry = createCardRegistry([
20
+ {
21
+ id: "creature1",
22
+ name: "Creature 1",
23
+ type: "creature",
24
+ basePower: 2,
25
+ baseToughness: 2,
26
+ abilities: [],
27
+ },
28
+ {
29
+ id: "creature2",
30
+ name: "Creature 2",
31
+ type: "creature",
32
+ basePower: 3,
33
+ baseToughness: 3,
34
+ abilities: [],
35
+ },
36
+ {
37
+ id: "instant",
38
+ name: "Instant",
39
+ type: "instant",
40
+ abilities: [],
41
+ },
42
+ ]);
43
+
44
+ describe("isLegal Target", () => {
45
+ it("should validate target matches filter", () => {
46
+ const playZone = createZoneId("play");
47
+ const player1 = createPlayerId("player-1");
48
+
49
+ const card: CardInstance<{ modifiers: Modifier[] }> = {
50
+ id: createCardId("card-1"),
51
+ definitionId: "creature1",
52
+ owner: player1,
53
+ controller: player1,
54
+ zone: playZone,
55
+ tapped: false,
56
+ flipped: false,
57
+ revealed: false,
58
+ phased: false,
59
+ modifiers: [],
60
+ };
61
+
62
+ const state: TestGameState = {
63
+ cards: { [String(card.id)]: card },
64
+ };
65
+
66
+ const targetDef: TargetDefinition = {
67
+ filter: { zone: playZone, type: "creature" },
68
+ count: 1,
69
+ };
70
+
71
+ const result = isLegalTarget(card, targetDef, state, registry, {
72
+ sourceCard: card,
73
+ controller: player1,
74
+ previousTargets: [],
75
+ });
76
+
77
+ expect(result).toBe(true);
78
+ });
79
+
80
+ it("should reject target that doesn't match filter", () => {
81
+ const playZone = createZoneId("play");
82
+ const player1 = createPlayerId("player-1");
83
+
84
+ const card: CardInstance<{ modifiers: Modifier[] }> = {
85
+ id: createCardId("card-1"),
86
+ definitionId: "instant",
87
+ owner: player1,
88
+ controller: player1,
89
+ zone: playZone,
90
+ tapped: false,
91
+ flipped: false,
92
+ revealed: false,
93
+ phased: false,
94
+ modifiers: [],
95
+ };
96
+
97
+ const state: TestGameState = {
98
+ cards: { [String(card.id)]: card },
99
+ };
100
+
101
+ const targetDef: TargetDefinition = {
102
+ filter: { type: "creature" },
103
+ count: 1,
104
+ };
105
+
106
+ const result = isLegalTarget(card, targetDef, state, registry, {
107
+ sourceCard: card,
108
+ controller: player1,
109
+ previousTargets: [],
110
+ });
111
+
112
+ expect(result).toBe(false);
113
+ });
114
+
115
+ it("should enforce 'not-self' restriction", () => {
116
+ const playZone = createZoneId("play");
117
+ const player1 = createPlayerId("player-1");
118
+
119
+ const sourceCard: CardInstance<{ modifiers: Modifier[] }> = {
120
+ id: createCardId("source"),
121
+ definitionId: "creature1",
122
+ owner: player1,
123
+ controller: player1,
124
+ zone: playZone,
125
+ tapped: false,
126
+ flipped: false,
127
+ revealed: false,
128
+ phased: false,
129
+ modifiers: [],
130
+ };
131
+
132
+ const state: TestGameState = {
133
+ cards: { [String(sourceCard.id)]: sourceCard },
134
+ };
135
+
136
+ const targetDef: TargetDefinition = {
137
+ filter: { type: "creature" },
138
+ count: 1,
139
+ restrictions: ["not-self"],
140
+ };
141
+
142
+ const result = isLegalTarget(sourceCard, targetDef, state, registry, {
143
+ sourceCard,
144
+ controller: player1,
145
+ previousTargets: [],
146
+ });
147
+
148
+ expect(result).toBe(false);
149
+ });
150
+
151
+ it("should allow targeting self without 'not-self' restriction", () => {
152
+ const playZone = createZoneId("play");
153
+ const player1 = createPlayerId("player-1");
154
+
155
+ const sourceCard: CardInstance<{ modifiers: Modifier[] }> = {
156
+ id: createCardId("source"),
157
+ definitionId: "creature1",
158
+ owner: player1,
159
+ controller: player1,
160
+ zone: playZone,
161
+ tapped: false,
162
+ flipped: false,
163
+ revealed: false,
164
+ phased: false,
165
+ modifiers: [],
166
+ };
167
+
168
+ const state: TestGameState = {
169
+ cards: { [String(sourceCard.id)]: sourceCard },
170
+ };
171
+
172
+ const targetDef: TargetDefinition = {
173
+ filter: { type: "creature" },
174
+ count: 1,
175
+ };
176
+
177
+ const result = isLegalTarget(sourceCard, targetDef, state, registry, {
178
+ sourceCard,
179
+ controller: player1,
180
+ previousTargets: [],
181
+ });
182
+
183
+ expect(result).toBe(true);
184
+ });
185
+
186
+ it("should enforce 'not-controller' restriction", () => {
187
+ const playZone = createZoneId("play");
188
+ const player1 = createPlayerId("player-1");
189
+
190
+ const card: CardInstance<{ modifiers: Modifier[] }> = {
191
+ id: createCardId("card-1"),
192
+ definitionId: "creature1",
193
+ owner: player1,
194
+ controller: player1,
195
+ zone: playZone,
196
+ tapped: false,
197
+ flipped: false,
198
+ revealed: false,
199
+ phased: false,
200
+ modifiers: [],
201
+ };
202
+
203
+ const state: TestGameState = {
204
+ cards: { [String(card.id)]: card },
205
+ };
206
+
207
+ const targetDef: TargetDefinition = {
208
+ filter: { type: "creature" },
209
+ count: 1,
210
+ restrictions: ["not-controller"],
211
+ };
212
+
213
+ const result = isLegalTarget(card, targetDef, state, registry, {
214
+ sourceCard: card,
215
+ controller: player1,
216
+ previousTargets: [],
217
+ });
218
+
219
+ expect(result).toBe(false);
220
+ });
221
+
222
+ it("should enforce 'not-owner' restriction", () => {
223
+ const playZone = createZoneId("play");
224
+ const player1 = createPlayerId("player-1");
225
+
226
+ const card: CardInstance<{ modifiers: Modifier[] }> = {
227
+ id: createCardId("card-1"),
228
+ definitionId: "creature1",
229
+ owner: player1,
230
+ controller: player1,
231
+ zone: playZone,
232
+ tapped: false,
233
+ flipped: false,
234
+ revealed: false,
235
+ phased: false,
236
+ modifiers: [],
237
+ };
238
+
239
+ const state: TestGameState = {
240
+ cards: { [String(card.id)]: card },
241
+ };
242
+
243
+ const targetDef: TargetDefinition = {
244
+ filter: { type: "creature" },
245
+ count: 1,
246
+ restrictions: ["not-owner"],
247
+ };
248
+
249
+ const result = isLegalTarget(card, targetDef, state, registry, {
250
+ sourceCard: card,
251
+ controller: player1,
252
+ previousTargets: [],
253
+ });
254
+
255
+ expect(result).toBe(false);
256
+ });
257
+
258
+ it("should enforce 'different-targets' restriction", () => {
259
+ const playZone = createZoneId("play");
260
+ const player1 = createPlayerId("player-1");
261
+
262
+ const card1: CardInstance<{ modifiers: Modifier[] }> = {
263
+ id: createCardId("card-1"),
264
+ definitionId: "creature1",
265
+ owner: player1,
266
+ controller: player1,
267
+ zone: playZone,
268
+ tapped: false,
269
+ flipped: false,
270
+ revealed: false,
271
+ phased: false,
272
+ modifiers: [],
273
+ };
274
+
275
+ const card2: CardInstance<{ modifiers: Modifier[] }> = {
276
+ id: createCardId("card-2"),
277
+ definitionId: "creature2",
278
+ owner: player1,
279
+ controller: player1,
280
+ zone: playZone,
281
+ tapped: false,
282
+ flipped: false,
283
+ revealed: false,
284
+ phased: false,
285
+ modifiers: [],
286
+ };
287
+
288
+ const state: TestGameState = {
289
+ cards: {
290
+ [String(card1.id)]: card1,
291
+ [String(card2.id)]: card2,
292
+ },
293
+ };
294
+
295
+ const targetDef: TargetDefinition = {
296
+ filter: { type: "creature" },
297
+ count: 2,
298
+ restrictions: ["different-targets"],
299
+ };
300
+
301
+ // First target is always legal
302
+ const result1 = isLegalTarget(card1, targetDef, state, registry, {
303
+ sourceCard: card1,
304
+ controller: player1,
305
+ previousTargets: [],
306
+ });
307
+ expect(result1).toBe(true);
308
+
309
+ // Second target must be different
310
+ const result2 = isLegalTarget(card1, targetDef, state, registry, {
311
+ sourceCard: card1,
312
+ controller: player1,
313
+ previousTargets: [card1],
314
+ });
315
+ expect(result2).toBe(false);
316
+
317
+ // Different card is legal
318
+ const result3 = isLegalTarget(card2, targetDef, state, registry, {
319
+ sourceCard: card1,
320
+ controller: player1,
321
+ previousTargets: [card1],
322
+ });
323
+ expect(result3).toBe(true);
324
+ });
325
+ });
326
+
327
+ describe("getLegalTargets", () => {
328
+ it("should return all cards matching filter", () => {
329
+ const playZone = createZoneId("play");
330
+ const player1 = createPlayerId("player-1");
331
+
332
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
333
+ {
334
+ id: createCardId("card-1"),
335
+ definitionId: "creature1",
336
+ owner: player1,
337
+ controller: player1,
338
+ zone: playZone,
339
+ tapped: false,
340
+ flipped: false,
341
+ revealed: false,
342
+ phased: false,
343
+ modifiers: [],
344
+ },
345
+ {
346
+ id: createCardId("card-2"),
347
+ definitionId: "creature2",
348
+ owner: player1,
349
+ controller: player1,
350
+ zone: playZone,
351
+ tapped: false,
352
+ flipped: false,
353
+ revealed: false,
354
+ phased: false,
355
+ modifiers: [],
356
+ },
357
+ {
358
+ id: createCardId("card-3"),
359
+ definitionId: "instant",
360
+ owner: player1,
361
+ controller: player1,
362
+ zone: playZone,
363
+ tapped: false,
364
+ flipped: false,
365
+ revealed: false,
366
+ phased: false,
367
+ modifiers: [],
368
+ },
369
+ ];
370
+
371
+ const state: TestGameState = {
372
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
373
+ };
374
+
375
+ const targetDef: TargetDefinition = {
376
+ filter: { type: "creature" },
377
+ count: 1,
378
+ };
379
+
380
+ const result = getLegalTargets(targetDef, state, registry, {
381
+ sourceCard: cards[0],
382
+ controller: player1,
383
+ previousTargets: [],
384
+ });
385
+
386
+ expect(result).toHaveLength(2);
387
+ expect(result.map((c) => c.definitionId).sort()).toEqual([
388
+ "creature1",
389
+ "creature2",
390
+ ]);
391
+ });
392
+
393
+ it("should exclude cards that don't meet restrictions", () => {
394
+ const playZone = createZoneId("play");
395
+ const player1 = createPlayerId("player-1");
396
+
397
+ const sourceCard: CardInstance<{ modifiers: Modifier[] }> = {
398
+ id: createCardId("source"),
399
+ definitionId: "creature1",
400
+ owner: player1,
401
+ controller: player1,
402
+ zone: playZone,
403
+ tapped: false,
404
+ flipped: false,
405
+ revealed: false,
406
+ phased: false,
407
+ modifiers: [],
408
+ };
409
+
410
+ const otherCard: CardInstance<{ modifiers: Modifier[] }> = {
411
+ id: createCardId("other"),
412
+ definitionId: "creature2",
413
+ owner: player1,
414
+ controller: player1,
415
+ zone: playZone,
416
+ tapped: false,
417
+ flipped: false,
418
+ revealed: false,
419
+ phased: false,
420
+ modifiers: [],
421
+ };
422
+
423
+ const state: TestGameState = {
424
+ cards: {
425
+ [String(sourceCard.id)]: sourceCard,
426
+ [String(otherCard.id)]: otherCard,
427
+ },
428
+ };
429
+
430
+ const targetDef: TargetDefinition = {
431
+ filter: { type: "creature" },
432
+ count: 1,
433
+ restrictions: ["not-self"],
434
+ };
435
+
436
+ const result = getLegalTargets(targetDef, state, registry, {
437
+ sourceCard,
438
+ controller: player1,
439
+ previousTargets: [],
440
+ });
441
+
442
+ expect(result).toHaveLength(1);
443
+ expect(result[0].id).toBe(otherCard.id);
444
+ });
445
+
446
+ it("should return empty array when no legal targets", () => {
447
+ const playZone = createZoneId("play");
448
+ const player1 = createPlayerId("player-1");
449
+
450
+ const card: CardInstance<{ modifiers: Modifier[] }> = {
451
+ id: createCardId("card-1"),
452
+ definitionId: "instant",
453
+ owner: player1,
454
+ controller: player1,
455
+ zone: playZone,
456
+ tapped: false,
457
+ flipped: false,
458
+ revealed: false,
459
+ phased: false,
460
+ modifiers: [],
461
+ };
462
+
463
+ const state: TestGameState = {
464
+ cards: { [String(card.id)]: card },
465
+ };
466
+
467
+ const targetDef: TargetDefinition = {
468
+ filter: { type: "creature" },
469
+ count: 1,
470
+ };
471
+
472
+ const result = getLegalTargets(targetDef, state, registry, {
473
+ sourceCard: card,
474
+ controller: player1,
475
+ previousTargets: [],
476
+ });
477
+
478
+ expect(result).toHaveLength(0);
479
+ });
480
+ });
481
+
482
+ describe("validateTargetSelection", () => {
483
+ it("should validate exact count matches", () => {
484
+ const playZone = createZoneId("play");
485
+ const player1 = createPlayerId("player-1");
486
+
487
+ const card: CardInstance<{ modifiers: Modifier[] }> = {
488
+ id: createCardId("card-1"),
489
+ definitionId: "creature1",
490
+ owner: player1,
491
+ controller: player1,
492
+ zone: playZone,
493
+ tapped: false,
494
+ flipped: false,
495
+ revealed: false,
496
+ phased: false,
497
+ modifiers: [],
498
+ };
499
+
500
+ const state: TestGameState = {
501
+ cards: { [String(card.id)]: card },
502
+ };
503
+
504
+ const targetDef: TargetDefinition = {
505
+ filter: { type: "creature" },
506
+ count: 1,
507
+ };
508
+
509
+ const result = validateTargetSelection(
510
+ [card],
511
+ targetDef,
512
+ state,
513
+ registry,
514
+ {
515
+ sourceCard: card,
516
+ controller: player1,
517
+ },
518
+ );
519
+
520
+ expect(result.valid).toBe(true);
521
+ });
522
+
523
+ it("should reject when count doesn't match", () => {
524
+ const playZone = createZoneId("play");
525
+ const player1 = createPlayerId("player-1");
526
+
527
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
528
+ {
529
+ id: createCardId("card-1"),
530
+ definitionId: "creature1",
531
+ owner: player1,
532
+ controller: player1,
533
+ zone: playZone,
534
+ tapped: false,
535
+ flipped: false,
536
+ revealed: false,
537
+ phased: false,
538
+ modifiers: [],
539
+ },
540
+ {
541
+ id: createCardId("card-2"),
542
+ definitionId: "creature2",
543
+ owner: player1,
544
+ controller: player1,
545
+ zone: playZone,
546
+ tapped: false,
547
+ flipped: false,
548
+ revealed: false,
549
+ phased: false,
550
+ modifiers: [],
551
+ },
552
+ ];
553
+
554
+ const state: TestGameState = {
555
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
556
+ };
557
+
558
+ const targetDef: TargetDefinition = {
559
+ filter: { type: "creature" },
560
+ count: 1,
561
+ };
562
+
563
+ const result = validateTargetSelection(
564
+ cards,
565
+ targetDef,
566
+ state,
567
+ registry,
568
+ {
569
+ sourceCard: cards[0],
570
+ controller: player1,
571
+ },
572
+ );
573
+
574
+ expect(result.valid).toBe(false);
575
+ expect(result.error).toContain("Expected 1 target");
576
+ });
577
+
578
+ it("should validate range count (min/max)", () => {
579
+ const playZone = createZoneId("play");
580
+ const player1 = createPlayerId("player-1");
581
+
582
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
583
+ {
584
+ id: createCardId("card-1"),
585
+ definitionId: "creature1",
586
+ owner: player1,
587
+ controller: player1,
588
+ zone: playZone,
589
+ tapped: false,
590
+ flipped: false,
591
+ revealed: false,
592
+ phased: false,
593
+ modifiers: [],
594
+ },
595
+ {
596
+ id: createCardId("card-2"),
597
+ definitionId: "creature2",
598
+ owner: player1,
599
+ controller: player1,
600
+ zone: playZone,
601
+ tapped: false,
602
+ flipped: false,
603
+ revealed: false,
604
+ phased: false,
605
+ modifiers: [],
606
+ },
607
+ ];
608
+
609
+ const state: TestGameState = {
610
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
611
+ };
612
+
613
+ const targetDef: TargetDefinition = {
614
+ filter: { type: "creature" },
615
+ count: { min: 1, max: 3 },
616
+ };
617
+
618
+ // 2 targets is within range
619
+ const result = validateTargetSelection(
620
+ cards,
621
+ targetDef,
622
+ state,
623
+ registry,
624
+ {
625
+ sourceCard: cards[0],
626
+ controller: player1,
627
+ },
628
+ );
629
+
630
+ expect(result.valid).toBe(true);
631
+ });
632
+
633
+ it("should reject when below minimum count", () => {
634
+ const playZone = createZoneId("play");
635
+ const player1 = createPlayerId("player-1");
636
+
637
+ const card: CardInstance<{ modifiers: Modifier[] }> = {
638
+ id: createCardId("card-1"),
639
+ definitionId: "creature1",
640
+ owner: player1,
641
+ controller: player1,
642
+ zone: playZone,
643
+ tapped: false,
644
+ flipped: false,
645
+ revealed: false,
646
+ phased: false,
647
+ modifiers: [],
648
+ };
649
+
650
+ const state: TestGameState = {
651
+ cards: { [String(card.id)]: card },
652
+ };
653
+
654
+ const targetDef: TargetDefinition = {
655
+ filter: { type: "creature" },
656
+ count: { min: 2, max: 4 },
657
+ };
658
+
659
+ const result = validateTargetSelection(
660
+ [card],
661
+ targetDef,
662
+ state,
663
+ registry,
664
+ {
665
+ sourceCard: card,
666
+ controller: player1,
667
+ },
668
+ );
669
+
670
+ expect(result.valid).toBe(false);
671
+ expect(result.error).toContain("at least 2");
672
+ });
673
+
674
+ it("should reject when above maximum count", () => {
675
+ const playZone = createZoneId("play");
676
+ const player1 = createPlayerId("player-1");
677
+
678
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = Array.from(
679
+ { length: 5 },
680
+ (_, i) => ({
681
+ id: createCardId(`card-${i}`),
682
+ definitionId: "creature1",
683
+ owner: player1,
684
+ controller: player1,
685
+ zone: playZone,
686
+ tapped: false,
687
+ flipped: false,
688
+ revealed: false,
689
+ phased: false,
690
+ modifiers: [],
691
+ }),
692
+ );
693
+
694
+ const state: TestGameState = {
695
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
696
+ };
697
+
698
+ const targetDef: TargetDefinition = {
699
+ filter: { type: "creature" },
700
+ count: { min: 1, max: 3 },
701
+ };
702
+
703
+ const result = validateTargetSelection(
704
+ cards,
705
+ targetDef,
706
+ state,
707
+ registry,
708
+ {
709
+ sourceCard: cards[0],
710
+ controller: player1,
711
+ },
712
+ );
713
+
714
+ expect(result.valid).toBe(false);
715
+ expect(result.error).toContain("at most 3");
716
+ });
717
+
718
+ it("should validate each target individually", () => {
719
+ const playZone = createZoneId("play");
720
+ const player1 = createPlayerId("player-1");
721
+
722
+ const creatures: CardInstance<{ modifiers: Modifier[] }>[] = [
723
+ {
724
+ id: createCardId("creature-1"),
725
+ definitionId: "creature1",
726
+ owner: player1,
727
+ controller: player1,
728
+ zone: playZone,
729
+ tapped: false,
730
+ flipped: false,
731
+ revealed: false,
732
+ phased: false,
733
+ modifiers: [],
734
+ },
735
+ ];
736
+
737
+ const instant: CardInstance<{ modifiers: Modifier[] }> = {
738
+ id: createCardId("instant"),
739
+ definitionId: "instant",
740
+ owner: player1,
741
+ controller: player1,
742
+ zone: playZone,
743
+ tapped: false,
744
+ flipped: false,
745
+ revealed: false,
746
+ phased: false,
747
+ modifiers: [],
748
+ };
749
+
750
+ const state: TestGameState = {
751
+ cards: {
752
+ [String(creatures[0].id)]: creatures[0],
753
+ [String(instant.id)]: instant,
754
+ },
755
+ };
756
+
757
+ const targetDef: TargetDefinition = {
758
+ filter: { type: "creature" },
759
+ count: 1,
760
+ };
761
+
762
+ const result = validateTargetSelection(
763
+ [instant],
764
+ targetDef,
765
+ state,
766
+ registry,
767
+ {
768
+ sourceCard: creatures[0],
769
+ controller: player1,
770
+ },
771
+ );
772
+
773
+ expect(result.valid).toBe(false);
774
+ expect(result.error).toContain("not a legal target");
775
+ });
776
+ });
777
+
778
+ describe("enumerateTargetCombinations", () => {
779
+ it("should enumerate single target options", () => {
780
+ const playZone = createZoneId("play");
781
+ const player1 = createPlayerId("player-1");
782
+
783
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
784
+ {
785
+ id: createCardId("card-1"),
786
+ definitionId: "creature1",
787
+ owner: player1,
788
+ controller: player1,
789
+ zone: playZone,
790
+ tapped: false,
791
+ flipped: false,
792
+ revealed: false,
793
+ phased: false,
794
+ modifiers: [],
795
+ },
796
+ {
797
+ id: createCardId("card-2"),
798
+ definitionId: "creature2",
799
+ owner: player1,
800
+ controller: player1,
801
+ zone: playZone,
802
+ tapped: false,
803
+ flipped: false,
804
+ revealed: false,
805
+ phased: false,
806
+ modifiers: [],
807
+ },
808
+ ];
809
+
810
+ const state: TestGameState = {
811
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
812
+ };
813
+
814
+ const targetDef: TargetDefinition = {
815
+ filter: { type: "creature" },
816
+ count: 1,
817
+ };
818
+
819
+ const result = enumerateTargetCombinations(
820
+ targetDef,
821
+ state,
822
+ registry,
823
+ {
824
+ sourceCard: cards[0],
825
+ controller: player1,
826
+ previousTargets: [],
827
+ },
828
+ 10,
829
+ );
830
+
831
+ expect(result).toHaveLength(2);
832
+ expect(result[0]).toHaveLength(1);
833
+ expect(result[1]).toHaveLength(1);
834
+ });
835
+
836
+ it("should enumerate multiple target combinations", () => {
837
+ const playZone = createZoneId("play");
838
+ const player1 = createPlayerId("player-1");
839
+
840
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
841
+ {
842
+ id: createCardId("card-1"),
843
+ definitionId: "creature1",
844
+ owner: player1,
845
+ controller: player1,
846
+ zone: playZone,
847
+ tapped: false,
848
+ flipped: false,
849
+ revealed: false,
850
+ phased: false,
851
+ modifiers: [],
852
+ },
853
+ {
854
+ id: createCardId("card-2"),
855
+ definitionId: "creature2",
856
+ owner: player1,
857
+ controller: player1,
858
+ zone: playZone,
859
+ tapped: false,
860
+ flipped: false,
861
+ revealed: false,
862
+ phased: false,
863
+ modifiers: [],
864
+ },
865
+ {
866
+ id: createCardId("card-3"),
867
+ definitionId: "creature1",
868
+ owner: player1,
869
+ controller: player1,
870
+ zone: playZone,
871
+ tapped: false,
872
+ flipped: false,
873
+ revealed: false,
874
+ phased: false,
875
+ modifiers: [],
876
+ },
877
+ ];
878
+
879
+ const state: TestGameState = {
880
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
881
+ };
882
+
883
+ const targetDef: TargetDefinition = {
884
+ filter: { type: "creature" },
885
+ count: 2,
886
+ restrictions: ["different-targets"],
887
+ };
888
+
889
+ const result = enumerateTargetCombinations(
890
+ targetDef,
891
+ state,
892
+ registry,
893
+ {
894
+ sourceCard: cards[0],
895
+ controller: player1,
896
+ previousTargets: [],
897
+ },
898
+ 10,
899
+ );
900
+
901
+ // Should have C(3,2) = 3 combinations
902
+ expect(result).toHaveLength(3);
903
+ expect(result.every((combo) => combo.length === 2)).toBe(true);
904
+ });
905
+
906
+ it("should respect maxCombinations limit", () => {
907
+ const playZone = createZoneId("play");
908
+ const player1 = createPlayerId("player-1");
909
+
910
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = Array.from(
911
+ { length: 10 },
912
+ (_, i) => ({
913
+ id: createCardId(`card-${i}`),
914
+ definitionId: "creature1",
915
+ owner: player1,
916
+ controller: player1,
917
+ zone: playZone,
918
+ tapped: false,
919
+ flipped: false,
920
+ revealed: false,
921
+ phased: false,
922
+ modifiers: [],
923
+ }),
924
+ );
925
+
926
+ const state: TestGameState = {
927
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
928
+ };
929
+
930
+ const targetDef: TargetDefinition = {
931
+ filter: { type: "creature" },
932
+ count: 1,
933
+ };
934
+
935
+ const result = enumerateTargetCombinations(
936
+ targetDef,
937
+ state,
938
+ registry,
939
+ {
940
+ sourceCard: cards[0],
941
+ controller: player1,
942
+ previousTargets: [],
943
+ },
944
+ 5,
945
+ );
946
+
947
+ expect(result).toHaveLength(5);
948
+ });
949
+
950
+ it("should handle optional targets (min 0)", () => {
951
+ const playZone = createZoneId("play");
952
+ const player1 = createPlayerId("player-1");
953
+
954
+ const card: CardInstance<{ modifiers: Modifier[] }> = {
955
+ id: createCardId("card-1"),
956
+ definitionId: "creature1",
957
+ owner: player1,
958
+ controller: player1,
959
+ zone: playZone,
960
+ tapped: false,
961
+ flipped: false,
962
+ revealed: false,
963
+ phased: false,
964
+ modifiers: [],
965
+ };
966
+
967
+ const state: TestGameState = {
968
+ cards: { [String(card.id)]: card },
969
+ };
970
+
971
+ const targetDef: TargetDefinition = {
972
+ filter: { type: "creature" },
973
+ count: { min: 0, max: 1 },
974
+ };
975
+
976
+ const result = enumerateTargetCombinations(
977
+ targetDef,
978
+ state,
979
+ registry,
980
+ {
981
+ sourceCard: card,
982
+ controller: player1,
983
+ previousTargets: [],
984
+ },
985
+ 10,
986
+ );
987
+
988
+ // Should have: [] (0 targets) and [card] (1 target)
989
+ expect(result).toHaveLength(2);
990
+ expect(result.some((combo) => combo.length === 0)).toBe(true);
991
+ expect(result.some((combo) => combo.length === 1)).toBe(true);
992
+ });
993
+ });
994
+ });