@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,115 @@
|
|
|
1
|
+
import type { RuleEngine } from "../engine/rule-engine";
|
|
2
|
+
import type { MoveContext, MoveContextInput } from "../moves/move-system";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Test Flow Assertions
|
|
6
|
+
*
|
|
7
|
+
* Assertion helpers for testing game flow and phase transitions
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Assert that a move causes a phase transition
|
|
12
|
+
*
|
|
13
|
+
* Verifies that executing a move transitions from one phase to another.
|
|
14
|
+
* Supports custom phase paths for games that store phase in nested state.
|
|
15
|
+
*
|
|
16
|
+
* @param engine - Rule engine instance
|
|
17
|
+
* @param moveId - Move to execute
|
|
18
|
+
* @param context - Move context
|
|
19
|
+
* @param fromPhase - Expected initial phase
|
|
20
|
+
* @param toPhase - Expected final phase
|
|
21
|
+
* @param phasePath - Optional path to phase in state (default: 'phase')
|
|
22
|
+
* @throws Error if phase transition doesn't match expectations
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* // Test phase transition
|
|
27
|
+
* expectPhaseTransition(
|
|
28
|
+
* engine,
|
|
29
|
+
* 'endPhase',
|
|
30
|
+
* { playerId: 'p1' },
|
|
31
|
+
* 'main',
|
|
32
|
+
* 'end'
|
|
33
|
+
* );
|
|
34
|
+
*
|
|
35
|
+
* // Test with custom phase path
|
|
36
|
+
* expectPhaseTransition(
|
|
37
|
+
* engine,
|
|
38
|
+
* 'advance',
|
|
39
|
+
* { playerId: 'p1' },
|
|
40
|
+
* 'draw',
|
|
41
|
+
* 'main',
|
|
42
|
+
* 'gameState.currentPhase'
|
|
43
|
+
* );
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function expectPhaseTransition<
|
|
47
|
+
TState,
|
|
48
|
+
TMoves extends Record<string, any>,
|
|
49
|
+
>(
|
|
50
|
+
engine: RuleEngine<TState, TMoves>,
|
|
51
|
+
moveId: string,
|
|
52
|
+
context: MoveContextInput,
|
|
53
|
+
fromPhase: string,
|
|
54
|
+
toPhase: string,
|
|
55
|
+
phasePath = "phase",
|
|
56
|
+
): void {
|
|
57
|
+
// Get initial state
|
|
58
|
+
const initialState = engine.getState();
|
|
59
|
+
const initialPhase = getPropertyByPath(initialState, phasePath);
|
|
60
|
+
|
|
61
|
+
// Verify initial phase
|
|
62
|
+
if (initialPhase !== fromPhase) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Expected initial phase to be '${fromPhase}', but found '${initialPhase}'`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Execute move
|
|
69
|
+
const result = engine.executeMove(moveId, context);
|
|
70
|
+
|
|
71
|
+
// Check if move succeeded
|
|
72
|
+
if (!result.success) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Move failed during phase transition test: ${result.error}` +
|
|
75
|
+
(result.errorCode ? ` (code: ${result.errorCode})` : ""),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Get final state
|
|
80
|
+
const finalState = engine.getState();
|
|
81
|
+
const finalPhase = getPropertyByPath(finalState, phasePath);
|
|
82
|
+
|
|
83
|
+
// Verify final phase
|
|
84
|
+
if (finalPhase !== toPhase) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Expected final phase to be '${toPhase}', but found '${finalPhase}'`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get property value by path with dot notation
|
|
93
|
+
*
|
|
94
|
+
* Supports paths like:
|
|
95
|
+
* - 'phase'
|
|
96
|
+
* - 'gameState.currentPhase'
|
|
97
|
+
* - 'flow.phase'
|
|
98
|
+
*
|
|
99
|
+
* @param obj - Object to traverse
|
|
100
|
+
* @param path - Property path
|
|
101
|
+
* @returns Property value or undefined
|
|
102
|
+
*/
|
|
103
|
+
function getPropertyByPath(obj: any, path: string): any {
|
|
104
|
+
const parts = path.split(".");
|
|
105
|
+
let current = obj;
|
|
106
|
+
|
|
107
|
+
for (const part of parts) {
|
|
108
|
+
if (current === null || current === undefined) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
current = current[part];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return current;
|
|
115
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { createTestPlayers } from "./test-player-builder";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for createTestPlayers - Player builder for tests
|
|
6
|
+
*
|
|
7
|
+
* Task 2.1: Write tests for test builders (createTestPlayers)
|
|
8
|
+
*
|
|
9
|
+
* Tests verify:
|
|
10
|
+
* - Creating multiple players with default names
|
|
11
|
+
* - Creating players with custom names
|
|
12
|
+
* - Player IDs are unique and properly formatted
|
|
13
|
+
* - Type safety for Player objects
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
describe("createTestPlayers", () => {
|
|
17
|
+
describe("Basic Functionality", () => {
|
|
18
|
+
it("should create specified number of players with default names", () => {
|
|
19
|
+
const players = createTestPlayers(2);
|
|
20
|
+
|
|
21
|
+
expect(players).toHaveLength(2);
|
|
22
|
+
expect(players[0]?.name).toBe("Player 1");
|
|
23
|
+
expect(players[1]?.name).toBe("Player 2");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should create players with custom names", () => {
|
|
27
|
+
const players = createTestPlayers(3, ["Alice", "Bob", "Charlie"]);
|
|
28
|
+
|
|
29
|
+
expect(players).toHaveLength(3);
|
|
30
|
+
expect(players[0]?.name).toBe("Alice");
|
|
31
|
+
expect(players[1]?.name).toBe("Bob");
|
|
32
|
+
expect(players[2]?.name).toBe("Charlie");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should handle partial custom names with defaults for remaining", () => {
|
|
36
|
+
const players = createTestPlayers(4, ["Alice", "Bob"]);
|
|
37
|
+
|
|
38
|
+
expect(players).toHaveLength(4);
|
|
39
|
+
expect(players[0]?.name).toBe("Alice");
|
|
40
|
+
expect(players[1]?.name).toBe("Bob");
|
|
41
|
+
expect(players[2]?.name).toBe("Player 3");
|
|
42
|
+
expect(players[3]?.name).toBe("Player 4");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should create single player", () => {
|
|
46
|
+
const players = createTestPlayers(1);
|
|
47
|
+
|
|
48
|
+
expect(players).toHaveLength(1);
|
|
49
|
+
expect(players[0]?.name).toBe("Player 1");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should create single player with custom name", () => {
|
|
53
|
+
const players = createTestPlayers(1, ["Alice"]);
|
|
54
|
+
|
|
55
|
+
expect(players).toHaveLength(1);
|
|
56
|
+
expect(players[0]?.name).toBe("Alice");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("Player IDs", () => {
|
|
61
|
+
it("should generate unique IDs for each player", () => {
|
|
62
|
+
const players = createTestPlayers(3);
|
|
63
|
+
|
|
64
|
+
const ids = players.map((p) => p.id);
|
|
65
|
+
const uniqueIds = new Set(ids);
|
|
66
|
+
|
|
67
|
+
expect(uniqueIds.size).toBe(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should generate properly formatted PlayerId brand", () => {
|
|
71
|
+
const players = createTestPlayers(2);
|
|
72
|
+
|
|
73
|
+
// IDs should be non-empty strings
|
|
74
|
+
for (const player of players) {
|
|
75
|
+
expect(player.id).toBeTypeOf("string");
|
|
76
|
+
expect(player.id.length).toBeGreaterThan(0);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should generate deterministic IDs based on index", () => {
|
|
81
|
+
const players1 = createTestPlayers(2);
|
|
82
|
+
const players2 = createTestPlayers(2);
|
|
83
|
+
|
|
84
|
+
// IDs should be based on player index, so they should match
|
|
85
|
+
expect(players1[0]?.id).toBe(players2[0]?.id);
|
|
86
|
+
expect(players1[1]?.id).toBe(players2[1]?.id);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("Edge Cases", () => {
|
|
91
|
+
it("should handle 0 players by returning empty array", () => {
|
|
92
|
+
const players = createTestPlayers(0);
|
|
93
|
+
|
|
94
|
+
expect(players).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should handle large number of players", () => {
|
|
98
|
+
const players = createTestPlayers(10);
|
|
99
|
+
|
|
100
|
+
expect(players).toHaveLength(10);
|
|
101
|
+
expect(players[9]?.name).toBe("Player 10");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should ignore extra names beyond count", () => {
|
|
105
|
+
const players = createTestPlayers(2, ["Alice", "Bob", "Charlie"]);
|
|
106
|
+
|
|
107
|
+
expect(players).toHaveLength(2);
|
|
108
|
+
expect(players[0]?.name).toBe("Alice");
|
|
109
|
+
expect(players[1]?.name).toBe("Bob");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("Type Safety", () => {
|
|
114
|
+
it("should return Player objects with correct structure", () => {
|
|
115
|
+
const players = createTestPlayers(1);
|
|
116
|
+
|
|
117
|
+
expect(players[0]).toHaveProperty("id");
|
|
118
|
+
expect(players[0]).toHaveProperty("name");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should have PlayerId branded type", () => {
|
|
122
|
+
const players = createTestPlayers(1);
|
|
123
|
+
|
|
124
|
+
// Runtime check - ID should be a string
|
|
125
|
+
expect(typeof players[0]?.id).toBe("string");
|
|
126
|
+
|
|
127
|
+
// Type-level check happens at compile time
|
|
128
|
+
// The following would fail TypeScript compilation if Player.id wasn't PlayerId:
|
|
129
|
+
// const playerId: PlayerId = players[0].id;
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Player } from "../game-definition/game-definition";
|
|
2
|
+
import { createPlayerId } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Test Player Builder
|
|
6
|
+
*
|
|
7
|
+
* Task 2.4: Implement createTestPlayers(count, names?)
|
|
8
|
+
*
|
|
9
|
+
* Creates an array of test players with unique IDs and names.
|
|
10
|
+
* Simplifies test setup by reducing boilerplate player creation.
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - Generates unique PlayerId for each player
|
|
14
|
+
* - Uses custom names if provided, otherwise generates default names
|
|
15
|
+
* - Deterministic IDs based on player index for reproducible tests
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* // Create 2 players with default names
|
|
20
|
+
* const players = createTestPlayers(2);
|
|
21
|
+
* // [{ id: 'test-p1', name: 'Player 1' }, { id: 'test-p2', name: 'Player 2' }]
|
|
22
|
+
*
|
|
23
|
+
* // Create players with custom names
|
|
24
|
+
* const players = createTestPlayers(3, ['Alice', 'Bob', 'Charlie']);
|
|
25
|
+
* // [{ id: 'test-p1', name: 'Alice' }, ...]
|
|
26
|
+
*
|
|
27
|
+
* // Partial custom names
|
|
28
|
+
* const players = createTestPlayers(3, ['Alice']);
|
|
29
|
+
* // [{ id: 'test-p1', name: 'Alice' }, { id: 'test-p2', name: 'Player 2' }, ...]
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function createTestPlayers(count: number, names?: string[]): Player[] {
|
|
33
|
+
const players: Player[] = [];
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < count; i++) {
|
|
36
|
+
const playerId = createPlayerId(`test-p${i + 1}`);
|
|
37
|
+
const playerName = names?.[i] ?? `Player ${i + 1}`;
|
|
38
|
+
|
|
39
|
+
players.push({
|
|
40
|
+
id: playerId,
|
|
41
|
+
name: playerName,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return players;
|
|
46
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
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, type PlayerId } from "../types";
|
|
6
|
+
import { expectDeterministicReplay } from "./test-replay-assertions";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Test state for replay assertions
|
|
10
|
+
*/
|
|
11
|
+
type ReplayTestState = {
|
|
12
|
+
players: Array<{
|
|
13
|
+
id: PlayerId;
|
|
14
|
+
name: string;
|
|
15
|
+
score: number;
|
|
16
|
+
}>;
|
|
17
|
+
turnNumber: number;
|
|
18
|
+
randomValues: number[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ReplayTestMoves = {
|
|
22
|
+
addRandom: Record<string, never>;
|
|
23
|
+
incrementScore: { amount: number };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("test-replay-assertions", () => {
|
|
27
|
+
function createTestEngine(seed?: string) {
|
|
28
|
+
const moves: GameMoveDefinitions<ReplayTestState, ReplayTestMoves> = {
|
|
29
|
+
addRandom: {
|
|
30
|
+
reducer: (draft, context) => {
|
|
31
|
+
// Use RNG to add random value
|
|
32
|
+
const value = context.rng?.randomInt(1, 100) ?? 0;
|
|
33
|
+
draft.randomValues.push(value);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
incrementScore: {
|
|
37
|
+
reducer: (draft, context) => {
|
|
38
|
+
const player = draft.players.find((p) => p.id === context.playerId);
|
|
39
|
+
if (player && context.params?.amount) {
|
|
40
|
+
player.score += context.params.amount as number;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const gameDefinition: GameDefinition<ReplayTestState, ReplayTestMoves> = {
|
|
47
|
+
name: "Replay Test Game",
|
|
48
|
+
setup: (players) => ({
|
|
49
|
+
players: players.map((p) => ({
|
|
50
|
+
id: p.id as PlayerId,
|
|
51
|
+
name: p.name || "Player",
|
|
52
|
+
score: 0,
|
|
53
|
+
})),
|
|
54
|
+
turnNumber: 1,
|
|
55
|
+
randomValues: [],
|
|
56
|
+
}),
|
|
57
|
+
moves,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const players = [
|
|
61
|
+
{ id: createPlayerId("p1"), name: "Alice" },
|
|
62
|
+
{ id: createPlayerId("p2"), name: "Bob" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
return new RuleEngine(gameDefinition, players, { seed });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("expectDeterministicReplay", () => {
|
|
69
|
+
it("should pass when replay produces same state", () => {
|
|
70
|
+
const engine = createTestEngine("replay-seed");
|
|
71
|
+
|
|
72
|
+
// Execute some moves
|
|
73
|
+
engine.executeMove("incrementScore", {
|
|
74
|
+
playerId: createPlayerId("p1"),
|
|
75
|
+
params: { amount: 5 },
|
|
76
|
+
});
|
|
77
|
+
engine.executeMove("addRandom", {
|
|
78
|
+
playerId: createPlayerId("p1"),
|
|
79
|
+
params: {},
|
|
80
|
+
});
|
|
81
|
+
engine.executeMove("incrementScore", {
|
|
82
|
+
playerId: createPlayerId("p2"),
|
|
83
|
+
params: { amount: 3 },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Should not throw
|
|
87
|
+
expectDeterministicReplay(engine);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should verify replay with RNG operations", () => {
|
|
91
|
+
const engine = createTestEngine("rng-replay-seed");
|
|
92
|
+
|
|
93
|
+
// Execute moves that use RNG
|
|
94
|
+
for (let i = 0; i < 10; i++) {
|
|
95
|
+
engine.executeMove("addRandom", {
|
|
96
|
+
playerId: createPlayerId("p1"),
|
|
97
|
+
params: {},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Replay should produce exact same random values
|
|
102
|
+
expectDeterministicReplay(engine);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should work with complex move sequences", () => {
|
|
106
|
+
const engine = createTestEngine("complex-replay");
|
|
107
|
+
|
|
108
|
+
// Complex sequence
|
|
109
|
+
engine.executeMove("incrementScore", {
|
|
110
|
+
playerId: createPlayerId("p1"),
|
|
111
|
+
params: { amount: 1 },
|
|
112
|
+
});
|
|
113
|
+
engine.executeMove("addRandom", {
|
|
114
|
+
playerId: createPlayerId("p1"),
|
|
115
|
+
params: {},
|
|
116
|
+
});
|
|
117
|
+
engine.executeMove("incrementScore", {
|
|
118
|
+
playerId: createPlayerId("p2"),
|
|
119
|
+
params: { amount: 2 },
|
|
120
|
+
});
|
|
121
|
+
engine.executeMove("addRandom", {
|
|
122
|
+
playerId: createPlayerId("p2"),
|
|
123
|
+
params: {},
|
|
124
|
+
});
|
|
125
|
+
engine.executeMove("incrementScore", {
|
|
126
|
+
playerId: createPlayerId("p1"),
|
|
127
|
+
params: { amount: 3 },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expectDeterministicReplay(engine);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should detect non-deterministic behavior", () => {
|
|
134
|
+
// Create engine with non-deterministic move
|
|
135
|
+
let callCount = 0;
|
|
136
|
+
const moves: GameMoveDefinitions<ReplayTestState, ReplayTestMoves> = {
|
|
137
|
+
addRandom: {
|
|
138
|
+
reducer: (draft) => {
|
|
139
|
+
// Add non-deterministic behavior
|
|
140
|
+
callCount++;
|
|
141
|
+
draft.randomValues.push(callCount); // Different each time
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
incrementScore: {
|
|
145
|
+
reducer: () => {},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const gameDefinition: GameDefinition<ReplayTestState, ReplayTestMoves> = {
|
|
150
|
+
name: "Non-deterministic Test",
|
|
151
|
+
setup: (players) => ({
|
|
152
|
+
players: players.map((p) => ({
|
|
153
|
+
id: p.id as PlayerId,
|
|
154
|
+
name: p.name || "Player",
|
|
155
|
+
score: 0,
|
|
156
|
+
})),
|
|
157
|
+
turnNumber: 1,
|
|
158
|
+
randomValues: [],
|
|
159
|
+
}),
|
|
160
|
+
moves,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const engine = new RuleEngine(
|
|
164
|
+
gameDefinition,
|
|
165
|
+
[{ id: createPlayerId("p1"), name: "Alice" }],
|
|
166
|
+
{ seed: "non-deterministic" },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
engine.executeMove("addRandom", {
|
|
170
|
+
playerId: createPlayerId("p1"),
|
|
171
|
+
params: {},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Should throw because replay will have different state
|
|
175
|
+
expect(() => {
|
|
176
|
+
expectDeterministicReplay(engine);
|
|
177
|
+
}).toThrow(/Replay produced different state/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should work with empty history", () => {
|
|
181
|
+
const engine = createTestEngine("empty-seed");
|
|
182
|
+
|
|
183
|
+
// No moves executed
|
|
184
|
+
expectDeterministicReplay(engine);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should work with single move", () => {
|
|
188
|
+
const engine = createTestEngine("single-move");
|
|
189
|
+
|
|
190
|
+
engine.executeMove("incrementScore", {
|
|
191
|
+
playerId: createPlayerId("p1"),
|
|
192
|
+
params: { amount: 10 },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expectDeterministicReplay(engine);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should provide helpful error message on mismatch", () => {
|
|
199
|
+
// Create problematic engine
|
|
200
|
+
let isReplay = false;
|
|
201
|
+
const moves: GameMoveDefinitions<ReplayTestState, ReplayTestMoves> = {
|
|
202
|
+
addRandom: {
|
|
203
|
+
reducer: (draft, context) => {
|
|
204
|
+
if (isReplay) {
|
|
205
|
+
// Different behavior on replay
|
|
206
|
+
draft.randomValues.push(999);
|
|
207
|
+
} else {
|
|
208
|
+
const value = context.rng?.randomInt(1, 100) ?? 0;
|
|
209
|
+
draft.randomValues.push(value);
|
|
210
|
+
isReplay = true; // Flag for next call
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
incrementScore: { reducer: () => {} },
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const gameDefinition: GameDefinition<ReplayTestState, ReplayTestMoves> = {
|
|
218
|
+
name: "Mismatch Test",
|
|
219
|
+
setup: (players) => ({
|
|
220
|
+
players: players.map((p) => ({
|
|
221
|
+
id: p.id as PlayerId,
|
|
222
|
+
name: p.name || "Player",
|
|
223
|
+
score: 0,
|
|
224
|
+
})),
|
|
225
|
+
turnNumber: 1,
|
|
226
|
+
randomValues: [],
|
|
227
|
+
}),
|
|
228
|
+
moves,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const engine = new RuleEngine(
|
|
232
|
+
gameDefinition,
|
|
233
|
+
[{ id: createPlayerId("p1"), name: "Alice" }],
|
|
234
|
+
{ seed: "mismatch" },
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
engine.executeMove("addRandom", {
|
|
238
|
+
playerId: createPlayerId("p1"),
|
|
239
|
+
params: {},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(() => {
|
|
243
|
+
expectDeterministicReplay(engine);
|
|
244
|
+
}).toThrow(/Replay produced different state/);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("integration with real game scenarios", () => {
|
|
249
|
+
it("should verify card shuffling is deterministic", () => {
|
|
250
|
+
type CardGameState = {
|
|
251
|
+
players: Array<{ id: PlayerId; name: string }>;
|
|
252
|
+
deck: string[];
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
type CardMoves = {
|
|
256
|
+
shuffle: Record<string, never>;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const moves: GameMoveDefinitions<CardGameState, CardMoves> = {
|
|
260
|
+
shuffle: {
|
|
261
|
+
reducer: (draft, context) => {
|
|
262
|
+
if (context.rng) {
|
|
263
|
+
draft.deck = context.rng.shuffle(draft.deck);
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const gameDefinition: GameDefinition<CardGameState, CardMoves> = {
|
|
270
|
+
name: "Card Shuffle Test",
|
|
271
|
+
setup: (players) => ({
|
|
272
|
+
players: players.map((p) => ({
|
|
273
|
+
id: p.id as PlayerId,
|
|
274
|
+
name: p.name || "Player",
|
|
275
|
+
})),
|
|
276
|
+
deck: ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"],
|
|
277
|
+
}),
|
|
278
|
+
moves,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const engine = new RuleEngine(
|
|
282
|
+
gameDefinition,
|
|
283
|
+
[{ id: createPlayerId("p1"), name: "Alice" }],
|
|
284
|
+
{ seed: "shuffle-test" },
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Shuffle multiple times
|
|
288
|
+
engine.executeMove("shuffle", {
|
|
289
|
+
playerId: createPlayerId("p1"),
|
|
290
|
+
params: {},
|
|
291
|
+
});
|
|
292
|
+
engine.executeMove("shuffle", {
|
|
293
|
+
playerId: createPlayerId("p1"),
|
|
294
|
+
params: {},
|
|
295
|
+
});
|
|
296
|
+
engine.executeMove("shuffle", {
|
|
297
|
+
playerId: createPlayerId("p1"),
|
|
298
|
+
params: {},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Replay should produce same sequence of shuffles
|
|
302
|
+
expectDeterministicReplay(engine);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("should verify dice rolls are deterministic", () => {
|
|
306
|
+
type DiceGameState = {
|
|
307
|
+
players: Array<{ id: PlayerId; name: string }>;
|
|
308
|
+
rolls: number[];
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
type DiceMoves = {
|
|
312
|
+
roll: Record<string, never>;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const moves: GameMoveDefinitions<DiceGameState, DiceMoves> = {
|
|
316
|
+
roll: {
|
|
317
|
+
reducer: (draft, context) => {
|
|
318
|
+
if (context.rng) {
|
|
319
|
+
const roll = context.rng.rollDice(20) as number;
|
|
320
|
+
draft.rolls.push(roll);
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const gameDefinition: GameDefinition<DiceGameState, DiceMoves> = {
|
|
327
|
+
name: "Dice Roll Test",
|
|
328
|
+
setup: (players) => ({
|
|
329
|
+
players: players.map((p) => ({
|
|
330
|
+
id: p.id as PlayerId,
|
|
331
|
+
name: p.name || "Player",
|
|
332
|
+
})),
|
|
333
|
+
rolls: [],
|
|
334
|
+
}),
|
|
335
|
+
moves,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const engine = new RuleEngine(
|
|
339
|
+
gameDefinition,
|
|
340
|
+
[{ id: createPlayerId("p1"), name: "Alice" }],
|
|
341
|
+
{ seed: "dice-test" },
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// Roll dice multiple times
|
|
345
|
+
for (let i = 0; i < 20; i++) {
|
|
346
|
+
engine.executeMove("roll", {
|
|
347
|
+
playerId: createPlayerId("p1"),
|
|
348
|
+
params: {},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Replay should produce same dice rolls
|
|
353
|
+
expectDeterministicReplay(engine);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|