@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,469 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { RuleEngine } from "../engine/rule-engine";
|
|
3
|
+
import type { GameDefinition } from "../game-definition/game-definition";
|
|
4
|
+
import type { GameMoveDefinitions } from "../game-definition/move-definitions";
|
|
5
|
+
import { createPlayerId } from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Task 16.1, 16.2: Integration Tests - Server-Authoritative Pattern
|
|
9
|
+
*
|
|
10
|
+
* Tests the complete network synchronization pattern:
|
|
11
|
+
* - Server receives move from client
|
|
12
|
+
* - Server validates and executes move
|
|
13
|
+
* - Server broadcasts patches to all clients
|
|
14
|
+
* - Clients apply patches to sync state
|
|
15
|
+
*
|
|
16
|
+
* This validates that the patch-based synchronization enables
|
|
17
|
+
* authoritative multiplayer gameplay.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type MultiplayerGameState = {
|
|
21
|
+
players: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
hand: string[];
|
|
25
|
+
score: number;
|
|
26
|
+
}>;
|
|
27
|
+
currentPlayerIndex: number;
|
|
28
|
+
deck: string[];
|
|
29
|
+
turnNumber: number;
|
|
30
|
+
phase: "draw" | "play" | "ended";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type MultiplayerMoves = {
|
|
34
|
+
drawCard: Record<string, never>;
|
|
35
|
+
playCard: { cardId: string };
|
|
36
|
+
endTurn: Record<string, never>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("Integration - Network Synchronization", () => {
|
|
40
|
+
describe("Task 16.1: Server-Authoritative Pattern", () => {
|
|
41
|
+
it("should execute move on server and broadcast patches to clients", () => {
|
|
42
|
+
const moves: GameMoveDefinitions<MultiplayerGameState, MultiplayerMoves> =
|
|
43
|
+
{
|
|
44
|
+
drawCard: {
|
|
45
|
+
reducer: (draft) => {
|
|
46
|
+
const card = draft.deck.pop();
|
|
47
|
+
if (card) {
|
|
48
|
+
const player = draft.players[draft.currentPlayerIndex];
|
|
49
|
+
if (player) {
|
|
50
|
+
player.hand.push(card);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
playCard: {
|
|
56
|
+
reducer: (draft, context) => {
|
|
57
|
+
const player = draft.players[draft.currentPlayerIndex];
|
|
58
|
+
if (player && context.params?.cardId) {
|
|
59
|
+
const cardId = context.params.cardId as string;
|
|
60
|
+
const cardIndex = player.hand.indexOf(cardId);
|
|
61
|
+
if (cardIndex >= 0) {
|
|
62
|
+
player.hand.splice(cardIndex, 1);
|
|
63
|
+
player.score += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
endTurn: {
|
|
69
|
+
reducer: (draft) => {
|
|
70
|
+
draft.currentPlayerIndex =
|
|
71
|
+
(draft.currentPlayerIndex + 1) % draft.players.length;
|
|
72
|
+
draft.turnNumber += 1;
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const gameDefinition: GameDefinition<
|
|
78
|
+
MultiplayerGameState,
|
|
79
|
+
MultiplayerMoves
|
|
80
|
+
> = {
|
|
81
|
+
name: "Multiplayer Test Game",
|
|
82
|
+
setup: (players) => ({
|
|
83
|
+
players: players.map((p) => ({
|
|
84
|
+
id: p.id,
|
|
85
|
+
name: p.name || "Player",
|
|
86
|
+
hand: [],
|
|
87
|
+
score: 0,
|
|
88
|
+
})),
|
|
89
|
+
currentPlayerIndex: 0,
|
|
90
|
+
deck: ["card1", "card2", "card3", "card4", "card5"],
|
|
91
|
+
turnNumber: 1,
|
|
92
|
+
phase: "draw",
|
|
93
|
+
}),
|
|
94
|
+
moves,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const players = [
|
|
98
|
+
{ id: createPlayerId("p1"), name: "Alice" },
|
|
99
|
+
{ id: createPlayerId("p2"), name: "Bob" },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// Server creates authoritative engine
|
|
103
|
+
const server = new RuleEngine(gameDefinition, players, {
|
|
104
|
+
seed: "server-123",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Clients create local engines (synchronized via patches)
|
|
108
|
+
const client1 = new RuleEngine(gameDefinition, players, {
|
|
109
|
+
seed: "client-1",
|
|
110
|
+
});
|
|
111
|
+
const client2 = new RuleEngine(gameDefinition, players, {
|
|
112
|
+
seed: "client-2",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Initial state should match
|
|
116
|
+
expect(server.getState()).toEqual(client1.getState());
|
|
117
|
+
expect(server.getState()).toEqual(client2.getState());
|
|
118
|
+
|
|
119
|
+
// Client 1 sends move to server
|
|
120
|
+
const moveContext = {
|
|
121
|
+
playerId: createPlayerId("p1"),
|
|
122
|
+
params: {},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Server executes move
|
|
126
|
+
const result = server.executeMove("drawCard", moveContext);
|
|
127
|
+
|
|
128
|
+
expect(result.success).toBe(true);
|
|
129
|
+
if (result.success) {
|
|
130
|
+
// Server broadcasts patches to all clients
|
|
131
|
+
const patches = result.patches;
|
|
132
|
+
|
|
133
|
+
// Clients apply patches to synchronize
|
|
134
|
+
client1.applyPatches(patches);
|
|
135
|
+
client2.applyPatches(patches);
|
|
136
|
+
|
|
137
|
+
// All states should now match
|
|
138
|
+
const serverState = server.getState();
|
|
139
|
+
const client1State = client1.getState();
|
|
140
|
+
const client2State = client2.getState();
|
|
141
|
+
|
|
142
|
+
expect(serverState).toEqual(client1State);
|
|
143
|
+
expect(serverState).toEqual(client2State);
|
|
144
|
+
|
|
145
|
+
// Verify move actually executed
|
|
146
|
+
expect(serverState.players[0]?.hand.length).toBe(1);
|
|
147
|
+
expect(serverState.deck.length).toBe(4);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should reject invalid moves on server before broadcasting", () => {
|
|
152
|
+
const moves: GameMoveDefinitions<MultiplayerGameState, MultiplayerMoves> =
|
|
153
|
+
{
|
|
154
|
+
drawCard: {
|
|
155
|
+
condition: (state) => state.phase === "draw",
|
|
156
|
+
reducer: (draft) => {
|
|
157
|
+
const card = draft.deck.pop();
|
|
158
|
+
if (card) {
|
|
159
|
+
const player = draft.players[draft.currentPlayerIndex];
|
|
160
|
+
if (player) {
|
|
161
|
+
player.hand.push(card);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
playCard: { reducer: () => {} },
|
|
167
|
+
endTurn: { reducer: () => {} },
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const gameDefinition: GameDefinition<
|
|
171
|
+
MultiplayerGameState,
|
|
172
|
+
MultiplayerMoves
|
|
173
|
+
> = {
|
|
174
|
+
name: "Validation Test",
|
|
175
|
+
setup: (players) => ({
|
|
176
|
+
players: players.map((p) => ({
|
|
177
|
+
id: p.id,
|
|
178
|
+
name: p.name || "Player",
|
|
179
|
+
hand: [],
|
|
180
|
+
score: 0,
|
|
181
|
+
})),
|
|
182
|
+
currentPlayerIndex: 0,
|
|
183
|
+
deck: ["card1"],
|
|
184
|
+
turnNumber: 1,
|
|
185
|
+
phase: "play", // Not "draw" phase
|
|
186
|
+
}),
|
|
187
|
+
moves,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const players = [{ id: createPlayerId("p1"), name: "Alice" }];
|
|
191
|
+
|
|
192
|
+
const server = new RuleEngine(gameDefinition, players);
|
|
193
|
+
const client = new RuleEngine(gameDefinition, players);
|
|
194
|
+
|
|
195
|
+
// Client attempts invalid move
|
|
196
|
+
const result = server.executeMove("drawCard", {
|
|
197
|
+
playerId: createPlayerId("p1"),
|
|
198
|
+
params: {},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Server rejects move
|
|
202
|
+
expect(result.success).toBe(false);
|
|
203
|
+
|
|
204
|
+
// No patches to broadcast - invalid moves don't have patches
|
|
205
|
+
// Type guard: when success is false, patches property doesn't exist
|
|
206
|
+
if (result.success === false) {
|
|
207
|
+
// patches property doesn't exist on error result
|
|
208
|
+
expect("patches" in result).toBe(false);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Client state unchanged
|
|
212
|
+
expect(server.getState()).toEqual(client.getState());
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("Task 16.2: Network Synchronization Pattern", () => {
|
|
217
|
+
it("should handle multiple moves with incremental patch synchronization", () => {
|
|
218
|
+
const moves: GameMoveDefinitions<MultiplayerGameState, MultiplayerMoves> =
|
|
219
|
+
{
|
|
220
|
+
drawCard: {
|
|
221
|
+
reducer: (draft) => {
|
|
222
|
+
const card = draft.deck.pop();
|
|
223
|
+
if (card) {
|
|
224
|
+
const player = draft.players[draft.currentPlayerIndex];
|
|
225
|
+
if (player) {
|
|
226
|
+
player.hand.push(card);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
playCard: {
|
|
232
|
+
reducer: (draft, context) => {
|
|
233
|
+
const player = draft.players[draft.currentPlayerIndex];
|
|
234
|
+
if (player && context.params?.cardId) {
|
|
235
|
+
const cardId = context.params.cardId as string;
|
|
236
|
+
const cardIndex = player.hand.indexOf(cardId);
|
|
237
|
+
if (cardIndex >= 0) {
|
|
238
|
+
player.hand.splice(cardIndex, 1);
|
|
239
|
+
player.score += 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
endTurn: {
|
|
245
|
+
reducer: (draft) => {
|
|
246
|
+
draft.currentPlayerIndex =
|
|
247
|
+
(draft.currentPlayerIndex + 1) % draft.players.length;
|
|
248
|
+
draft.turnNumber += 1;
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const gameDefinition: GameDefinition<
|
|
254
|
+
MultiplayerGameState,
|
|
255
|
+
MultiplayerMoves
|
|
256
|
+
> = {
|
|
257
|
+
name: "Incremental Sync Test",
|
|
258
|
+
setup: (players) => ({
|
|
259
|
+
players: players.map((p) => ({
|
|
260
|
+
id: p.id,
|
|
261
|
+
name: p.name || "Player",
|
|
262
|
+
hand: [],
|
|
263
|
+
score: 0,
|
|
264
|
+
})),
|
|
265
|
+
currentPlayerIndex: 0,
|
|
266
|
+
deck: ["card1", "card2", "card3"],
|
|
267
|
+
turnNumber: 1,
|
|
268
|
+
phase: "draw",
|
|
269
|
+
}),
|
|
270
|
+
moves,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const players = [
|
|
274
|
+
{ id: createPlayerId("p1"), name: "Alice" },
|
|
275
|
+
{ id: createPlayerId("p2"), name: "Bob" },
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
const server = new RuleEngine(gameDefinition, players);
|
|
279
|
+
const client = new RuleEngine(gameDefinition, players);
|
|
280
|
+
|
|
281
|
+
// Simulate 3 moves with incremental synchronization
|
|
282
|
+
const moves_to_execute = [
|
|
283
|
+
{ move: "drawCard", params: {} },
|
|
284
|
+
{ move: "playCard", params: { cardId: "card3" } }, // card3 was drawn
|
|
285
|
+
{ move: "endTurn", params: {} },
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
for (const moveToExecute of moves_to_execute) {
|
|
289
|
+
const result = server.executeMove(moveToExecute.move, {
|
|
290
|
+
playerId: createPlayerId("p1"),
|
|
291
|
+
params: moveToExecute.params,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (result.success) {
|
|
295
|
+
// Client applies patches incrementally
|
|
296
|
+
client.applyPatches(result.patches);
|
|
297
|
+
|
|
298
|
+
// States should match after each move
|
|
299
|
+
expect(server.getState()).toEqual(client.getState());
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Final verification
|
|
304
|
+
const finalState = server.getState();
|
|
305
|
+
expect(finalState.players[0]?.hand.length).toBe(0); // Played card
|
|
306
|
+
expect(finalState.players[0]?.score).toBe(1); // Scored 1 point
|
|
307
|
+
expect(finalState.currentPlayerIndex).toBe(1); // Next player
|
|
308
|
+
expect(finalState.turnNumber).toBe(2); // Turn incremented
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("should support batch patch application for reconnecting clients", () => {
|
|
312
|
+
const moves: GameMoveDefinitions<MultiplayerGameState, MultiplayerMoves> =
|
|
313
|
+
{
|
|
314
|
+
drawCard: {
|
|
315
|
+
reducer: (draft) => {
|
|
316
|
+
const card = draft.deck.pop();
|
|
317
|
+
if (card) {
|
|
318
|
+
const player = draft.players[draft.currentPlayerIndex];
|
|
319
|
+
if (player) {
|
|
320
|
+
player.hand.push(card);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
playCard: { reducer: () => {} },
|
|
326
|
+
endTurn: {
|
|
327
|
+
reducer: (draft) => {
|
|
328
|
+
draft.currentPlayerIndex =
|
|
329
|
+
(draft.currentPlayerIndex + 1) % draft.players.length;
|
|
330
|
+
draft.turnNumber += 1;
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const gameDefinition: GameDefinition<
|
|
336
|
+
MultiplayerGameState,
|
|
337
|
+
MultiplayerMoves
|
|
338
|
+
> = {
|
|
339
|
+
name: "Batch Sync Test",
|
|
340
|
+
setup: (players) => ({
|
|
341
|
+
players: players.map((p) => ({
|
|
342
|
+
id: p.id,
|
|
343
|
+
name: p.name || "Player",
|
|
344
|
+
hand: [],
|
|
345
|
+
score: 0,
|
|
346
|
+
})),
|
|
347
|
+
currentPlayerIndex: 0,
|
|
348
|
+
deck: ["card1", "card2", "card3", "card4"],
|
|
349
|
+
turnNumber: 1,
|
|
350
|
+
phase: "draw",
|
|
351
|
+
}),
|
|
352
|
+
moves,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const players = [
|
|
356
|
+
{ id: createPlayerId("p1"), name: "Alice" },
|
|
357
|
+
{ id: createPlayerId("p2"), name: "Bob" },
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
const server = new RuleEngine(gameDefinition, players);
|
|
361
|
+
|
|
362
|
+
// Execute 3 moves on server while client is disconnected
|
|
363
|
+
server.executeMove("drawCard", {
|
|
364
|
+
playerId: createPlayerId("p1"),
|
|
365
|
+
params: {},
|
|
366
|
+
});
|
|
367
|
+
server.executeMove("endTurn", {
|
|
368
|
+
playerId: createPlayerId("p1"),
|
|
369
|
+
params: {},
|
|
370
|
+
});
|
|
371
|
+
server.executeMove("drawCard", {
|
|
372
|
+
playerId: createPlayerId("p2"),
|
|
373
|
+
params: {},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Client reconnects and needs to catch up
|
|
377
|
+
const disconnectedClient = new RuleEngine(gameDefinition, players);
|
|
378
|
+
|
|
379
|
+
// Server sends all accumulated patches
|
|
380
|
+
const allPatches = server.getPatches();
|
|
381
|
+
|
|
382
|
+
// Client applies batch
|
|
383
|
+
disconnectedClient.applyPatches(allPatches);
|
|
384
|
+
|
|
385
|
+
// Client is now synchronized
|
|
386
|
+
expect(server.getState()).toEqual(disconnectedClient.getState());
|
|
387
|
+
|
|
388
|
+
// Verify state is correct
|
|
389
|
+
const state = disconnectedClient.getState();
|
|
390
|
+
expect(state.players[0]?.hand.length).toBe(1); // Alice drew 1
|
|
391
|
+
expect(state.players[1]?.hand.length).toBe(1); // Bob drew 1
|
|
392
|
+
expect(state.currentPlayerIndex).toBe(1); // Bob's turn
|
|
393
|
+
expect(state.turnNumber).toBe(2);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("should maintain deterministic state across server and clients", () => {
|
|
397
|
+
const moves: GameMoveDefinitions<MultiplayerGameState, MultiplayerMoves> =
|
|
398
|
+
{
|
|
399
|
+
drawCard: {
|
|
400
|
+
reducer: (draft, context) => {
|
|
401
|
+
// Use RNG for deterministic shuffling
|
|
402
|
+
const rng = context.rng;
|
|
403
|
+
if (rng && draft.deck.length > 0) {
|
|
404
|
+
const index = rng.randomInt(0, draft.deck.length - 1);
|
|
405
|
+
const card = draft.deck.splice(index, 1)[0];
|
|
406
|
+
if (card) {
|
|
407
|
+
const player = draft.players[draft.currentPlayerIndex];
|
|
408
|
+
if (player) {
|
|
409
|
+
player.hand.push(card);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
playCard: { reducer: () => {} },
|
|
416
|
+
endTurn: { reducer: () => {} },
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const gameDefinition: GameDefinition<
|
|
420
|
+
MultiplayerGameState,
|
|
421
|
+
MultiplayerMoves
|
|
422
|
+
> = {
|
|
423
|
+
name: "Deterministic Sync Test",
|
|
424
|
+
setup: (players) => ({
|
|
425
|
+
players: players.map((p) => ({
|
|
426
|
+
id: p.id,
|
|
427
|
+
name: p.name || "Player",
|
|
428
|
+
hand: [],
|
|
429
|
+
score: 0,
|
|
430
|
+
})),
|
|
431
|
+
currentPlayerIndex: 0,
|
|
432
|
+
deck: ["card1", "card2", "card3", "card4", "card5"],
|
|
433
|
+
turnNumber: 1,
|
|
434
|
+
phase: "draw",
|
|
435
|
+
}),
|
|
436
|
+
moves,
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const players = [{ id: createPlayerId("p1"), name: "Alice" }];
|
|
440
|
+
|
|
441
|
+
// Server and client use same seed
|
|
442
|
+
const server = new RuleEngine(gameDefinition, players, {
|
|
443
|
+
seed: "deterministic-123",
|
|
444
|
+
});
|
|
445
|
+
const client = new RuleEngine(gameDefinition, players, {
|
|
446
|
+
seed: "deterministic-123",
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Execute move on server
|
|
450
|
+
const result = server.executeMove("drawCard", {
|
|
451
|
+
playerId: createPlayerId("p1"),
|
|
452
|
+
params: {},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (result.success) {
|
|
456
|
+
// Client applies patches
|
|
457
|
+
client.applyPatches(result.patches);
|
|
458
|
+
|
|
459
|
+
// States match exactly
|
|
460
|
+
expect(server.getState()).toEqual(client.getState());
|
|
461
|
+
|
|
462
|
+
// Same card drawn due to deterministic RNG
|
|
463
|
+
const serverHand = server.getState().players[0]?.hand;
|
|
464
|
+
const clientHand = client.getState().players[0]?.hand;
|
|
465
|
+
expect(serverHand).toEqual(clientHand);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { createTestEngine } from "../testing/test-engine-builder";
|
|
3
|
+
import { createTestPlayers } from "../testing/test-player-builder";
|
|
4
|
+
import { createMockLorcanaGame } from "./createMockLorcanaGame";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lorcana Card Game - Engine Feature Tests
|
|
8
|
+
*
|
|
9
|
+
* Refactored to showcase:
|
|
10
|
+
* ✅ Engine-managed flow state
|
|
11
|
+
* ✅ High-level zone utilities (drawCards, mulligan)
|
|
12
|
+
* ✅ Tracker system (hasInked, per-card quested tracking)
|
|
13
|
+
* ✅ Standard moves (passTurn, concede)
|
|
14
|
+
* ✅ Simplified state (8 fields → 3 fields)
|
|
15
|
+
*/
|
|
16
|
+
describe("Lorcana Game - Refactored Engine Features", () => {
|
|
17
|
+
it("should initialize game with ONLY game-specific state", () => {
|
|
18
|
+
const gameDefinition = createMockLorcanaGame();
|
|
19
|
+
const players = createTestPlayers(2);
|
|
20
|
+
const engine = createTestEngine(gameDefinition, players);
|
|
21
|
+
|
|
22
|
+
const state = engine.getState();
|
|
23
|
+
|
|
24
|
+
// ✅ NEW: Only game-specific data
|
|
25
|
+
expect(state.effects).toBeDefined();
|
|
26
|
+
expect(state.bag).toBeDefined();
|
|
27
|
+
expect(state.loreScores).toBeDefined();
|
|
28
|
+
|
|
29
|
+
// ✅ REMOVED: No manual tracking
|
|
30
|
+
// @ts-expect-error
|
|
31
|
+
expect(state.activePlayerId).toBeUndefined();
|
|
32
|
+
// @ts-expect-error
|
|
33
|
+
expect(state.turnNumber).toBeUndefined();
|
|
34
|
+
// @ts-expect-error
|
|
35
|
+
expect(state.gamePhase).toBeUndefined();
|
|
36
|
+
// @ts-expect-error
|
|
37
|
+
expect(state.firstPlayerDetermined).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should have proper zone configuration", () => {
|
|
41
|
+
const gameDefinition = createMockLorcanaGame();
|
|
42
|
+
const zones = gameDefinition.zones;
|
|
43
|
+
|
|
44
|
+
expect(zones?.deck).toBeDefined();
|
|
45
|
+
expect(zones?.hand).toBeDefined();
|
|
46
|
+
expect(zones?.inkwell).toBeDefined();
|
|
47
|
+
expect(zones?.play).toBeDefined();
|
|
48
|
+
expect(zones?.discard).toBeDefined();
|
|
49
|
+
|
|
50
|
+
expect(zones?.deck?.maxSize).toBe(60);
|
|
51
|
+
expect(zones?.inkwell?.faceDown).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should use high-level zone utilities", () => {
|
|
55
|
+
// ✅ NEW: zones.mulligan() for alterHand
|
|
56
|
+
// ✅ NEW: zones.drawCards() for drawing
|
|
57
|
+
|
|
58
|
+
const gameDefinition = createMockLorcanaGame();
|
|
59
|
+
expect(gameDefinition.moves.alterHand).toBeDefined();
|
|
60
|
+
expect(gameDefinition.moves.drawCards).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should configure tracker system for inking", () => {
|
|
64
|
+
const gameDefinition = createMockLorcanaGame();
|
|
65
|
+
|
|
66
|
+
expect(gameDefinition.trackers).toBeDefined();
|
|
67
|
+
expect(gameDefinition.trackers?.perTurn).toContain("hasInked");
|
|
68
|
+
expect(gameDefinition.trackers?.perPlayer).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should use tracker system for ink and quest actions", () => {
|
|
72
|
+
const gameDefinition = createMockLorcanaGame();
|
|
73
|
+
|
|
74
|
+
// Inking uses hasInked tracker
|
|
75
|
+
const putInkwell = gameDefinition.moves.putACardIntoTheInkwell;
|
|
76
|
+
expect(putInkwell.condition).toBeDefined();
|
|
77
|
+
|
|
78
|
+
// Questing uses per-card trackers
|
|
79
|
+
const quest = gameDefinition.moves.quest;
|
|
80
|
+
expect(quest.condition).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should use standard moves", () => {
|
|
84
|
+
const gameDefinition = createMockLorcanaGame();
|
|
85
|
+
|
|
86
|
+
expect(gameDefinition.moves.passTurn).toBeDefined();
|
|
87
|
+
expect(gameDefinition.moves.concede).toBeDefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should demonstrate boilerplate reduction", () => {
|
|
91
|
+
// State fields: 8 → 3 (-62%)
|
|
92
|
+
// Eliminated player zones from state (engine manages)
|
|
93
|
+
|
|
94
|
+
const gameDefinition = createMockLorcanaGame();
|
|
95
|
+
const players = createTestPlayers(2);
|
|
96
|
+
const state = gameDefinition.setup(players);
|
|
97
|
+
|
|
98
|
+
expect(Object.keys(state).length).toBe(3);
|
|
99
|
+
});
|
|
100
|
+
});
|