@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,944 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { CardDefinition } from "../cards/card-definition";
3
+ import type { CardInstance } from "../cards/card-instance";
4
+ import type { Modifier } from "../cards/modifiers";
5
+ import { createCardRegistry } from "../operations/card-registry-impl";
6
+ import { createCardId, createPlayerId, createZoneId } from "../types";
7
+ import type { CardFilter } from "./card-filter";
8
+ import {
9
+ anyCard,
10
+ countCards,
11
+ matchesNumberFilter,
12
+ selectCards,
13
+ } from "./filter-matching";
14
+
15
+ type TestGameState = {
16
+ cards: Record<string, CardInstance<{ modifiers: Modifier[] }>>;
17
+ };
18
+
19
+ describe("Filter Matching", () => {
20
+ describe("matchesNumberFilter", () => {
21
+ it("should match exact number", () => {
22
+ expect(matchesNumberFilter(5, 5)).toBe(true);
23
+ expect(matchesNumberFilter(5, 3)).toBe(false);
24
+ });
25
+
26
+ it("should match eq (equal)", () => {
27
+ expect(matchesNumberFilter({ eq: 5 }, 5)).toBe(true);
28
+ expect(matchesNumberFilter({ eq: 5 }, 3)).toBe(false);
29
+ });
30
+
31
+ it("should match gte (greater than or equal)", () => {
32
+ expect(matchesNumberFilter({ gte: 5 }, 5)).toBe(true);
33
+ expect(matchesNumberFilter({ gte: 5 }, 6)).toBe(true);
34
+ expect(matchesNumberFilter({ gte: 5 }, 4)).toBe(false);
35
+ });
36
+
37
+ it("should match lte (less than or equal)", () => {
38
+ expect(matchesNumberFilter({ lte: 5 }, 5)).toBe(true);
39
+ expect(matchesNumberFilter({ lte: 5 }, 4)).toBe(true);
40
+ expect(matchesNumberFilter({ lte: 5 }, 6)).toBe(false);
41
+ });
42
+
43
+ it("should match gt (greater than)", () => {
44
+ expect(matchesNumberFilter({ gt: 5 }, 6)).toBe(true);
45
+ expect(matchesNumberFilter({ gt: 5 }, 5)).toBe(false);
46
+ expect(matchesNumberFilter({ gt: 5 }, 4)).toBe(false);
47
+ });
48
+
49
+ it("should match lt (less than)", () => {
50
+ expect(matchesNumberFilter({ lt: 5 }, 4)).toBe(true);
51
+ expect(matchesNumberFilter({ lt: 5 }, 5)).toBe(false);
52
+ expect(matchesNumberFilter({ lt: 5 }, 6)).toBe(false);
53
+ });
54
+
55
+ it("should match between (range inclusive)", () => {
56
+ expect(matchesNumberFilter({ between: [2, 5] }, 2)).toBe(true);
57
+ expect(matchesNumberFilter({ between: [2, 5] }, 3)).toBe(true);
58
+ expect(matchesNumberFilter({ between: [2, 5] }, 5)).toBe(true);
59
+ expect(matchesNumberFilter({ between: [2, 5] }, 1)).toBe(false);
60
+ expect(matchesNumberFilter({ between: [2, 5] }, 6)).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("selectCards", () => {
65
+ const definitions: CardDefinition[] = [
66
+ {
67
+ id: "grizzly-bears",
68
+ name: "Grizzly Bears",
69
+ type: "creature",
70
+ basePower: 2,
71
+ baseToughness: 2,
72
+ baseCost: 2,
73
+ abilities: [],
74
+ },
75
+ {
76
+ id: "lightning-bolt",
77
+ name: "Lightning Bolt",
78
+ type: "instant",
79
+ baseCost: 1,
80
+ abilities: [],
81
+ },
82
+ {
83
+ id: "dragon",
84
+ name: "Shivan Dragon",
85
+ type: "creature",
86
+ basePower: 5,
87
+ baseToughness: 5,
88
+ baseCost: 6,
89
+ abilities: ["flying"],
90
+ },
91
+ ];
92
+
93
+ const registry = createCardRegistry(definitions);
94
+
95
+ it("should filter by zone", () => {
96
+ const playZone = createZoneId("play");
97
+ const handZone = createZoneId("hand");
98
+
99
+ const card1: CardInstance<{ modifiers: Modifier[] }> = {
100
+ id: createCardId("card-1"),
101
+ definitionId: "grizzly-bears",
102
+ owner: createPlayerId("player-1"),
103
+ controller: createPlayerId("player-1"),
104
+ zone: playZone,
105
+ tapped: false,
106
+ flipped: false,
107
+ revealed: false,
108
+ phased: false,
109
+ modifiers: [],
110
+ };
111
+
112
+ const card2: CardInstance<{ modifiers: Modifier[] }> = {
113
+ id: createCardId("card-2"),
114
+ definitionId: "lightning-bolt",
115
+ owner: createPlayerId("player-1"),
116
+ controller: createPlayerId("player-1"),
117
+ zone: handZone,
118
+ tapped: false,
119
+ flipped: false,
120
+ revealed: false,
121
+ phased: false,
122
+ modifiers: [],
123
+ };
124
+
125
+ const state: TestGameState = {
126
+ cards: {
127
+ [String(card1.id)]: card1,
128
+ [String(card2.id)]: card2,
129
+ },
130
+ };
131
+
132
+ const filter: CardFilter = { zone: playZone };
133
+ const result = selectCards(state, filter, registry);
134
+
135
+ expect(result).toHaveLength(1);
136
+ expect(result[0].id).toBe(card1.id);
137
+ });
138
+
139
+ it("should filter by multiple zones", () => {
140
+ const playZone = createZoneId("play");
141
+ const handZone = createZoneId("hand");
142
+ const deckZone = createZoneId("deck");
143
+
144
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
145
+ {
146
+ id: createCardId("card-1"),
147
+ definitionId: "grizzly-bears",
148
+ owner: createPlayerId("player-1"),
149
+ controller: createPlayerId("player-1"),
150
+ zone: playZone,
151
+ tapped: false,
152
+ flipped: false,
153
+ revealed: false,
154
+ phased: false,
155
+ modifiers: [],
156
+ },
157
+ {
158
+ id: createCardId("card-2"),
159
+ definitionId: "lightning-bolt",
160
+ owner: createPlayerId("player-1"),
161
+ controller: createPlayerId("player-1"),
162
+ zone: handZone,
163
+ tapped: false,
164
+ flipped: false,
165
+ revealed: false,
166
+ phased: false,
167
+ modifiers: [],
168
+ },
169
+ {
170
+ id: createCardId("card-3"),
171
+ definitionId: "dragon",
172
+ owner: createPlayerId("player-1"),
173
+ controller: createPlayerId("player-1"),
174
+ zone: deckZone,
175
+ tapped: false,
176
+ flipped: false,
177
+ revealed: false,
178
+ phased: false,
179
+ modifiers: [],
180
+ },
181
+ ];
182
+
183
+ const state: TestGameState = {
184
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
185
+ };
186
+
187
+ const filter: CardFilter = { zone: [playZone, handZone] };
188
+ const result = selectCards(state, filter, registry);
189
+
190
+ expect(result).toHaveLength(2);
191
+ });
192
+
193
+ it("should filter by owner", () => {
194
+ const player1 = createPlayerId("player-1");
195
+ const player2 = createPlayerId("player-2");
196
+
197
+ const card1: CardInstance<{ modifiers: Modifier[] }> = {
198
+ id: createCardId("card-1"),
199
+ definitionId: "grizzly-bears",
200
+ owner: player1,
201
+ controller: player1,
202
+ zone: createZoneId("play"),
203
+ tapped: false,
204
+ flipped: false,
205
+ revealed: false,
206
+ phased: false,
207
+ modifiers: [],
208
+ };
209
+
210
+ const card2: CardInstance<{ modifiers: Modifier[] }> = {
211
+ id: createCardId("card-2"),
212
+ definitionId: "dragon",
213
+ owner: player2,
214
+ controller: player2,
215
+ zone: createZoneId("play"),
216
+ tapped: false,
217
+ flipped: false,
218
+ revealed: false,
219
+ phased: false,
220
+ modifiers: [],
221
+ };
222
+
223
+ const state: TestGameState = {
224
+ cards: {
225
+ [String(card1.id)]: card1,
226
+ [String(card2.id)]: card2,
227
+ },
228
+ };
229
+
230
+ const filter: CardFilter = { owner: player1 };
231
+ const result = selectCards(state, filter, registry);
232
+
233
+ expect(result).toHaveLength(1);
234
+ expect(result[0].owner).toBe(player1);
235
+ });
236
+
237
+ it("should filter by tapped state", () => {
238
+ const card1: CardInstance<{ modifiers: Modifier[] }> = {
239
+ id: createCardId("card-1"),
240
+ definitionId: "grizzly-bears",
241
+ owner: createPlayerId("player-1"),
242
+ controller: createPlayerId("player-1"),
243
+ zone: createZoneId("play"),
244
+ tapped: true,
245
+ flipped: false,
246
+ revealed: false,
247
+ phased: false,
248
+ modifiers: [],
249
+ };
250
+
251
+ const card2: CardInstance<{ modifiers: Modifier[] }> = {
252
+ id: createCardId("card-2"),
253
+ definitionId: "dragon",
254
+ owner: createPlayerId("player-1"),
255
+ controller: createPlayerId("player-1"),
256
+ zone: createZoneId("play"),
257
+ tapped: false,
258
+ flipped: false,
259
+ revealed: false,
260
+ phased: false,
261
+ modifiers: [],
262
+ };
263
+
264
+ const state: TestGameState = {
265
+ cards: {
266
+ [String(card1.id)]: card1,
267
+ [String(card2.id)]: card2,
268
+ },
269
+ };
270
+
271
+ const filter: CardFilter = { tapped: true };
272
+ const result = selectCards(state, filter, registry);
273
+
274
+ expect(result).toHaveLength(1);
275
+ expect(result[0].tapped).toBe(true);
276
+ });
277
+
278
+ it("should combine multiple filters with AND logic", () => {
279
+ const player1 = createPlayerId("player-1");
280
+ const playZone = createZoneId("play");
281
+
282
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
283
+ {
284
+ id: createCardId("card-1"),
285
+ definitionId: "grizzly-bears",
286
+ owner: player1,
287
+ controller: player1,
288
+ zone: playZone,
289
+ tapped: false,
290
+ flipped: false,
291
+ revealed: false,
292
+ phased: false,
293
+ modifiers: [],
294
+ },
295
+ {
296
+ id: createCardId("card-2"),
297
+ definitionId: "dragon",
298
+ owner: player1,
299
+ controller: player1,
300
+ zone: playZone,
301
+ tapped: true,
302
+ flipped: false,
303
+ revealed: false,
304
+ phased: false,
305
+ modifiers: [],
306
+ },
307
+ {
308
+ id: createCardId("card-3"),
309
+ definitionId: "lightning-bolt",
310
+ owner: player1,
311
+ controller: player1,
312
+ zone: createZoneId("hand"),
313
+ tapped: false,
314
+ flipped: false,
315
+ revealed: false,
316
+ phased: false,
317
+ modifiers: [],
318
+ },
319
+ ];
320
+
321
+ const state: TestGameState = {
322
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
323
+ };
324
+
325
+ // Filter: in play zone AND not tapped AND type is creature
326
+ const filter: CardFilter = {
327
+ zone: playZone,
328
+ tapped: false,
329
+ type: "creature",
330
+ };
331
+
332
+ const result = selectCards(state, filter, registry);
333
+
334
+ // Only card-1 matches all criteria
335
+ expect(result).toHaveLength(1);
336
+ expect(result[0].definitionId).toBe("grizzly-bears");
337
+ });
338
+ });
339
+
340
+ describe("countCards", () => {
341
+ it("should count cards matching filter", () => {
342
+ const definitions: CardDefinition[] = [
343
+ {
344
+ id: "creature1",
345
+ name: "Creature 1",
346
+ type: "creature",
347
+ basePower: 2,
348
+ baseToughness: 2,
349
+ abilities: [],
350
+ },
351
+ {
352
+ id: "creature2",
353
+ name: "Creature 2",
354
+ type: "creature",
355
+ basePower: 3,
356
+ baseToughness: 3,
357
+ abilities: [],
358
+ },
359
+ {
360
+ id: "instant",
361
+ name: "Instant",
362
+ type: "instant",
363
+ abilities: [],
364
+ },
365
+ ];
366
+
367
+ const registry = createCardRegistry(definitions);
368
+
369
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
370
+ {
371
+ id: createCardId("card-1"),
372
+ definitionId: "creature1",
373
+ owner: createPlayerId("player-1"),
374
+ controller: createPlayerId("player-1"),
375
+ zone: createZoneId("play"),
376
+ tapped: false,
377
+ flipped: false,
378
+ revealed: false,
379
+ phased: false,
380
+ modifiers: [],
381
+ },
382
+ {
383
+ id: createCardId("card-2"),
384
+ definitionId: "creature2",
385
+ owner: createPlayerId("player-1"),
386
+ controller: createPlayerId("player-1"),
387
+ zone: createZoneId("play"),
388
+ tapped: false,
389
+ flipped: false,
390
+ revealed: false,
391
+ phased: false,
392
+ modifiers: [],
393
+ },
394
+ {
395
+ id: createCardId("card-3"),
396
+ definitionId: "instant",
397
+ owner: createPlayerId("player-1"),
398
+ controller: createPlayerId("player-1"),
399
+ zone: createZoneId("hand"),
400
+ tapped: false,
401
+ flipped: false,
402
+ revealed: false,
403
+ phased: false,
404
+ modifiers: [],
405
+ },
406
+ ];
407
+
408
+ const state: TestGameState = {
409
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
410
+ };
411
+
412
+ const filter: CardFilter = { type: "creature" };
413
+ const count = countCards(state, filter, registry);
414
+
415
+ expect(count).toBe(2);
416
+ });
417
+ });
418
+
419
+ describe("anyCard", () => {
420
+ it("should return true if any card matches filter", () => {
421
+ const definitions: CardDefinition[] = [
422
+ {
423
+ id: "creature",
424
+ name: "Creature",
425
+ type: "creature",
426
+ abilities: [],
427
+ },
428
+ {
429
+ id: "instant",
430
+ name: "Instant",
431
+ type: "instant",
432
+ abilities: [],
433
+ },
434
+ ];
435
+
436
+ const registry = createCardRegistry(definitions);
437
+
438
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
439
+ {
440
+ id: createCardId("card-1"),
441
+ definitionId: "creature",
442
+ owner: createPlayerId("player-1"),
443
+ controller: createPlayerId("player-1"),
444
+ zone: createZoneId("play"),
445
+ tapped: false,
446
+ flipped: false,
447
+ revealed: false,
448
+ phased: false,
449
+ modifiers: [],
450
+ },
451
+ ];
452
+
453
+ const state: TestGameState = {
454
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
455
+ };
456
+
457
+ expect(anyCard(state, { type: "creature" }, registry)).toBe(true);
458
+ expect(anyCard(state, { type: "land" }, registry)).toBe(false);
459
+ });
460
+ });
461
+
462
+ describe("Composite Filters", () => {
463
+ const definitions: CardDefinition[] = [
464
+ {
465
+ id: "bear",
466
+ name: "Grizzly Bears",
467
+ type: "creature",
468
+ basePower: 2,
469
+ baseToughness: 2,
470
+ baseCost: 2,
471
+ abilities: [],
472
+ },
473
+ {
474
+ id: "dragon",
475
+ name: "Shivan Dragon",
476
+ type: "creature",
477
+ basePower: 5,
478
+ baseToughness: 5,
479
+ baseCost: 6,
480
+ abilities: ["flying"],
481
+ },
482
+ {
483
+ id: "bolt",
484
+ name: "Lightning Bolt",
485
+ type: "instant",
486
+ baseCost: 1,
487
+ abilities: [],
488
+ },
489
+ {
490
+ id: "mountain",
491
+ name: "Mountain",
492
+ type: "land",
493
+ abilities: [],
494
+ },
495
+ ];
496
+
497
+ const registry = createCardRegistry(definitions);
498
+
499
+ describe("AND filters", () => {
500
+ it("should match when all sub-filters match", () => {
501
+ const playZone = createZoneId("play");
502
+ const player1 = createPlayerId("player-1");
503
+
504
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
505
+ {
506
+ id: createCardId("card-1"),
507
+ definitionId: "bear",
508
+ owner: player1,
509
+ controller: player1,
510
+ zone: playZone,
511
+ tapped: false,
512
+ flipped: false,
513
+ revealed: false,
514
+ phased: false,
515
+ modifiers: [],
516
+ },
517
+ {
518
+ id: createCardId("card-2"),
519
+ definitionId: "dragon",
520
+ owner: player1,
521
+ controller: player1,
522
+ zone: playZone,
523
+ tapped: true,
524
+ flipped: false,
525
+ revealed: false,
526
+ phased: false,
527
+ modifiers: [],
528
+ },
529
+ {
530
+ id: createCardId("card-3"),
531
+ definitionId: "bolt",
532
+ owner: player1,
533
+ controller: player1,
534
+ zone: createZoneId("hand"),
535
+ tapped: false,
536
+ flipped: false,
537
+ revealed: false,
538
+ phased: false,
539
+ modifiers: [],
540
+ },
541
+ ];
542
+
543
+ const state: TestGameState = {
544
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
545
+ };
546
+
547
+ // Find untapped creatures in play
548
+ const filter: CardFilter = {
549
+ and: [{ zone: playZone }, { type: "creature" }, { tapped: false }],
550
+ };
551
+
552
+ const result = selectCards(state, filter, registry);
553
+
554
+ expect(result).toHaveLength(1);
555
+ expect(result[0].definitionId).toBe("bear");
556
+ });
557
+
558
+ it("should not match when any sub-filter fails", () => {
559
+ const playZone = createZoneId("play");
560
+ const player1 = createPlayerId("player-1");
561
+
562
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
563
+ {
564
+ id: createCardId("card-1"),
565
+ definitionId: "bear",
566
+ owner: player1,
567
+ controller: player1,
568
+ zone: playZone,
569
+ tapped: true,
570
+ flipped: false,
571
+ revealed: false,
572
+ phased: false,
573
+ modifiers: [],
574
+ },
575
+ ];
576
+
577
+ const state: TestGameState = {
578
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
579
+ };
580
+
581
+ const filter: CardFilter = {
582
+ and: [{ type: "creature" }, { tapped: false }],
583
+ };
584
+
585
+ const result = selectCards(state, filter, registry);
586
+ expect(result).toHaveLength(0);
587
+ });
588
+ });
589
+
590
+ describe("OR filters", () => {
591
+ it("should match when any sub-filter matches", () => {
592
+ const playZone = createZoneId("play");
593
+ const handZone = createZoneId("hand");
594
+ const player1 = createPlayerId("player-1");
595
+
596
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
597
+ {
598
+ id: createCardId("card-1"),
599
+ definitionId: "bear",
600
+ owner: player1,
601
+ controller: player1,
602
+ zone: playZone,
603
+ tapped: false,
604
+ flipped: false,
605
+ revealed: false,
606
+ phased: false,
607
+ modifiers: [],
608
+ },
609
+ {
610
+ id: createCardId("card-2"),
611
+ definitionId: "bolt",
612
+ owner: player1,
613
+ controller: player1,
614
+ zone: handZone,
615
+ tapped: false,
616
+ flipped: false,
617
+ revealed: false,
618
+ phased: false,
619
+ modifiers: [],
620
+ },
621
+ {
622
+ id: createCardId("card-3"),
623
+ definitionId: "mountain",
624
+ owner: player1,
625
+ controller: player1,
626
+ zone: createZoneId("deck"),
627
+ tapped: false,
628
+ flipped: false,
629
+ revealed: false,
630
+ phased: false,
631
+ modifiers: [],
632
+ },
633
+ ];
634
+
635
+ const state: TestGameState = {
636
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
637
+ };
638
+
639
+ // Find cards that are either creatures OR instants
640
+ const filter: CardFilter = {
641
+ or: [{ type: "creature" }, { type: "instant" }],
642
+ };
643
+
644
+ const result = selectCards(state, filter, registry);
645
+
646
+ expect(result).toHaveLength(2);
647
+ expect(result.map((c) => c.definitionId).sort()).toEqual([
648
+ "bear",
649
+ "bolt",
650
+ ]);
651
+ });
652
+
653
+ it("should not match when no sub-filter matches", () => {
654
+ const player1 = createPlayerId("player-1");
655
+
656
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
657
+ {
658
+ id: createCardId("card-1"),
659
+ definitionId: "mountain",
660
+ owner: player1,
661
+ controller: player1,
662
+ zone: createZoneId("play"),
663
+ tapped: false,
664
+ flipped: false,
665
+ revealed: false,
666
+ phased: false,
667
+ modifiers: [],
668
+ },
669
+ ];
670
+
671
+ const state: TestGameState = {
672
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
673
+ };
674
+
675
+ const filter: CardFilter = {
676
+ or: [{ type: "creature" }, { type: "instant" }],
677
+ };
678
+
679
+ const result = selectCards(state, filter, registry);
680
+ expect(result).toHaveLength(0);
681
+ });
682
+ });
683
+
684
+ describe("NOT filters", () => {
685
+ it("should match when sub-filter does not match", () => {
686
+ const player1 = createPlayerId("player-1");
687
+ const playZone = createZoneId("play");
688
+
689
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
690
+ {
691
+ id: createCardId("card-1"),
692
+ definitionId: "bear",
693
+ owner: player1,
694
+ controller: player1,
695
+ zone: playZone,
696
+ tapped: false,
697
+ flipped: false,
698
+ revealed: false,
699
+ phased: false,
700
+ modifiers: [],
701
+ },
702
+ {
703
+ id: createCardId("card-2"),
704
+ definitionId: "mountain",
705
+ owner: player1,
706
+ controller: player1,
707
+ zone: playZone,
708
+ tapped: false,
709
+ flipped: false,
710
+ revealed: false,
711
+ phased: false,
712
+ modifiers: [],
713
+ },
714
+ ];
715
+
716
+ const state: TestGameState = {
717
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
718
+ };
719
+
720
+ // Find all cards in play that are NOT lands
721
+ const filter: CardFilter = {
722
+ zone: playZone,
723
+ not: { type: "land" },
724
+ };
725
+
726
+ const result = selectCards(state, filter, registry);
727
+
728
+ expect(result).toHaveLength(1);
729
+ expect(result[0].definitionId).toBe("bear");
730
+ });
731
+
732
+ it("should not match when sub-filter matches", () => {
733
+ const player1 = createPlayerId("player-1");
734
+
735
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
736
+ {
737
+ id: createCardId("card-1"),
738
+ definitionId: "bear",
739
+ owner: player1,
740
+ controller: player1,
741
+ zone: createZoneId("play"),
742
+ tapped: false,
743
+ flipped: false,
744
+ revealed: false,
745
+ phased: false,
746
+ modifiers: [],
747
+ },
748
+ ];
749
+
750
+ const state: TestGameState = {
751
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
752
+ };
753
+
754
+ const filter: CardFilter = {
755
+ not: { type: "creature" },
756
+ };
757
+
758
+ const result = selectCards(state, filter, registry);
759
+ expect(result).toHaveLength(0);
760
+ });
761
+ });
762
+
763
+ describe("Complex composite filters", () => {
764
+ it("should support nested composite filters", () => {
765
+ const playZone = createZoneId("play");
766
+ const player1 = createPlayerId("player-1");
767
+
768
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
769
+ {
770
+ id: createCardId("card-1"),
771
+ definitionId: "bear",
772
+ owner: player1,
773
+ controller: player1,
774
+ zone: playZone,
775
+ tapped: false,
776
+ flipped: false,
777
+ revealed: false,
778
+ phased: false,
779
+ modifiers: [],
780
+ },
781
+ {
782
+ id: createCardId("card-2"),
783
+ definitionId: "dragon",
784
+ owner: player1,
785
+ controller: player1,
786
+ zone: playZone,
787
+ tapped: false,
788
+ flipped: false,
789
+ revealed: false,
790
+ phased: false,
791
+ modifiers: [],
792
+ },
793
+ {
794
+ id: createCardId("card-3"),
795
+ definitionId: "bolt",
796
+ owner: player1,
797
+ controller: player1,
798
+ zone: playZone,
799
+ tapped: false,
800
+ flipped: false,
801
+ revealed: false,
802
+ phased: false,
803
+ modifiers: [],
804
+ },
805
+ ];
806
+
807
+ const state: TestGameState = {
808
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
809
+ };
810
+
811
+ // Find creatures in play with power >= 3 OR cost <= 2
812
+ const filter: CardFilter = {
813
+ and: [
814
+ { zone: playZone },
815
+ { type: "creature" },
816
+ {
817
+ or: [
818
+ { properties: { basePower: { gte: 3 } } },
819
+ { properties: { baseCost: { lte: 2 } } },
820
+ ],
821
+ },
822
+ ],
823
+ };
824
+
825
+ const result = selectCards(state, filter, registry);
826
+
827
+ // Should match both bear (cost 2) and dragon (power 5)
828
+ expect(result).toHaveLength(2);
829
+ expect(result.map((c) => c.definitionId).sort()).toEqual([
830
+ "bear",
831
+ "dragon",
832
+ ]);
833
+ });
834
+ });
835
+
836
+ describe("Custom predicate filters", () => {
837
+ it("should support custom where predicate", () => {
838
+ const player1 = createPlayerId("player-1");
839
+ const playZone = createZoneId("play");
840
+
841
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
842
+ {
843
+ id: createCardId("card-1"),
844
+ definitionId: "bear",
845
+ owner: player1,
846
+ controller: player1,
847
+ zone: playZone,
848
+ tapped: false,
849
+ flipped: false,
850
+ revealed: false,
851
+ phased: false,
852
+ modifiers: [],
853
+ },
854
+ {
855
+ id: createCardId("card-2"),
856
+ definitionId: "dragon",
857
+ owner: player1,
858
+ controller: player1,
859
+ zone: playZone,
860
+ tapped: true,
861
+ flipped: false,
862
+ revealed: false,
863
+ phased: false,
864
+ modifiers: [],
865
+ },
866
+ ];
867
+
868
+ const state: TestGameState = {
869
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
870
+ };
871
+
872
+ // Custom filter: cards with even cost
873
+ const filter: CardFilter<TestGameState> = {
874
+ where: (card, _state) => {
875
+ const definition = registry.getCard(card.definitionId);
876
+ return (
877
+ definition?.baseCost !== undefined &&
878
+ definition.baseCost % 2 === 0
879
+ );
880
+ },
881
+ };
882
+
883
+ const result = selectCards(state, filter, registry);
884
+
885
+ // Should match bear (cost 2) and dragon (cost 6)
886
+ expect(result).toHaveLength(2);
887
+ });
888
+
889
+ it("should combine where predicate with other filters", () => {
890
+ const player1 = createPlayerId("player-1");
891
+ const playZone = createZoneId("play");
892
+
893
+ const cards: CardInstance<{ modifiers: Modifier[] }>[] = [
894
+ {
895
+ id: createCardId("card-1"),
896
+ definitionId: "bear",
897
+ owner: player1,
898
+ controller: player1,
899
+ zone: playZone,
900
+ tapped: false,
901
+ flipped: false,
902
+ revealed: false,
903
+ phased: false,
904
+ modifiers: [],
905
+ },
906
+ {
907
+ id: createCardId("card-2"),
908
+ definitionId: "dragon",
909
+ owner: player1,
910
+ controller: player1,
911
+ zone: playZone,
912
+ tapped: true,
913
+ flipped: false,
914
+ revealed: false,
915
+ phased: false,
916
+ modifiers: [],
917
+ },
918
+ ];
919
+
920
+ const state: TestGameState = {
921
+ cards: Object.fromEntries(cards.map((c) => [String(c.id), c])),
922
+ };
923
+
924
+ // Custom filter combined with tapped state
925
+ const filter: CardFilter<TestGameState> = {
926
+ tapped: false,
927
+ where: (card, _state) => {
928
+ const definition = registry.getCard(card.definitionId);
929
+ return (
930
+ definition?.baseCost !== undefined &&
931
+ definition.baseCost % 2 === 0
932
+ );
933
+ },
934
+ };
935
+
936
+ const result = selectCards(state, filter, registry);
937
+
938
+ // Should match only bear (cost 2 and not tapped)
939
+ expect(result).toHaveLength(1);
940
+ expect(result[0].definitionId).toBe("bear");
941
+ });
942
+ });
943
+ });
944
+ });