@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,409 @@
1
+ import type { Patch } from "immer";
2
+ import type {
3
+ GameDefinition,
4
+ Player,
5
+ } from "../game-definition/game-definition";
6
+ import type { MoveContext, MoveContextInput } from "../moves/move-system";
7
+ import type { PlayerId } from "../types/branded";
8
+ import type { MoveExecutionResult, RuleEngineOptions } from "./rule-engine";
9
+ import { RuleEngine } from "./rule-engine";
10
+
11
+ /**
12
+ * Multiplayer Engine Mode
13
+ */
14
+ export type MultiplayerMode = "server" | "client";
15
+
16
+ /**
17
+ * Client State Tracking
18
+ */
19
+ export type ClientState = {
20
+ clientId: string;
21
+ lastSyncedIndex: number;
22
+ connected: boolean;
23
+ };
24
+
25
+ /**
26
+ * Patch Broadcast Event
27
+ */
28
+ export type PatchBroadcast = {
29
+ patches: Patch[];
30
+ inversePatches: Patch[];
31
+ historyIndex: number;
32
+ moveId: string;
33
+ context: MoveContext;
34
+ };
35
+
36
+ /**
37
+ * Multiplayer Engine Options
38
+ */
39
+ export type MultiplayerEngineOptions = RuleEngineOptions & {
40
+ mode: MultiplayerMode;
41
+ /** Callback when server generates patches to broadcast */
42
+ onPatchBroadcast?: (broadcast: PatchBroadcast) => void;
43
+ /** Callback when client applies patches (for logging/debugging) */
44
+ onPatchesApplied?: (patches: Patch[]) => void;
45
+ /** Callback when move is rejected (for client feedback) */
46
+ onMoveRejected?: (moveId: string, error: string, errorCode?: string) => void;
47
+ };
48
+
49
+ /**
50
+ * MultiplayerEngine - Network-aware game engine wrapper
51
+ *
52
+ * Encapsulates multiplayer patterns for server-authoritative gameplay:
53
+ * - Server mode: Executes moves, generates patches, broadcasts to clients
54
+ * - Client mode: Applies patches from server, maintains synced state
55
+ * - Reconnection support: Batch patch application for catching up
56
+ * - Client tracking: Monitors which clients are synced
57
+ *
58
+ * @example Server Setup
59
+ * ```typescript
60
+ * const server = new MultiplayerEngine(gameDefinition, players, {
61
+ * mode: "server",
62
+ * seed: "server-seed-123",
63
+ * onPatchBroadcast: (broadcast) => {
64
+ * // Send patches to all connected clients
65
+ * websocket.broadcast(broadcast.patches);
66
+ * }
67
+ * });
68
+ *
69
+ * // Execute move (only on server)
70
+ * const result = server.executeMove("playCard", {
71
+ * playerId: "p1",
72
+ * data: { cardId: "card-123" }
73
+ * });
74
+ * // Patches automatically broadcast via callback
75
+ * ```
76
+ *
77
+ * @example Client Setup
78
+ * ```typescript
79
+ * const client = new MultiplayerEngine(gameDefinition, players, {
80
+ * mode: "client",
81
+ * onPatchesApplied: (patches) => {
82
+ * console.log("State synced:", patches.length, "patches");
83
+ * }
84
+ * });
85
+ *
86
+ * // Receive patches from server
87
+ * websocket.on("patches", (patches) => {
88
+ * client.applyServerPatches(patches);
89
+ * });
90
+ * ```
91
+ *
92
+ * @example Reconnection
93
+ * ```typescript
94
+ * // Client reconnects after disconnect
95
+ * const catchupPatches = server.getCatchupPatches(lastKnownIndex);
96
+ * client.applyServerPatches(catchupPatches);
97
+ * ```
98
+ */
99
+ export class MultiplayerEngine<TState, TMoves extends Record<string, any>> {
100
+ private engine: RuleEngine<TState, TMoves>;
101
+ private mode: MultiplayerMode;
102
+ private readonly options: MultiplayerEngineOptions;
103
+ private clients: Map<string, ClientState> = new Map();
104
+
105
+ constructor(
106
+ gameDefinition: GameDefinition<TState, TMoves>,
107
+ players: Player[],
108
+ options: MultiplayerEngineOptions,
109
+ ) {
110
+ this.mode = options.mode;
111
+ this.options = options;
112
+
113
+ // Create underlying RuleEngine
114
+ this.engine = new RuleEngine(gameDefinition, players, {
115
+ seed: options.seed,
116
+ initialPatches: options.initialPatches,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Execute move (Server only)
122
+ *
123
+ * Executes a move on the authoritative server engine.
124
+ * On success, automatically broadcasts patches via callback.
125
+ *
126
+ * @param moveId - Move to execute
127
+ * @param context - Move context
128
+ * @returns Move execution result
129
+ * @throws Error if called on client
130
+ */
131
+ executeMove(
132
+ moveId: string,
133
+ contextInput: MoveContextInput,
134
+ ): MoveExecutionResult {
135
+ if (this.mode !== "server") {
136
+ throw new Error("Only server can execute moves");
137
+ }
138
+
139
+ const result = this.engine.executeMove(moveId, contextInput);
140
+
141
+ // Handle failure case
142
+ if (result.success === false) {
143
+ // Notify about rejected move
144
+ if (this.options.onMoveRejected) {
145
+ this.options.onMoveRejected(moveId, result.error, result.errorCode);
146
+ }
147
+ return result;
148
+ }
149
+
150
+ // Handle success case - broadcast patches to clients
151
+ if (this.options.onPatchBroadcast) {
152
+ const historyIndex = this.engine.getHistory().length - 1;
153
+ this.options.onPatchBroadcast({
154
+ patches: result.patches,
155
+ inversePatches: result.inversePatches,
156
+ historyIndex,
157
+ moveId,
158
+ context: contextInput as MoveContext,
159
+ });
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Apply patches from server (Client only)
167
+ *
168
+ * Applies patches received from the authoritative server.
169
+ * Used for incremental state synchronization.
170
+ *
171
+ * @param patches - Patches from server
172
+ * @throws Error if called on server
173
+ */
174
+ applyServerPatches(patches: Patch[]): void {
175
+ if (this.mode !== "client") {
176
+ throw new Error("Only clients can apply server patches");
177
+ }
178
+
179
+ this.engine.applyPatches(patches);
180
+
181
+ if (this.options.onPatchesApplied) {
182
+ this.options.onPatchesApplied(patches);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Get catchup patches for reconnecting client (Server only)
188
+ *
189
+ * Returns all patches since a given history index.
190
+ * Used when clients reconnect and need to catch up.
191
+ *
192
+ * @param sinceIndex - History index to start from (default: 0)
193
+ * @returns Array of patches to apply
194
+ * @throws Error if called on client
195
+ */
196
+ getCatchupPatches(sinceIndex = 0): Patch[] {
197
+ if (this.mode !== "server") {
198
+ throw new Error("Only server can provide catchup patches");
199
+ }
200
+
201
+ return this.engine.getPatches(sinceIndex);
202
+ }
203
+
204
+ /**
205
+ * Register client (Server only)
206
+ *
207
+ * Track a connected client for patch synchronization.
208
+ *
209
+ * @param clientId - Unique client identifier
210
+ * @param lastSyncedIndex - Last history index client has (default: -1 for new clients)
211
+ */
212
+ registerClient(clientId: string, lastSyncedIndex = -1): void {
213
+ if (this.mode !== "server") {
214
+ throw new Error("Only server can register clients");
215
+ }
216
+
217
+ this.clients.set(clientId, {
218
+ clientId,
219
+ lastSyncedIndex,
220
+ connected: true,
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Unregister client (Server only)
226
+ *
227
+ * Mark client as disconnected but preserve sync state for reconnection.
228
+ *
229
+ * @param clientId - Client identifier
230
+ */
231
+ unregisterClient(clientId: string): void {
232
+ if (this.mode !== "server") {
233
+ throw new Error("Only server can unregister clients");
234
+ }
235
+
236
+ const client = this.clients.get(clientId);
237
+ if (client) {
238
+ client.connected = false;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Update client sync index (Server only)
244
+ *
245
+ * Track which patches a client has applied.
246
+ *
247
+ * @param clientId - Client identifier
248
+ * @param historyIndex - Latest history index client has
249
+ */
250
+ updateClientSyncIndex(clientId: string, historyIndex: number): void {
251
+ if (this.mode !== "server") {
252
+ throw new Error("Only server can update client sync");
253
+ }
254
+
255
+ const client = this.clients.get(clientId);
256
+ if (client) {
257
+ client.lastSyncedIndex = historyIndex;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Get client state (Server only)
263
+ *
264
+ * Get synchronization state for a specific client.
265
+ *
266
+ * @param clientId - Client identifier
267
+ * @returns Client state or undefined
268
+ */
269
+ getClientState(clientId: string): ClientState | undefined {
270
+ if (this.mode !== "server") {
271
+ throw new Error("Only server can get client state");
272
+ }
273
+
274
+ return this.clients.get(clientId);
275
+ }
276
+
277
+ /**
278
+ * Get all clients (Server only)
279
+ *
280
+ * Get all registered clients and their sync states.
281
+ *
282
+ * @returns Array of client states
283
+ */
284
+ getAllClients(): ClientState[] {
285
+ if (this.mode !== "server") {
286
+ throw new Error("Only server can get all clients");
287
+ }
288
+
289
+ return Array.from(this.clients.values());
290
+ }
291
+
292
+ /**
293
+ * Get current game state
294
+ *
295
+ * Returns immutable snapshot of current state.
296
+ * Available on both server and client.
297
+ *
298
+ * @returns Current game state
299
+ */
300
+ getState(): TState {
301
+ return this.engine.getState();
302
+ }
303
+
304
+ /**
305
+ * Get player-specific view of game state
306
+ *
307
+ * Applies playerView filter to hide private information.
308
+ * Available on both server and client.
309
+ *
310
+ * @param playerId - Player requesting the view
311
+ * @returns Filtered state for this player
312
+ */
313
+ getPlayerView(playerId: string): TState {
314
+ return this.engine.getPlayerView(playerId);
315
+ }
316
+
317
+ /**
318
+ * Check if a move can be executed
319
+ *
320
+ * Validates move without executing it.
321
+ * Available on both server and client (for UI state).
322
+ *
323
+ * @param moveId - Move to check
324
+ * @param context - Move context
325
+ * @returns True if move can be executed
326
+ */
327
+ canExecuteMove(moveId: string, contextInput: MoveContextInput): boolean {
328
+ return this.engine.canExecuteMove(moveId, contextInput);
329
+ }
330
+
331
+ /**
332
+ * Get valid moves for a player
333
+ *
334
+ * Returns list of moves that pass their conditions.
335
+ * Available on both server and client.
336
+ *
337
+ * @param playerId - Player to get moves for
338
+ * @returns Array of valid move IDs
339
+ */
340
+ getValidMoves(playerId: PlayerId): string[] {
341
+ return this.engine.getValidMoves(playerId);
342
+ }
343
+
344
+ /**
345
+ * Check if game has ended
346
+ *
347
+ * Checks game end condition.
348
+ * Available on both server and client.
349
+ *
350
+ * @returns Game end result if ended, undefined otherwise
351
+ */
352
+ checkGameEnd() {
353
+ return this.engine.checkGameEnd();
354
+ }
355
+
356
+ /**
357
+ * Get game history (Server only)
358
+ *
359
+ * Returns full move history.
360
+ *
361
+ * @returns Array of history entries
362
+ */
363
+ getHistory() {
364
+ if (this.mode !== "server") {
365
+ throw new Error("Only server maintains authoritative history");
366
+ }
367
+
368
+ return this.engine.getHistory();
369
+ }
370
+
371
+ /**
372
+ * Get current history index (Server only)
373
+ *
374
+ * Returns the current position in history.
375
+ * Useful for clients to know their sync position.
376
+ *
377
+ * @returns Current history index
378
+ */
379
+ getCurrentHistoryIndex(): number {
380
+ if (this.mode !== "server") {
381
+ throw new Error("Only server maintains authoritative history");
382
+ }
383
+
384
+ return this.engine.getHistory().length - 1;
385
+ }
386
+
387
+ /**
388
+ * Get underlying RuleEngine (Advanced usage)
389
+ *
390
+ * Provides access to the underlying engine for advanced features.
391
+ * Use with caution - direct engine access bypasses multiplayer safeguards.
392
+ *
393
+ * @returns Underlying RuleEngine instance
394
+ */
395
+ getEngine(): RuleEngine<TState, TMoves> {
396
+ return this.engine;
397
+ }
398
+
399
+ /**
400
+ * Get multiplayer mode
401
+ *
402
+ * Returns whether this engine is operating as server or client.
403
+ *
404
+ * @returns Multiplayer mode
405
+ */
406
+ getMode(): MultiplayerMode {
407
+ return this.mode;
408
+ }
409
+ }
@@ -0,0 +1,286 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type {
3
+ GameDefinition,
4
+ Player,
5
+ } from "../game-definition/game-definition";
6
+ import type { CardId, PlayerId, ZoneId } from "../types";
7
+ import type { CardZoneConfig } from "../zones";
8
+ import { RuleEngine } from "./rule-engine";
9
+
10
+ describe("RuleEngine - Operations Integration", () => {
11
+ type TestCardDef = {
12
+ id: string;
13
+ name: string;
14
+ cost: number;
15
+ };
16
+
17
+ type TestCardMeta = {
18
+ damage?: number;
19
+ exerted?: boolean;
20
+ };
21
+
22
+ type TestState = {
23
+ players: Player[];
24
+ currentPlayer: number;
25
+ resources: Record<string, number>;
26
+ };
27
+
28
+ type TestMoves = {
29
+ playCard: { cardId: string };
30
+ draw: {};
31
+ };
32
+
33
+ const createTestGameDefinition = (): GameDefinition<
34
+ TestState,
35
+ TestMoves,
36
+ TestCardDef,
37
+ TestCardMeta
38
+ > => {
39
+ const handZone: CardZoneConfig = {
40
+ id: "hand" as ZoneId,
41
+ name: "Hand",
42
+ visibility: "private",
43
+ ordered: false,
44
+ };
45
+
46
+ const deckZone: CardZoneConfig = {
47
+ id: "deck" as ZoneId,
48
+ name: "Deck",
49
+ visibility: "secret",
50
+ ordered: true,
51
+ };
52
+
53
+ const playZone: CardZoneConfig = {
54
+ id: "play" as ZoneId,
55
+ name: "Play Area",
56
+ visibility: "public",
57
+ ordered: false,
58
+ };
59
+
60
+ return {
61
+ name: "Test Card Game",
62
+ zones: {
63
+ hand: handZone,
64
+ deck: deckZone,
65
+ play: playZone,
66
+ },
67
+ cards: {
68
+ "monster-1": { id: "monster-1", name: "Monster 1", cost: 3 },
69
+ "monster-2": { id: "monster-2", name: "Monster 2", cost: 5 },
70
+ },
71
+ setup: (players: Player[]) => ({
72
+ players,
73
+ currentPlayer: 0,
74
+ resources: {
75
+ [players[0].id]: 10,
76
+ [players[1].id]: 10,
77
+ },
78
+ }),
79
+ moves: {
80
+ playCard: {
81
+ condition: (state, context) => {
82
+ const playerId = context.playerId as string;
83
+ const cardId = context.params?.cardId as CardId;
84
+
85
+ // Check zones API is available
86
+ if (!context.zones) {
87
+ return false;
88
+ }
89
+
90
+ // Check card is in player's hand
91
+ const handCards = context.zones.getCardsInZone(
92
+ "hand" as ZoneId,
93
+ playerId as unknown as PlayerId,
94
+ );
95
+ if (!handCards.includes(cardId)) {
96
+ return false;
97
+ }
98
+
99
+ return true;
100
+ },
101
+ reducer: (draft, context) => {
102
+ const cardId = context.params?.cardId as CardId;
103
+
104
+ // Operations should be available in reducer
105
+ if (!(context.zones && context.cards)) {
106
+ throw new Error("Operations API not available");
107
+ }
108
+
109
+ // Move card from hand to play
110
+ context.zones.moveCard({
111
+ cardId,
112
+ targetZoneId: "play" as ZoneId,
113
+ });
114
+
115
+ // Set initial metadata
116
+ context.cards.setCardMeta(cardId, {
117
+ damage: 0,
118
+ exerted: false,
119
+ });
120
+ },
121
+ },
122
+ draw: {
123
+ reducer: (draft, context) => {
124
+ const playerId = context.playerId;
125
+
126
+ // Operations should be available in reducer
127
+ if (!context.zones) {
128
+ throw new Error("Zones API not available");
129
+ }
130
+
131
+ // Get top card from player's deck
132
+ const deckCards = context.zones.getCardsInZone(
133
+ "deck" as ZoneId,
134
+ playerId as unknown as PlayerId,
135
+ );
136
+
137
+ if (deckCards.length > 0) {
138
+ const topCard = deckCards[0];
139
+
140
+ // Move to hand
141
+ context.zones.moveCard({
142
+ cardId: topCard,
143
+ targetZoneId: "hand" as ZoneId,
144
+ });
145
+ }
146
+ },
147
+ },
148
+ },
149
+ };
150
+ };
151
+
152
+ describe("Operations API in Move Context", () => {
153
+ it("should provide zone operations to move reducers", () => {
154
+ const players: Player[] = [
155
+ { id: "player-1", name: "Player 1" },
156
+ { id: "player-2", name: "Player 2" },
157
+ ];
158
+
159
+ const gameDef = createTestGameDefinition();
160
+ const engine = new RuleEngine(gameDef, players);
161
+
162
+ // Execute draw move - even with empty deck, it should succeed
163
+ // (the move just won't do anything)
164
+ const result = engine.executeMove("draw", {
165
+ playerId: "player-1" as unknown as PlayerId,
166
+ params: {},
167
+ });
168
+
169
+ // The move should execute successfully even with no cards
170
+ expect(result.success).toBe(true);
171
+ if (result.success) {
172
+ expect(Array.isArray(result.patches)).toBe(true);
173
+ }
174
+ });
175
+
176
+ it("should provide card operations to move reducers", () => {
177
+ const players: Player[] = [
178
+ { id: "player-1", name: "Player 1" },
179
+ { id: "player-2", name: "Player 2" },
180
+ ];
181
+
182
+ const gameDef = createTestGameDefinition();
183
+ const engine = new RuleEngine(gameDef, players);
184
+
185
+ // Execute playCard move - this should use card operations
186
+ const result = engine.executeMove("playCard", {
187
+ playerId: "player-1" as unknown as PlayerId,
188
+ params: { cardId: "card-1" },
189
+ });
190
+
191
+ // Will fail initially since we haven't populated cards, but tests the API
192
+ expect(result.success).toBeDefined();
193
+ });
194
+
195
+ it("should provide operations to move conditions", () => {
196
+ const players: Player[] = [
197
+ { id: "player-1", name: "Player 1" },
198
+ { id: "player-2", name: "Player 2" },
199
+ ];
200
+
201
+ const gameDef = createTestGameDefinition();
202
+ const engine = new RuleEngine(gameDef, players);
203
+
204
+ // canExecuteMove should have access to operations
205
+ const canPlay = engine.canExecuteMove("playCard", {
206
+ playerId: "player-1" as unknown as PlayerId,
207
+ params: { cardId: "card-1" },
208
+ });
209
+
210
+ expect(typeof canPlay).toBe("boolean");
211
+ });
212
+ });
213
+
214
+ describe("Internal State Management", () => {
215
+ it("should initialize zones from game definition", () => {
216
+ const players: Player[] = [
217
+ { id: "player-1", name: "Player 1" },
218
+ { id: "player-2", name: "Player 2" },
219
+ ];
220
+
221
+ const gameDef = createTestGameDefinition();
222
+ const engine = new RuleEngine(gameDef, players);
223
+
224
+ // Engine should initialize with zones from definition
225
+ // We can verify this by executing a move that uses zones
226
+ const result = engine.executeMove("draw", {
227
+ playerId: "player-1" as unknown as PlayerId,
228
+ params: {},
229
+ });
230
+
231
+ // Move should execute successfully (zones are accessible)
232
+ expect(result.success).toBe(true);
233
+ });
234
+
235
+ it("should track card movements through operations", () => {
236
+ const players: Player[] = [
237
+ { id: "player-1", name: "Player 1" },
238
+ { id: "player-2", name: "Player 2" },
239
+ ];
240
+
241
+ const gameDef = createTestGameDefinition();
242
+ const engine = new RuleEngine(gameDef, players);
243
+
244
+ // Execute draw multiple times
245
+ engine.executeMove("draw", {
246
+ playerId: "player-1" as unknown as PlayerId,
247
+ params: {},
248
+ });
249
+
250
+ engine.executeMove("draw", {
251
+ playerId: "player-1" as unknown as PlayerId,
252
+ params: {},
253
+ });
254
+
255
+ // Internal state should be tracking card movements
256
+ // This will be verified through move conditions and game state
257
+ const state = engine.getState();
258
+ expect(state.players).toHaveLength(2);
259
+ });
260
+ });
261
+
262
+ describe("Patch Generation with Operations", () => {
263
+ it("should generate patches when operations modify internal state", () => {
264
+ const players: Player[] = [
265
+ { id: "player-1", name: "Player 1" },
266
+ { id: "player-2", name: "Player 2" },
267
+ ];
268
+
269
+ const gameDef = createTestGameDefinition();
270
+ const engine = new RuleEngine(gameDef, players);
271
+
272
+ const result = engine.executeMove("draw", {
273
+ playerId: "player-1" as unknown as PlayerId,
274
+ params: {},
275
+ });
276
+
277
+ // Move should execute successfully
278
+ expect(result.success).toBe(true);
279
+ if (result.success) {
280
+ // Patches should be generated for state changes
281
+ expect(Array.isArray(result.patches)).toBe(true);
282
+ expect(Array.isArray(result.inversePatches)).toBe(true);
283
+ }
284
+ });
285
+ });
286
+ });