@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,431 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { Draft } from "immer";
|
|
3
|
+
import type { GameMoveDefinition } from "../game-definition/move-definitions";
|
|
4
|
+
import { createMockContext } from "../testing/test-context-factory";
|
|
5
|
+
import type { PlayerId } from "../types";
|
|
6
|
+
import { createPlayerId } from "../types";
|
|
7
|
+
import {
|
|
8
|
+
canExecuteMove,
|
|
9
|
+
executeMove,
|
|
10
|
+
getMove,
|
|
11
|
+
getMoveIds,
|
|
12
|
+
moveExists,
|
|
13
|
+
} from "./move-executor";
|
|
14
|
+
import type { MoveContext } from "./move-system";
|
|
15
|
+
|
|
16
|
+
describe("Move Executor", () => {
|
|
17
|
+
type TestGameState = {
|
|
18
|
+
players: Record<PlayerId, { life: number; mana: number }>;
|
|
19
|
+
turnCount: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const player1 = createPlayerId("p1");
|
|
23
|
+
const player2 = createPlayerId("p2");
|
|
24
|
+
|
|
25
|
+
const initialState: TestGameState = {
|
|
26
|
+
players: {
|
|
27
|
+
[player1]: { life: 20, mana: 5 },
|
|
28
|
+
[player2]: { life: 20, mana: 5 },
|
|
29
|
+
},
|
|
30
|
+
turnCount: 1,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const testMoves: Record<string, GameMoveDefinition<TestGameState>> = {
|
|
34
|
+
"spend-mana": {
|
|
35
|
+
condition: (state: TestGameState, context: MoveContext) =>
|
|
36
|
+
state.players[context.playerId].mana >= 2,
|
|
37
|
+
reducer: (draft: Draft<TestGameState>, context: MoveContext) => {
|
|
38
|
+
draft.players[context.playerId].mana -= 2;
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"deal-damage": {
|
|
42
|
+
condition: (state: TestGameState, context: MoveContext) => {
|
|
43
|
+
if (!context.targets?.[0]) return false;
|
|
44
|
+
const targetId = context.targets[0][0] as PlayerId;
|
|
45
|
+
return targetId in state.players;
|
|
46
|
+
},
|
|
47
|
+
reducer: (draft: Draft<TestGameState>, context: MoveContext) => {
|
|
48
|
+
const targetId = context.targets?.[0]?.[0] as PlayerId;
|
|
49
|
+
if (targetId) {
|
|
50
|
+
draft.players[targetId].life -= 3;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
"next-turn": {
|
|
55
|
+
reducer: (draft: Draft<TestGameState>) => {
|
|
56
|
+
draft.turnCount += 1;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("executeMove", () => {
|
|
62
|
+
it("should execute valid move successfully", () => {
|
|
63
|
+
const context: MoveContext = createMockContext({
|
|
64
|
+
playerId: player1,
|
|
65
|
+
params: {},
|
|
66
|
+
});
|
|
67
|
+
const result = executeMove(
|
|
68
|
+
initialState,
|
|
69
|
+
"spend-mana",
|
|
70
|
+
context,
|
|
71
|
+
testMoves,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(result.success).toBe(true);
|
|
75
|
+
if (result.success) {
|
|
76
|
+
expect(result.state.players[player1].mana).toBe(3);
|
|
77
|
+
expect(result.state.players[player2].mana).toBe(5); // Unchanged
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should reject move with failed condition", () => {
|
|
82
|
+
const lowManaState: TestGameState = {
|
|
83
|
+
...initialState,
|
|
84
|
+
players: {
|
|
85
|
+
...initialState.players,
|
|
86
|
+
[player1]: { life: 20, mana: 1 },
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const context: MoveContext = createMockContext({
|
|
91
|
+
playerId: player1,
|
|
92
|
+
params: {},
|
|
93
|
+
});
|
|
94
|
+
const result = executeMove(
|
|
95
|
+
lowManaState,
|
|
96
|
+
"spend-mana",
|
|
97
|
+
context,
|
|
98
|
+
testMoves,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(result.success).toBe(false);
|
|
102
|
+
if (!result.success) {
|
|
103
|
+
expect(result.error).toContain("condition not met");
|
|
104
|
+
expect(result.errorCode).toBe("CONDITION_FAILED");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should reject non-existent move", () => {
|
|
109
|
+
const context: MoveContext = createMockContext({
|
|
110
|
+
playerId: player1,
|
|
111
|
+
params: {},
|
|
112
|
+
});
|
|
113
|
+
const result = executeMove(
|
|
114
|
+
initialState,
|
|
115
|
+
"nonexistent",
|
|
116
|
+
context,
|
|
117
|
+
testMoves,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(result.success).toBe(false);
|
|
121
|
+
if (!result.success) {
|
|
122
|
+
expect(result.error).toContain("does not exist");
|
|
123
|
+
expect(result.errorCode).toBe("MOVE_NOT_FOUND");
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should execute move without condition", () => {
|
|
128
|
+
const context: MoveContext = createMockContext({
|
|
129
|
+
playerId: player1,
|
|
130
|
+
params: {},
|
|
131
|
+
});
|
|
132
|
+
const result = executeMove(initialState, "next-turn", context, testMoves);
|
|
133
|
+
|
|
134
|
+
expect(result.success).toBe(true);
|
|
135
|
+
if (result.success) {
|
|
136
|
+
expect(result.state.turnCount).toBe(2);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should execute move with targets", () => {
|
|
141
|
+
const context: MoveContext = createMockContext({
|
|
142
|
+
playerId: player1,
|
|
143
|
+
params: {},
|
|
144
|
+
targets: [[player2]],
|
|
145
|
+
});
|
|
146
|
+
const result = executeMove(
|
|
147
|
+
initialState,
|
|
148
|
+
"deal-damage",
|
|
149
|
+
context,
|
|
150
|
+
testMoves,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(result.success).toBe(true);
|
|
154
|
+
if (result.success) {
|
|
155
|
+
expect(result.state.players[player2].life).toBe(17);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should handle condition errors gracefully", () => {
|
|
160
|
+
const brokenMove: GameMoveDefinition<TestGameState> = {
|
|
161
|
+
condition: () => {
|
|
162
|
+
throw new Error("Condition error");
|
|
163
|
+
},
|
|
164
|
+
reducer: (draft: Draft<TestGameState>) => draft,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const moves = { ...testMoves, broken: brokenMove };
|
|
168
|
+
const context: MoveContext = createMockContext({
|
|
169
|
+
playerId: player1,
|
|
170
|
+
params: {},
|
|
171
|
+
});
|
|
172
|
+
const result = executeMove(initialState, "broken", context, moves);
|
|
173
|
+
|
|
174
|
+
expect(result.success).toBe(false);
|
|
175
|
+
if (!result.success) {
|
|
176
|
+
expect(result.errorCode).toBe("CONDITION_ERROR");
|
|
177
|
+
expect(result.error).toContain("Error checking condition");
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should handle reducer errors gracefully", () => {
|
|
182
|
+
const brokenMove: GameMoveDefinition<TestGameState> = {
|
|
183
|
+
reducer: () => {
|
|
184
|
+
throw new Error("Reducer error");
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const moves = { ...testMoves, "broken-reducer": brokenMove };
|
|
189
|
+
const context: MoveContext = createMockContext({
|
|
190
|
+
playerId: player1,
|
|
191
|
+
params: {},
|
|
192
|
+
});
|
|
193
|
+
const result = executeMove(
|
|
194
|
+
initialState,
|
|
195
|
+
"broken-reducer",
|
|
196
|
+
context,
|
|
197
|
+
moves,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(result.success).toBe(false);
|
|
201
|
+
if (!result.success) {
|
|
202
|
+
expect(result.errorCode).toBe("EXECUTION_ERROR");
|
|
203
|
+
expect(result.error).toContain("Error executing move");
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should not mutate original state", () => {
|
|
208
|
+
const context: MoveContext = createMockContext({
|
|
209
|
+
playerId: player1,
|
|
210
|
+
params: {},
|
|
211
|
+
});
|
|
212
|
+
executeMove(initialState, "spend-mana", context, testMoves);
|
|
213
|
+
|
|
214
|
+
// Original state should be unchanged
|
|
215
|
+
expect(initialState.players[player1].mana).toBe(5);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("canExecuteMove", () => {
|
|
220
|
+
it("should return true for valid move", () => {
|
|
221
|
+
const context: MoveContext = createMockContext({
|
|
222
|
+
playerId: player1,
|
|
223
|
+
params: {},
|
|
224
|
+
});
|
|
225
|
+
const canExecute = canExecuteMove(
|
|
226
|
+
initialState,
|
|
227
|
+
"spend-mana",
|
|
228
|
+
context,
|
|
229
|
+
testMoves,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(canExecute).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should return false for invalid move", () => {
|
|
236
|
+
const lowManaState: TestGameState = {
|
|
237
|
+
...initialState,
|
|
238
|
+
players: {
|
|
239
|
+
...initialState.players,
|
|
240
|
+
[player1]: { life: 20, mana: 1 },
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const context: MoveContext = createMockContext({
|
|
245
|
+
playerId: player1,
|
|
246
|
+
params: {},
|
|
247
|
+
});
|
|
248
|
+
const canExecute = canExecuteMove(
|
|
249
|
+
lowManaState,
|
|
250
|
+
"spend-mana",
|
|
251
|
+
context,
|
|
252
|
+
testMoves,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
expect(canExecute).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("should return false for non-existent move", () => {
|
|
259
|
+
const context: MoveContext = createMockContext({
|
|
260
|
+
playerId: player1,
|
|
261
|
+
params: {},
|
|
262
|
+
});
|
|
263
|
+
const canExecute = canExecuteMove(
|
|
264
|
+
initialState,
|
|
265
|
+
"nonexistent",
|
|
266
|
+
context,
|
|
267
|
+
testMoves,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(canExecute).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("should return true for move without condition", () => {
|
|
274
|
+
const context: MoveContext = createMockContext({
|
|
275
|
+
playerId: player1,
|
|
276
|
+
params: {},
|
|
277
|
+
});
|
|
278
|
+
const canExecute = canExecuteMove(
|
|
279
|
+
initialState,
|
|
280
|
+
"next-turn",
|
|
281
|
+
context,
|
|
282
|
+
testMoves,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(canExecute).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("should not execute the move (dry run)", () => {
|
|
289
|
+
const context: MoveContext = createMockContext({
|
|
290
|
+
playerId: player1,
|
|
291
|
+
params: {},
|
|
292
|
+
});
|
|
293
|
+
canExecuteMove(initialState, "spend-mana", context, testMoves);
|
|
294
|
+
|
|
295
|
+
// State should be unchanged
|
|
296
|
+
expect(initialState.players[player1].mana).toBe(5);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("should handle condition errors by returning false", () => {
|
|
300
|
+
const brokenMove: GameMoveDefinition<TestGameState> = {
|
|
301
|
+
condition: () => {
|
|
302
|
+
throw new Error("Condition error");
|
|
303
|
+
},
|
|
304
|
+
reducer: (draft: Draft<TestGameState>) => draft,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const moves = { ...testMoves, broken: brokenMove };
|
|
308
|
+
const context: MoveContext = createMockContext({
|
|
309
|
+
playerId: player1,
|
|
310
|
+
params: {},
|
|
311
|
+
});
|
|
312
|
+
const canExecute = canExecuteMove(initialState, "broken", context, moves);
|
|
313
|
+
|
|
314
|
+
expect(canExecute).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("getMove", () => {
|
|
319
|
+
it("should return move definition if exists", () => {
|
|
320
|
+
const move = getMove("spend-mana", testMoves);
|
|
321
|
+
|
|
322
|
+
expect(move).toBeDefined();
|
|
323
|
+
expect(move?.reducer).toBeDefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("should return undefined if move does not exist", () => {
|
|
327
|
+
const move = getMove("nonexistent", testMoves);
|
|
328
|
+
|
|
329
|
+
expect(move).toBeUndefined();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("getMoveIds", () => {
|
|
334
|
+
it("should return all move IDs", () => {
|
|
335
|
+
const ids = getMoveIds(testMoves);
|
|
336
|
+
|
|
337
|
+
expect(ids).toContain("spend-mana");
|
|
338
|
+
expect(ids).toContain("deal-damage");
|
|
339
|
+
expect(ids).toContain("next-turn");
|
|
340
|
+
expect(ids).toHaveLength(3);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("should return empty array for empty moves", () => {
|
|
344
|
+
const ids = getMoveIds({});
|
|
345
|
+
|
|
346
|
+
expect(ids).toEqual([]);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("moveExists", () => {
|
|
351
|
+
it("should return true if move exists", () => {
|
|
352
|
+
expect(moveExists("spend-mana", testMoves)).toBe(true);
|
|
353
|
+
expect(moveExists("deal-damage", testMoves)).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("should return false if move does not exist", () => {
|
|
357
|
+
expect(moveExists("nonexistent", testMoves)).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("Integration: Validation Pipeline", () => {
|
|
362
|
+
it("should follow full validation pipeline", () => {
|
|
363
|
+
const context: MoveContext = createMockContext({
|
|
364
|
+
playerId: player1,
|
|
365
|
+
params: {},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// 1. Check if move can be executed
|
|
369
|
+
const canExecute = canExecuteMove(
|
|
370
|
+
initialState,
|
|
371
|
+
"spend-mana",
|
|
372
|
+
context,
|
|
373
|
+
testMoves,
|
|
374
|
+
);
|
|
375
|
+
expect(canExecute).toBe(true);
|
|
376
|
+
|
|
377
|
+
// 2. Execute the move
|
|
378
|
+
const result = executeMove(
|
|
379
|
+
initialState,
|
|
380
|
+
"spend-mana",
|
|
381
|
+
context,
|
|
382
|
+
testMoves,
|
|
383
|
+
);
|
|
384
|
+
expect(result.success).toBe(true);
|
|
385
|
+
|
|
386
|
+
// 3. Use new state
|
|
387
|
+
if (result.success) {
|
|
388
|
+
const newState = result.state;
|
|
389
|
+
expect(newState.players[player1].mana).toBe(3);
|
|
390
|
+
|
|
391
|
+
// 4. Check if move can still be executed
|
|
392
|
+
const canExecuteAgain = canExecuteMove(
|
|
393
|
+
newState,
|
|
394
|
+
"spend-mana",
|
|
395
|
+
context,
|
|
396
|
+
testMoves,
|
|
397
|
+
);
|
|
398
|
+
expect(canExecuteAgain).toBe(true); // Still >= 2 mana
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("should prevent invalid moves from being executed", () => {
|
|
403
|
+
const lowManaState: TestGameState = {
|
|
404
|
+
...initialState,
|
|
405
|
+
players: {
|
|
406
|
+
...initialState.players,
|
|
407
|
+
[player1]: { life: 20, mana: 1 },
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const context: MoveContext = createMockContext({
|
|
412
|
+
playerId: player1,
|
|
413
|
+
params: {},
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Pre-check prevents unnecessary execution
|
|
417
|
+
if (canExecuteMove(lowManaState, "spend-mana", context, testMoves)) {
|
|
418
|
+
const result = executeMove(
|
|
419
|
+
lowManaState,
|
|
420
|
+
"spend-mana",
|
|
421
|
+
context,
|
|
422
|
+
testMoves,
|
|
423
|
+
);
|
|
424
|
+
expect(result.success).toBe(true);
|
|
425
|
+
} else {
|
|
426
|
+
// Move was correctly prevented
|
|
427
|
+
expect(true).toBe(true);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { produce } from "immer";
|
|
2
|
+
import type { GameMoveDefinition } from "../game-definition/move-definitions";
|
|
3
|
+
import type { MoveContext, MoveResult } from "./move-system";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generic move map type for runtime lookup
|
|
7
|
+
*
|
|
8
|
+
* Used by executor functions that need to look up moves by string ID.
|
|
9
|
+
* For type-safe move definitions in game definitions, use GameMoveDefinitions instead.
|
|
10
|
+
*/
|
|
11
|
+
type GenericMoveMap<TGameState> = Record<
|
|
12
|
+
string,
|
|
13
|
+
GameMoveDefinition<TGameState>
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Execute a move with full validation pipeline
|
|
18
|
+
*
|
|
19
|
+
* Pipeline:
|
|
20
|
+
* 1. Validate move exists
|
|
21
|
+
* 2. Check condition (if present)
|
|
22
|
+
* 3. Execute reducer with Immer
|
|
23
|
+
* 4. Return result (success or failure)
|
|
24
|
+
*
|
|
25
|
+
* @param state - Current game state
|
|
26
|
+
* @param moveId - ID of move to execute
|
|
27
|
+
* @param context - Move context
|
|
28
|
+
* @param moves - Available moves
|
|
29
|
+
* @returns MoveResult with new state or error
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* const result = executeMove(
|
|
34
|
+
* gameState,
|
|
35
|
+
* 'draw-card',
|
|
36
|
+
* { playerId: 'p1' },
|
|
37
|
+
* gameMoves
|
|
38
|
+
* );
|
|
39
|
+
*
|
|
40
|
+
* if (result.success) {
|
|
41
|
+
* gameState = result.state;
|
|
42
|
+
* } else {
|
|
43
|
+
* console.error(result.error);
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function executeMove<TGameState>(
|
|
48
|
+
state: TGameState,
|
|
49
|
+
moveId: string,
|
|
50
|
+
context: MoveContext,
|
|
51
|
+
moves: GenericMoveMap<TGameState>,
|
|
52
|
+
): MoveResult<TGameState> {
|
|
53
|
+
// 1. Validate move exists
|
|
54
|
+
const moveDef = moves[moveId];
|
|
55
|
+
if (!moveDef) {
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
error: `Move '${moveId}' does not exist`,
|
|
59
|
+
errorCode: "MOVE_NOT_FOUND",
|
|
60
|
+
errorContext: { moveId },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Check condition
|
|
65
|
+
if (moveDef.condition) {
|
|
66
|
+
try {
|
|
67
|
+
const isValid = moveDef.condition(state, context);
|
|
68
|
+
if (!isValid) {
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
error: `Move '${moveId}' condition not met`,
|
|
72
|
+
errorCode: "CONDITION_FAILED",
|
|
73
|
+
errorContext: { moveId },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
error: `Error checking condition for move '${moveId}': ${error instanceof Error ? error.message : String(error)}`,
|
|
80
|
+
errorCode: "CONDITION_ERROR",
|
|
81
|
+
errorContext: {
|
|
82
|
+
moveId,
|
|
83
|
+
originalError: error instanceof Error ? error.message : String(error),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. Execute reducer
|
|
90
|
+
try {
|
|
91
|
+
const nextState = produce(state, (draft) => {
|
|
92
|
+
moveDef.reducer(draft, context);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
success: true,
|
|
97
|
+
state: nextState,
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return {
|
|
101
|
+
success: false,
|
|
102
|
+
error: `Error executing move '${moveId}': ${error instanceof Error ? error.message : String(error)}`,
|
|
103
|
+
errorCode: "EXECUTION_ERROR",
|
|
104
|
+
errorContext: {
|
|
105
|
+
moveId,
|
|
106
|
+
originalError: error instanceof Error ? error.message : String(error),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if a move can be executed without actually executing it
|
|
114
|
+
*
|
|
115
|
+
* Validates:
|
|
116
|
+
* 1. Move exists
|
|
117
|
+
* 2. Condition passes (if present)
|
|
118
|
+
*
|
|
119
|
+
* Does NOT execute the reducer or modify state.
|
|
120
|
+
*
|
|
121
|
+
* @param state - Current game state
|
|
122
|
+
* @param moveId - ID of move to check
|
|
123
|
+
* @param context - Move context
|
|
124
|
+
* @param moves - Available moves
|
|
125
|
+
* @returns True if move can be executed
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```typescript
|
|
129
|
+
* if (canExecuteMove(gameState, 'play-card', context, moves)) {
|
|
130
|
+
* const result = executeMove(gameState, 'play-card', context, moves);
|
|
131
|
+
* }
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function canExecuteMove<TGameState>(
|
|
135
|
+
state: TGameState,
|
|
136
|
+
moveId: string,
|
|
137
|
+
context: MoveContext,
|
|
138
|
+
moves: GenericMoveMap<TGameState>,
|
|
139
|
+
): boolean {
|
|
140
|
+
const moveDef = moves[moveId];
|
|
141
|
+
if (!moveDef) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!moveDef.condition) {
|
|
146
|
+
return true; // No condition means always valid
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const result = moveDef.condition(state, context);
|
|
151
|
+
return result === true; // Support both boolean and ConditionFailure returns
|
|
152
|
+
} catch {
|
|
153
|
+
return false; // Condition error = invalid
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get move definition by ID
|
|
159
|
+
*
|
|
160
|
+
* @param moveId - ID of move to retrieve
|
|
161
|
+
* @param moves - Available moves
|
|
162
|
+
* @returns Move definition or undefined
|
|
163
|
+
*/
|
|
164
|
+
export function getMove<TGameState>(
|
|
165
|
+
moveId: string,
|
|
166
|
+
moves: GenericMoveMap<TGameState>,
|
|
167
|
+
): GameMoveDefinition<TGameState> | undefined {
|
|
168
|
+
return moves[moveId];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get all move IDs
|
|
173
|
+
*
|
|
174
|
+
* @param moves - Available moves
|
|
175
|
+
* @returns Array of move IDs
|
|
176
|
+
*/
|
|
177
|
+
export function getMoveIds<TGameState>(
|
|
178
|
+
moves: GenericMoveMap<TGameState>,
|
|
179
|
+
): string[] {
|
|
180
|
+
return Object.keys(moves);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check if a move exists
|
|
185
|
+
*
|
|
186
|
+
* @param moveId - ID of move to check
|
|
187
|
+
* @param moves - Available moves
|
|
188
|
+
* @returns True if move exists
|
|
189
|
+
*/
|
|
190
|
+
export function moveExists<TGameState>(
|
|
191
|
+
moveId: string,
|
|
192
|
+
moves: GenericMoveMap<TGameState>,
|
|
193
|
+
): boolean {
|
|
194
|
+
return moveId in moves;
|
|
195
|
+
}
|