@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,306 @@
1
+ import { produce } from "immer";
2
+ import seedrandom from "seedrandom";
3
+ import type { CardId } from "../types";
4
+ import type { Zone } from "./zone";
5
+
6
+ /**
7
+ * Adds a card to a zone
8
+ * @param zone - Target zone
9
+ * @param cardId - Card to add
10
+ * @param position - Optional position for ordered zones
11
+ * @returns Updated zone
12
+ * @throws Error if zone is at maximum size
13
+ */
14
+ export function addCard(zone: Zone, cardId: CardId, position?: number): Zone {
15
+ if (
16
+ zone.config.maxSize !== undefined &&
17
+ zone.cards.length >= zone.config.maxSize
18
+ ) {
19
+ throw new Error(
20
+ `Cannot add card: zone is at maximum size (${zone.config.maxSize})`,
21
+ );
22
+ }
23
+
24
+ return produce(zone, (draft) => {
25
+ if (position !== undefined) {
26
+ draft.cards.splice(position, 0, cardId);
27
+ } else {
28
+ draft.cards.push(cardId);
29
+ }
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Removes a card from a zone
35
+ * @param zone - Source zone
36
+ * @param cardId - Card to remove
37
+ * @returns Updated zone
38
+ * @throws Error if card not found in zone
39
+ */
40
+ export function removeCard(zone: Zone, cardId: CardId): Zone {
41
+ const index = zone.cards.indexOf(cardId);
42
+ if (index === -1) {
43
+ throw new Error(`Card ${cardId} not found in zone ${zone.config.id}`);
44
+ }
45
+
46
+ return produce(zone, (draft) => {
47
+ draft.cards.splice(index, 1);
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Moves a card from one zone to another
53
+ * @param fromZone - Source zone
54
+ * @param toZone - Destination zone
55
+ * @param cardId - Card to move
56
+ * @param position - Optional position in destination zone
57
+ * @returns Updated zones
58
+ */
59
+ export function moveCard(
60
+ fromZone: Zone,
61
+ toZone: Zone,
62
+ cardId: CardId,
63
+ position?: number,
64
+ ): { fromZone: Zone; toZone: Zone } {
65
+ const updatedFrom = removeCard(fromZone, cardId);
66
+ const updatedTo = addCard(toZone, cardId, position);
67
+
68
+ return {
69
+ fromZone: updatedFrom,
70
+ toZone: updatedTo,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Draws cards from deck to hand
76
+ * @param deck - Source deck zone
77
+ * @param hand - Destination hand zone
78
+ * @param count - Number of cards to draw
79
+ * @returns Updated zones and drawn cards
80
+ * @throws Error if not enough cards in deck
81
+ */
82
+ export function draw(
83
+ deck: Zone,
84
+ hand: Zone,
85
+ count: number,
86
+ ): { fromZone: Zone; toZone: Zone; drawnCards: CardId[] } {
87
+ if (deck.cards.length < count) {
88
+ throw new Error(
89
+ `Cannot draw ${count} cards: only ${deck.cards.length} available in ${deck.config.id}`,
90
+ );
91
+ }
92
+
93
+ const drawnCards = deck.cards.slice(0, count);
94
+
95
+ const newDeck = produce(deck, (draft) => {
96
+ draft.cards.splice(0, count);
97
+ });
98
+
99
+ const newHand = produce(hand, (draft) => {
100
+ draft.cards.push(...drawnCards);
101
+ });
102
+
103
+ return {
104
+ fromZone: newDeck,
105
+ toZone: newHand,
106
+ drawnCards,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Shuffles a zone using seeded RNG for deterministic replay
112
+ * @param zone - Zone to shuffle
113
+ * @param seed - Random seed for determinism
114
+ * @returns Updated zone with shuffled cards
115
+ */
116
+ export function shuffle(zone: Zone, seed: string): Zone {
117
+ const rng = seedrandom(seed);
118
+
119
+ return produce(zone, (draft) => {
120
+ // Fisher-Yates shuffle with seeded RNG
121
+ for (let i = draft.cards.length - 1; i > 0; i--) {
122
+ const j = Math.floor(rng() * (i + 1));
123
+ const cardI = draft.cards[i];
124
+ const cardJ = draft.cards[j];
125
+ if (cardI !== undefined && cardJ !== undefined) {
126
+ draft.cards[i] = cardJ;
127
+ draft.cards[j] = cardI;
128
+ }
129
+ }
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Searches a zone for cards matching a filter
135
+ * @param zone - Zone to search
136
+ * @param filter - Filter function
137
+ * @returns Array of matching card IDs
138
+ */
139
+ export function search(
140
+ zone: Zone,
141
+ filter: (cardId: CardId) => boolean,
142
+ ): CardId[] {
143
+ return zone.cards.filter(filter);
144
+ }
145
+
146
+ /**
147
+ * Peeks at the top N cards of a zone without removing them
148
+ * @param zone - Zone to peek at
149
+ * @param count - Number of cards to peek
150
+ * @returns Array of card IDs
151
+ */
152
+ export function peek(zone: Zone, count: number): CardId[] {
153
+ return zone.cards.slice(0, count);
154
+ }
155
+
156
+ /**
157
+ * Mills cards from deck to graveyard
158
+ * @param deck - Source deck zone
159
+ * @param graveyard - Destination graveyard zone
160
+ * @param count - Number of cards to mill
161
+ * @returns Updated zones and milled cards
162
+ */
163
+ export function mill(
164
+ deck: Zone,
165
+ graveyard: Zone,
166
+ count: number,
167
+ ): { fromZone: Zone; toZone: Zone; milledCards: CardId[] } {
168
+ const milledCards = deck.cards.slice(0, count);
169
+
170
+ const newDeck = produce(deck, (draft) => {
171
+ draft.cards.splice(0, count);
172
+ });
173
+
174
+ const newGraveyard = produce(graveyard, (draft) => {
175
+ draft.cards.push(...milledCards);
176
+ });
177
+
178
+ return {
179
+ fromZone: newDeck,
180
+ toZone: newGraveyard,
181
+ milledCards,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Reveals cards (makes them temporarily visible)
187
+ * @param cardIds - Cards to reveal
188
+ * @returns Array of revealed card IDs
189
+ */
190
+ export function reveal(cardIds: CardId[]): CardId[] {
191
+ return [...cardIds];
192
+ }
193
+
194
+ /**
195
+ * Gets the number of cards in a zone
196
+ * @param zone - Zone to query
197
+ * @returns Number of cards
198
+ */
199
+ export function getZoneSize(zone: Zone): number {
200
+ return zone.cards.length;
201
+ }
202
+
203
+ /**
204
+ * Gets all cards in a zone
205
+ * @param zone - Zone to query
206
+ * @returns Array of card IDs
207
+ */
208
+ export function getCardsInZone(zone: Zone): CardId[] {
209
+ return [...zone.cards];
210
+ }
211
+
212
+ /**
213
+ * Gets the top card of a zone
214
+ * @param zone - Zone to query
215
+ * @returns Top card ID or undefined if empty
216
+ */
217
+ export function getTopCard(zone: Zone): CardId | undefined {
218
+ return zone.cards[0];
219
+ }
220
+
221
+ /**
222
+ * Gets the bottom card of a zone
223
+ * @param zone - Zone to query
224
+ * @returns Bottom card ID or undefined if empty
225
+ */
226
+ export function getBottomCard(zone: Zone): CardId | undefined {
227
+ return zone.cards[zone.cards.length - 1];
228
+ }
229
+
230
+ /**
231
+ * Checks if a card is in a zone
232
+ * @param zone - Zone to check
233
+ * @param cardId - Card to look for
234
+ * @returns True if card is in zone
235
+ */
236
+ export function isCardInZone(zone: Zone, cardId: CardId): boolean {
237
+ return zone.cards.includes(cardId);
238
+ }
239
+
240
+ /**
241
+ * Adds a card to the top of a zone
242
+ * @param zone - Target zone
243
+ * @param cardId - Card to add
244
+ * @returns Updated zone
245
+ * @throws Error if zone is at maximum size
246
+ */
247
+ export function addCardToTop(zone: Zone, cardId: CardId): Zone {
248
+ if (
249
+ zone.config.maxSize !== undefined &&
250
+ zone.cards.length >= zone.config.maxSize
251
+ ) {
252
+ throw new Error(
253
+ `Cannot add card: zone is at maximum size (${zone.config.maxSize})`,
254
+ );
255
+ }
256
+
257
+ return produce(zone, (draft) => {
258
+ draft.cards.unshift(cardId);
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Adds a card to the bottom of a zone
264
+ * @param zone - Target zone
265
+ * @param cardId - Card to add
266
+ * @returns Updated zone
267
+ * @throws Error if zone is at maximum size
268
+ */
269
+ export function addCardToBottom(zone: Zone, cardId: CardId): Zone {
270
+ if (
271
+ zone.config.maxSize !== undefined &&
272
+ zone.cards.length >= zone.config.maxSize
273
+ ) {
274
+ throw new Error(
275
+ `Cannot add card: zone is at maximum size (${zone.config.maxSize})`,
276
+ );
277
+ }
278
+
279
+ return produce(zone, (draft) => {
280
+ draft.cards.push(cardId);
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Clears all cards from a zone
286
+ * @param zone - Zone to clear
287
+ * @returns Updated zone with no cards
288
+ */
289
+ export function clearZone(zone: Zone): Zone {
290
+ return produce(zone, (draft) => {
291
+ draft.cards = [];
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Finds the first zone containing a card
297
+ * @param cardId - Card to find
298
+ * @param zones - Zones to search
299
+ * @returns First zone containing the card, or undefined if not found
300
+ */
301
+ export function findCardInZones(
302
+ cardId: CardId,
303
+ zones: Zone[],
304
+ ): Zone | undefined {
305
+ return zones.find((zone) => isCardInZone(zone, cardId));
306
+ }
@@ -0,0 +1,337 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { createCardId, createPlayerId, createZoneId } from "../types";
3
+ import { createZone } from "./zone-factory";
4
+ import {
5
+ createPlayerZones,
6
+ getCardZone,
7
+ moveCardInState,
8
+ } from "./zone-state-helpers";
9
+
10
+ describe("Zone State Helpers", () => {
11
+ describe("createPlayerZones", () => {
12
+ it("should create zones for each player with factory", () => {
13
+ const p1 = createPlayerId("p1");
14
+ const p2 = createPlayerId("p2");
15
+ const players = [p1, p2];
16
+
17
+ const zones = createPlayerZones(players, () => [] as string[]);
18
+
19
+ expect(zones[p1]).toBeDefined();
20
+ expect(zones[p2]).toBeDefined();
21
+ expect(Object.keys(zones)).toHaveLength(2);
22
+ });
23
+
24
+ it("should initialize with default values", () => {
25
+ const p1 = createPlayerId("p1");
26
+ const p2 = createPlayerId("p2");
27
+ const players = [p1, p2];
28
+
29
+ const zones = createPlayerZones(players, () => []);
30
+
31
+ expect(zones[p1]).toEqual([]);
32
+ expect(zones[p2]).toEqual([]);
33
+ });
34
+
35
+ it("should initialize with custom factory function", () => {
36
+ const p1 = createPlayerId("p1");
37
+ const p2 = createPlayerId("p2");
38
+ const players = [p1, p2];
39
+
40
+ const zones = createPlayerZones(players, () => ({ count: 0 }));
41
+
42
+ expect(zones[p1]).toEqual({ count: 0 });
43
+ expect(zones[p2]).toEqual({ count: 0 });
44
+ });
45
+
46
+ it("should call factory function for each player", () => {
47
+ const players = [createPlayerId("p1"), createPlayerId("p2")];
48
+ let callCount = 0;
49
+
50
+ createPlayerZones(players, () => {
51
+ callCount++;
52
+ return [];
53
+ });
54
+
55
+ expect(callCount).toBe(2);
56
+ });
57
+
58
+ it("should handle single player", () => {
59
+ const p1 = createPlayerId("p1");
60
+ const players = [p1];
61
+
62
+ const zones = createPlayerZones(players, () => [] as string[]);
63
+
64
+ expect(Object.keys(zones)).toHaveLength(1);
65
+ expect(zones[p1]).toBeDefined();
66
+ });
67
+
68
+ it("should handle empty players array", () => {
69
+ const players: ReturnType<typeof createPlayerId>[] = [];
70
+
71
+ const zones = createPlayerZones(players, () => [] as string[]);
72
+
73
+ expect(Object.keys(zones)).toHaveLength(0);
74
+ });
75
+ });
76
+
77
+ describe("moveCardInState", () => {
78
+ it("should move card between zones in flat state", () => {
79
+ const _playerId = createPlayerId("p1");
80
+ const cardId = createCardId("card-1");
81
+
82
+ const state = {
83
+ hand: createZone(
84
+ {
85
+ id: createZoneId("hand"),
86
+ name: "Hand",
87
+ visibility: "private",
88
+ ordered: false,
89
+ },
90
+ [cardId, createCardId("card-2")],
91
+ ),
92
+ deck: createZone({
93
+ id: createZoneId("deck"),
94
+ name: "Deck",
95
+ visibility: "secret",
96
+ ordered: true,
97
+ }),
98
+ };
99
+
100
+ const newState = moveCardInState(state, "hand", "deck", cardId);
101
+
102
+ expect(newState.hand.cards).not.toContain(cardId);
103
+ expect(newState.hand.cards).toHaveLength(1);
104
+ expect(newState.deck.cards).toContain(cardId);
105
+ expect(newState.deck.cards).toHaveLength(1);
106
+ });
107
+
108
+ it("should preserve other zones in state", () => {
109
+ const cardId = createCardId("card-1");
110
+
111
+ const state = {
112
+ hand: createZone(
113
+ {
114
+ id: createZoneId("hand"),
115
+ name: "Hand",
116
+ visibility: "private",
117
+ ordered: false,
118
+ },
119
+ [cardId],
120
+ ),
121
+ deck: createZone({
122
+ id: createZoneId("deck"),
123
+ name: "Deck",
124
+ visibility: "secret",
125
+ ordered: true,
126
+ }),
127
+ graveyard: createZone(
128
+ {
129
+ id: createZoneId("graveyard"),
130
+ name: "Graveyard",
131
+ visibility: "public",
132
+ ordered: true,
133
+ },
134
+ [createCardId("card-3")],
135
+ ),
136
+ };
137
+
138
+ const newState = moveCardInState(state, "hand", "deck", cardId);
139
+
140
+ expect(newState.graveyard).toBe(state.graveyard);
141
+ expect(newState.graveyard.cards).toHaveLength(1);
142
+ });
143
+
144
+ it("should maintain immutability", () => {
145
+ const cardId = createCardId("card-1");
146
+
147
+ const state = {
148
+ hand: createZone(
149
+ {
150
+ id: createZoneId("hand"),
151
+ name: "Hand",
152
+ visibility: "private",
153
+ ordered: false,
154
+ },
155
+ [cardId],
156
+ ),
157
+ deck: createZone({
158
+ id: createZoneId("deck"),
159
+ name: "Deck",
160
+ visibility: "secret",
161
+ ordered: true,
162
+ }),
163
+ };
164
+
165
+ const originalHandCards = state.hand.cards;
166
+ const originalDeckCards = state.deck.cards;
167
+
168
+ const newState = moveCardInState(state, "hand", "deck", cardId);
169
+
170
+ expect(state.hand.cards).toBe(originalHandCards);
171
+ expect(state.deck.cards).toBe(originalDeckCards);
172
+ expect(newState.hand).not.toBe(state.hand);
173
+ expect(newState.deck).not.toBe(state.deck);
174
+ });
175
+
176
+ it("should add card with optional position", () => {
177
+ const cardId = createCardId("card-1");
178
+
179
+ const state = {
180
+ hand: createZone(
181
+ {
182
+ id: createZoneId("hand"),
183
+ name: "Hand",
184
+ visibility: "private",
185
+ ordered: false,
186
+ },
187
+ [cardId],
188
+ ),
189
+ deck: createZone(
190
+ {
191
+ id: createZoneId("deck"),
192
+ name: "Deck",
193
+ visibility: "secret",
194
+ ordered: true,
195
+ },
196
+ [createCardId("card-2")],
197
+ ),
198
+ };
199
+
200
+ const newState = moveCardInState(state, "hand", "deck", cardId, 0);
201
+
202
+ expect(newState.deck.cards[0]).toBe(cardId);
203
+ expect(newState.deck.cards[1]).toBe(createCardId("card-2"));
204
+ });
205
+ });
206
+
207
+ describe("getCardZone", () => {
208
+ it("should find card in first zone", () => {
209
+ const cardId = createCardId("card-1");
210
+
211
+ const state = {
212
+ hand: createZone(
213
+ {
214
+ id: createZoneId("hand"),
215
+ name: "Hand",
216
+ visibility: "private",
217
+ ordered: false,
218
+ },
219
+ [cardId, createCardId("card-2")],
220
+ ),
221
+ deck: createZone({
222
+ id: createZoneId("deck"),
223
+ name: "Deck",
224
+ visibility: "secret",
225
+ ordered: true,
226
+ }),
227
+ };
228
+
229
+ const zoneName = getCardZone(state, cardId);
230
+
231
+ expect(zoneName).toBe("hand");
232
+ });
233
+
234
+ it("should find card in later zone", () => {
235
+ const cardId = createCardId("card-3");
236
+
237
+ const state = {
238
+ hand: createZone(
239
+ {
240
+ id: createZoneId("hand"),
241
+ name: "Hand",
242
+ visibility: "private",
243
+ ordered: false,
244
+ },
245
+ [createCardId("card-1")],
246
+ ),
247
+ deck: createZone(
248
+ {
249
+ id: createZoneId("deck"),
250
+ name: "Deck",
251
+ visibility: "secret",
252
+ ordered: true,
253
+ },
254
+ [createCardId("card-2"), cardId],
255
+ ),
256
+ };
257
+
258
+ const zoneName = getCardZone(state, cardId);
259
+
260
+ expect(zoneName).toBe("deck");
261
+ });
262
+
263
+ it("should return undefined when card not found", () => {
264
+ const state = {
265
+ hand: createZone(
266
+ {
267
+ id: createZoneId("hand"),
268
+ name: "Hand",
269
+ visibility: "private",
270
+ ordered: false,
271
+ },
272
+ [createCardId("card-1")],
273
+ ),
274
+ deck: createZone({
275
+ id: createZoneId("deck"),
276
+ name: "Deck",
277
+ visibility: "secret",
278
+ ordered: true,
279
+ }),
280
+ };
281
+
282
+ const zoneName = getCardZone(state, createCardId("missing"));
283
+
284
+ expect(zoneName).toBeUndefined();
285
+ });
286
+
287
+ it("should return first zone if card in multiple zones", () => {
288
+ const cardId = createCardId("card-1");
289
+
290
+ const state = {
291
+ hand: createZone(
292
+ {
293
+ id: createZoneId("hand"),
294
+ name: "Hand",
295
+ visibility: "private",
296
+ ordered: false,
297
+ },
298
+ [cardId],
299
+ ),
300
+ deck: createZone(
301
+ {
302
+ id: createZoneId("deck"),
303
+ name: "Deck",
304
+ visibility: "secret",
305
+ ordered: true,
306
+ },
307
+ [cardId],
308
+ ),
309
+ };
310
+
311
+ const zoneName = getCardZone(state, cardId);
312
+
313
+ expect(zoneName).toBe("hand");
314
+ });
315
+
316
+ it("should handle empty zones", () => {
317
+ const state = {
318
+ hand: createZone({
319
+ id: createZoneId("hand"),
320
+ name: "Hand",
321
+ visibility: "private",
322
+ ordered: false,
323
+ }),
324
+ deck: createZone({
325
+ id: createZoneId("deck"),
326
+ name: "Deck",
327
+ visibility: "secret",
328
+ ordered: true,
329
+ }),
330
+ };
331
+
332
+ const zoneName = getCardZone(state, createCardId("card-1"));
333
+
334
+ expect(zoneName).toBeUndefined();
335
+ });
336
+ });
337
+ });