@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.
- package/README.md +882 -0
- package/package.json +58 -0
- package/src/__tests__/alpha-clash-engine-definition.test.ts +319 -0
- package/src/__tests__/createMockAlphaClashGame.ts +462 -0
- package/src/__tests__/createMockGrandArchiveGame.ts +373 -0
- package/src/__tests__/createMockGundamGame.ts +379 -0
- package/src/__tests__/createMockLorcanaGame.ts +328 -0
- package/src/__tests__/createMockOnePieceGame.ts +429 -0
- package/src/__tests__/createMockRiftboundGame.ts +462 -0
- package/src/__tests__/grand-archive-engine-definition.test.ts +118 -0
- package/src/__tests__/gundam-engine-definition.test.ts +110 -0
- package/src/__tests__/integration-complete-game.test.ts +508 -0
- package/src/__tests__/integration-network-sync.test.ts +469 -0
- package/src/__tests__/lorcana-engine-definition.test.ts +100 -0
- package/src/__tests__/move-enumeration.test.ts +725 -0
- package/src/__tests__/multiplayer-engine.test.ts +555 -0
- package/src/__tests__/one-piece-engine-definition.test.ts +114 -0
- package/src/__tests__/riftbound-engine-definition.test.ts +124 -0
- package/src/actions/action-definition.test.ts +201 -0
- package/src/actions/action-definition.ts +122 -0
- package/src/actions/action-timing.test.ts +490 -0
- package/src/actions/action-timing.ts +257 -0
- package/src/cards/card-definition.test.ts +268 -0
- package/src/cards/card-definition.ts +27 -0
- package/src/cards/card-instance.test.ts +422 -0
- package/src/cards/card-instance.ts +49 -0
- package/src/cards/computed-properties.test.ts +530 -0
- package/src/cards/computed-properties.ts +84 -0
- package/src/cards/conditional-modifiers.test.ts +390 -0
- package/src/cards/modifiers.test.ts +286 -0
- package/src/cards/modifiers.ts +51 -0
- package/src/engine/MULTIPLAYER.md +425 -0
- package/src/engine/__tests__/rule-engine-flow.test.ts +348 -0
- package/src/engine/__tests__/rule-engine-history.test.ts +535 -0
- package/src/engine/__tests__/rule-engine-moves.test.ts +488 -0
- package/src/engine/__tests__/rule-engine.test.ts +366 -0
- package/src/engine/index.ts +14 -0
- package/src/engine/multiplayer-engine.example.ts +571 -0
- package/src/engine/multiplayer-engine.ts +409 -0
- package/src/engine/rule-engine.test.ts +286 -0
- package/src/engine/rule-engine.ts +1539 -0
- package/src/engine/tracker-system.ts +172 -0
- package/src/examples/__tests__/coin-flip-game.test.ts +641 -0
- package/src/filtering/card-filter.test.ts +230 -0
- package/src/filtering/card-filter.ts +91 -0
- package/src/filtering/card-query.test.ts +901 -0
- package/src/filtering/card-query.ts +273 -0
- package/src/filtering/filter-matching.test.ts +944 -0
- package/src/filtering/filter-matching.ts +315 -0
- package/src/flow/SERIALIZATION.md +428 -0
- package/src/flow/__tests__/flow-definition.test.ts +427 -0
- package/src/flow/__tests__/flow-manager.test.ts +756 -0
- package/src/flow/__tests__/flow-serialization.test.ts +565 -0
- package/src/flow/flow-definition.ts +453 -0
- package/src/flow/flow-manager.ts +1044 -0
- package/src/flow/index.ts +35 -0
- package/src/game-definition/__tests__/game-definition-validation.test.ts +359 -0
- package/src/game-definition/__tests__/game-definition.test.ts +291 -0
- package/src/game-definition/__tests__/move-definitions.test.ts +328 -0
- package/src/game-definition/game-definition.ts +261 -0
- package/src/game-definition/index.ts +28 -0
- package/src/game-definition/move-definitions.ts +188 -0
- package/src/game-definition/validation.ts +183 -0
- package/src/history/history-manager.test.ts +497 -0
- package/src/history/history-manager.ts +312 -0
- package/src/history/history-operations.ts +122 -0
- package/src/history/index.ts +9 -0
- package/src/history/types.ts +255 -0
- package/src/index.ts +32 -0
- package/src/logging/index.ts +27 -0
- package/src/logging/log-formatter.ts +187 -0
- package/src/logging/logger.ts +276 -0
- package/src/logging/types.ts +148 -0
- package/src/moves/create-move.test.ts +331 -0
- package/src/moves/create-move.ts +64 -0
- package/src/moves/move-enumeration.ts +228 -0
- package/src/moves/move-executor.test.ts +431 -0
- package/src/moves/move-executor.ts +195 -0
- package/src/moves/move-system.test.ts +380 -0
- package/src/moves/move-system.ts +463 -0
- package/src/moves/standard-moves.ts +231 -0
- package/src/operations/card-operations.test.ts +236 -0
- package/src/operations/card-operations.ts +116 -0
- package/src/operations/card-registry-impl.test.ts +251 -0
- package/src/operations/card-registry-impl.ts +70 -0
- package/src/operations/card-registry.test.ts +234 -0
- package/src/operations/card-registry.ts +106 -0
- package/src/operations/counter-operations.ts +152 -0
- package/src/operations/game-operations.test.ts +280 -0
- package/src/operations/game-operations.ts +140 -0
- package/src/operations/index.ts +24 -0
- package/src/operations/operations-impl.test.ts +354 -0
- package/src/operations/operations-impl.ts +468 -0
- package/src/operations/zone-operations.test.ts +295 -0
- package/src/operations/zone-operations.ts +223 -0
- package/src/rng/seeded-rng.test.ts +339 -0
- package/src/rng/seeded-rng.ts +123 -0
- package/src/targeting/index.ts +48 -0
- package/src/targeting/target-definition.test.ts +273 -0
- package/src/targeting/target-definition.ts +37 -0
- package/src/targeting/target-dsl.ts +279 -0
- package/src/targeting/target-resolver.ts +486 -0
- package/src/targeting/target-validation.test.ts +994 -0
- package/src/targeting/target-validation.ts +286 -0
- package/src/telemetry/events.ts +202 -0
- package/src/telemetry/index.ts +21 -0
- package/src/telemetry/telemetry-manager.ts +127 -0
- package/src/telemetry/types.ts +68 -0
- package/src/testing/__tests__/testing-utilities-integration.test.ts +161 -0
- package/src/testing/index.ts +88 -0
- package/src/testing/test-assertions.test.ts +341 -0
- package/src/testing/test-assertions.ts +256 -0
- package/src/testing/test-card-factory.test.ts +228 -0
- package/src/testing/test-card-factory.ts +111 -0
- package/src/testing/test-context-factory.ts +187 -0
- package/src/testing/test-end-assertions.test.ts +262 -0
- package/src/testing/test-end-assertions.ts +95 -0
- package/src/testing/test-engine-builder.test.ts +389 -0
- package/src/testing/test-engine-builder.ts +46 -0
- package/src/testing/test-flow-assertions.test.ts +284 -0
- package/src/testing/test-flow-assertions.ts +115 -0
- package/src/testing/test-player-builder.test.ts +132 -0
- package/src/testing/test-player-builder.ts +46 -0
- package/src/testing/test-replay-assertions.test.ts +356 -0
- package/src/testing/test-replay-assertions.ts +164 -0
- package/src/testing/test-rng-helpers.test.ts +260 -0
- package/src/testing/test-rng-helpers.ts +190 -0
- package/src/testing/test-state-builder.test.ts +373 -0
- package/src/testing/test-state-builder.ts +99 -0
- package/src/testing/test-zone-factory.test.ts +295 -0
- package/src/testing/test-zone-factory.ts +224 -0
- package/src/types/branded-utils.ts +54 -0
- package/src/types/branded.test.ts +175 -0
- package/src/types/branded.ts +33 -0
- package/src/types/index.ts +8 -0
- package/src/types/state.test.ts +198 -0
- package/src/types/state.ts +154 -0
- package/src/validation/card-type-guards.test.ts +242 -0
- package/src/validation/card-type-guards.ts +179 -0
- package/src/validation/index.ts +40 -0
- package/src/validation/schema-builders.test.ts +403 -0
- package/src/validation/schema-builders.ts +345 -0
- package/src/validation/type-guard-builder.test.ts +216 -0
- package/src/validation/type-guard-builder.ts +109 -0
- package/src/validation/validator-builder.test.ts +375 -0
- package/src/validation/validator-builder.ts +273 -0
- package/src/zones/index.ts +28 -0
- package/src/zones/zone-factory.test.ts +183 -0
- package/src/zones/zone-factory.ts +44 -0
- package/src/zones/zone-operations.test.ts +800 -0
- package/src/zones/zone-operations.ts +306 -0
- package/src/zones/zone-state-helpers.test.ts +337 -0
- package/src/zones/zone-state-helpers.ts +128 -0
- package/src/zones/zone-visibility.test.ts +156 -0
- package/src/zones/zone-visibility.ts +36 -0
- package/src/zones/zone.test.ts +186 -0
- 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
|
+
});
|