@quake2ts/test-utils 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +454 -0
  2. package/dist/index.cjs +5432 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2150 -0
  5. package/dist/index.d.ts +2150 -0
  6. package/dist/index.js +5165 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +82 -0
  9. package/src/client/helpers/hud.ts +114 -0
  10. package/src/client/helpers/prediction.ts +136 -0
  11. package/src/client/helpers/view.ts +201 -0
  12. package/src/client/mocks/console.ts +75 -0
  13. package/src/client/mocks/download.ts +48 -0
  14. package/src/client/mocks/input.ts +246 -0
  15. package/src/client/mocks/network.ts +148 -0
  16. package/src/client/mocks/state.ts +148 -0
  17. package/src/e2e/network.ts +47 -0
  18. package/src/e2e/playwright.ts +90 -0
  19. package/src/e2e/visual.ts +172 -0
  20. package/src/engine/helpers/pipeline-test-template.ts +113 -0
  21. package/src/engine/helpers/webgpu-rendering.ts +251 -0
  22. package/src/engine/mocks/assets.ts +129 -0
  23. package/src/engine/mocks/audio.ts +152 -0
  24. package/src/engine/mocks/buffers.ts +88 -0
  25. package/src/engine/mocks/lighting.ts +64 -0
  26. package/src/engine/mocks/particles.ts +76 -0
  27. package/src/engine/mocks/renderer.ts +218 -0
  28. package/src/engine/mocks/webgl.ts +267 -0
  29. package/src/engine/mocks/webgpu.ts +262 -0
  30. package/src/engine/rendering.ts +103 -0
  31. package/src/game/factories.ts +204 -0
  32. package/src/game/helpers/physics.ts +171 -0
  33. package/src/game/helpers/save.ts +232 -0
  34. package/src/game/helpers.ts +310 -0
  35. package/src/game/mocks/ai.ts +67 -0
  36. package/src/game/mocks/combat.ts +61 -0
  37. package/src/game/mocks/items.ts +166 -0
  38. package/src/game/mocks.ts +105 -0
  39. package/src/index.ts +93 -0
  40. package/src/server/helpers/bandwidth.ts +127 -0
  41. package/src/server/helpers/multiplayer.ts +158 -0
  42. package/src/server/helpers/snapshot.ts +241 -0
  43. package/src/server/mockNetDriver.ts +106 -0
  44. package/src/server/mockTransport.ts +50 -0
  45. package/src/server/mocks/commands.ts +93 -0
  46. package/src/server/mocks/connection.ts +139 -0
  47. package/src/server/mocks/master.ts +97 -0
  48. package/src/server/mocks/physics.ts +32 -0
  49. package/src/server/mocks/state.ts +162 -0
  50. package/src/server/mocks/transport.ts +161 -0
  51. package/src/setup/audio.ts +118 -0
  52. package/src/setup/browser.ts +249 -0
  53. package/src/setup/canvas.ts +142 -0
  54. package/src/setup/node.ts +21 -0
  55. package/src/setup/storage.ts +60 -0
  56. package/src/setup/timing.ts +142 -0
  57. package/src/setup/webgl.ts +8 -0
  58. package/src/setup/webgpu.ts +113 -0
  59. package/src/shared/bsp.ts +145 -0
  60. package/src/shared/collision.ts +64 -0
  61. package/src/shared/factories.ts +88 -0
  62. package/src/shared/math.ts +65 -0
  63. package/src/shared/mocks.ts +243 -0
  64. package/src/shared/pak-loader.ts +45 -0
  65. package/src/visual/snapshots.ts +292 -0
@@ -0,0 +1,204 @@
1
+ import {
2
+ Entity,
3
+ MoveType,
4
+ Solid,
5
+ ServerFlags,
6
+ DeadFlag,
7
+ EntityFlags
8
+ } from '@quake2ts/game';
9
+ import type { PlayerState, EntityState } from '@quake2ts/shared';
10
+ import type { GameStateSnapshot } from '@quake2ts/game';
11
+
12
+ // -- Shared / Game State Factories --
13
+
14
+ export const createPlayerStateFactory = (overrides?: Partial<PlayerState>): PlayerState => ({
15
+ pm_type: 0,
16
+ pm_time: 0,
17
+ pm_flags: 0,
18
+ origin: { x: 0, y: 0, z: 0 },
19
+ velocity: { x: 0, y: 0, z: 0 },
20
+ viewAngles: { x: 0, y: 0, z: 0 },
21
+ onGround: false,
22
+ waterLevel: 0,
23
+ watertype: 0,
24
+ mins: { x: 0, y: 0, z: 0 },
25
+ maxs: { x: 0, y: 0, z: 0 },
26
+ damageAlpha: 0,
27
+ damageIndicators: [],
28
+ blend: [0, 0, 0, 0],
29
+ stats: [],
30
+ kick_angles: { x: 0, y: 0, z: 0 },
31
+ kick_origin: { x: 0, y: 0, z: 0 },
32
+ gunoffset: { x: 0, y: 0, z: 0 },
33
+ gunangles: { x: 0, y: 0, z: 0 },
34
+ gunindex: 0,
35
+ gun_frame: 0,
36
+ rdflags: 0,
37
+ fov: 90,
38
+ renderfx: 0,
39
+ ...overrides,
40
+ });
41
+
42
+ export const createEntityStateFactory = (overrides?: Partial<EntityState>): EntityState => ({
43
+ number: 0,
44
+ origin: { x: 0, y: 0, z: 0 },
45
+ angles: { x: 0, y: 0, z: 0 },
46
+ oldOrigin: { x: 0, y: 0, z: 0 },
47
+ modelIndex: 0,
48
+ modelIndex2: 0,
49
+ modelIndex3: 0,
50
+ modelIndex4: 0,
51
+ frame: 0,
52
+ skinNum: 0,
53
+ effects: 0,
54
+ renderfx: 0,
55
+ solid: 0,
56
+ sound: 0,
57
+ event: 0,
58
+ ...overrides,
59
+ });
60
+
61
+ export const createGameStateSnapshotFactory = (overrides?: Partial<GameStateSnapshot>): GameStateSnapshot => ({
62
+ gravity: { x: 0, y: 0, z: -800 },
63
+ origin: { x: 0, y: 0, z: 0 },
64
+ velocity: { x: 0, y: 0, z: 0 },
65
+ viewangles: { x: 0, y: 0, z: 0 },
66
+ level: { timeSeconds: 0, frameNumber: 0, previousTimeSeconds: 0, deltaSeconds: 0.1 },
67
+ entities: {
68
+ activeCount: 0,
69
+ worldClassname: 'worldspawn',
70
+ },
71
+ packetEntities: [],
72
+ pmFlags: 0,
73
+ pmType: 0,
74
+ waterlevel: 0,
75
+ watertype: 0,
76
+ deltaAngles: { x: 0, y: 0, z: 0 },
77
+ health: 100,
78
+ armor: 0,
79
+ ammo: 0,
80
+ blend: [0, 0, 0, 0],
81
+ damageAlpha: 0,
82
+ damageIndicators: [],
83
+ stats: [],
84
+ kick_angles: { x: 0, y: 0, z: 0 },
85
+ kick_origin: { x: 0, y: 0, z: 0 },
86
+ gunoffset: { x: 0, y: 0, z: 0 },
87
+ gunangles: { x: 0, y: 0, z: 0 },
88
+ gunindex: 0,
89
+ pm_time: 0,
90
+ gun_frame: 0,
91
+ rdflags: 0,
92
+ fov: 90,
93
+ renderfx: 0,
94
+ pm_flags: 0,
95
+ pm_type: 0,
96
+ ...overrides,
97
+ });
98
+
99
+ // -- Entity Factories --
100
+
101
+ // Helper to remove internal fields that shouldn't be copied via Object.assign,
102
+ // but PRESERVE the Entity prototype so getters/setters/methods work.
103
+ function sanitizeEntity(ent: Entity): Partial<Entity> {
104
+ // We modify the instance in place (it's a factory-created one, so safe to mutate).
105
+ // We want to delete properties that would conflict with EntitySystem internals
106
+ // if this object is merged into another Entity via Object.assign.
107
+
108
+ // Actually, Object.assign(target, source) only copies enumerable own properties.
109
+ // If we delete them from 'ent', they won't be copied.
110
+ // BUT 'ent' must still be a valid Entity for tests that use the factory result directly.
111
+
112
+ // The issue in `dm-spawn.test.ts` was:
113
+ // Object.assign(player, createPlayerEntityFactory(...))
114
+ // 'player' is a REAL entity from the system.
115
+ // 'createPlayerEntityFactory' returns an Entity.
116
+ // 'Entity' class defines 'linkNext = null' etc as instance properties.
117
+ // So Object.assign copies 'linkNext: null'.
118
+
119
+ // We need to remove these properties from the returned object so Object.assign doesn't copy them.
120
+ // But we want to keep the prototype.
121
+
122
+ const safe = ent as any;
123
+ delete safe.index;
124
+ delete safe.inUse;
125
+ delete safe.freePending;
126
+ delete safe.linkPrevious;
127
+ delete safe.linkNext;
128
+ delete safe.linkcount;
129
+
130
+ return ent;
131
+ }
132
+
133
+ export function createEntityFactory(overrides: Partial<Entity> = {}): Partial<Entity> {
134
+ const ent = new Entity(1);
135
+ Object.assign(ent, {
136
+ classname: 'info_null',
137
+ health: 0,
138
+ max_health: 0,
139
+ takedamage: false,
140
+ deadflag: DeadFlag.Alive,
141
+ solid: Solid.Not,
142
+ movetype: MoveType.None,
143
+ flags: 0,
144
+ svflags: 0,
145
+ ...overrides
146
+ });
147
+ return sanitizeEntity(ent);
148
+ }
149
+
150
+ export function createPlayerEntityFactory(overrides: Partial<Entity> = {}): Partial<Entity> {
151
+ return createEntityFactory({
152
+ classname: 'player',
153
+ health: 100,
154
+ max_health: 100,
155
+ takedamage: true,
156
+ solid: Solid.BoundingBox,
157
+ movetype: MoveType.Walk,
158
+ svflags: ServerFlags.Player,
159
+ viewheight: 22,
160
+ ...overrides
161
+ });
162
+ }
163
+
164
+ export function createMonsterEntityFactory(classname: string, overrides: Partial<Entity> = {}): Partial<Entity> {
165
+ return createEntityFactory({
166
+ classname,
167
+ health: 100,
168
+ max_health: 100,
169
+ takedamage: true,
170
+ solid: Solid.BoundingBox,
171
+ movetype: MoveType.Step,
172
+ svflags: ServerFlags.Monster,
173
+ deadflag: DeadFlag.Alive,
174
+ ...overrides
175
+ });
176
+ }
177
+
178
+ export function createItemEntityFactory(classname: string, overrides: Partial<Entity> = {}): Partial<Entity> {
179
+ return createEntityFactory({
180
+ classname,
181
+ solid: Solid.Trigger,
182
+ movetype: MoveType.Toss,
183
+ ...overrides
184
+ });
185
+ }
186
+
187
+ export function createProjectileEntityFactory(classname: string, overrides: Partial<Entity> = {}): Partial<Entity> {
188
+ return createEntityFactory({
189
+ classname,
190
+ solid: Solid.Bsp,
191
+ movetype: MoveType.FlyMissile,
192
+ svflags: ServerFlags.Projectile,
193
+ ...overrides
194
+ });
195
+ }
196
+
197
+ export function createTriggerEntityFactory(classname: string, overrides: Partial<Entity> = {}): Partial<Entity> {
198
+ return createEntityFactory({
199
+ classname,
200
+ solid: Solid.Trigger,
201
+ movetype: MoveType.None,
202
+ ...overrides
203
+ });
204
+ }
@@ -0,0 +1,171 @@
1
+ import { Entity, MoveType, Solid, EntitySystem } from '@quake2ts/game';
2
+ import { Vec3, copyVec3, TraceResult, addVec3, subtractVec3, scaleVec3, lengthVec3, normalizeVec3, dotVec3 } from '@quake2ts/shared';
3
+ import { TestContext } from '../helpers.js';
4
+ import { intersects, createTraceMock } from '../../shared/collision.js';
5
+
6
+ /**
7
+ * Creates a physics test scenario with pre-configured geometry.
8
+ * NOTE: Since real BSP loading is complex, this often uses block entities
9
+ * or mocked trace functions unless a real BSP is loaded in the context.
10
+ */
11
+ export interface PhysicsScenario {
12
+ ground: Entity;
13
+ walls: Entity[];
14
+ setup: (context: TestContext) => void;
15
+ }
16
+
17
+ export function createPhysicsTestScenario(
18
+ scenarioType: 'stairs' | 'ladder' | 'platform' | 'slope' | 'room',
19
+ context: TestContext
20
+ ): PhysicsScenario {
21
+ const walls: Entity[] = [];
22
+ const ground = context.entities.spawn();
23
+ ground.classname = 'func_wall';
24
+ ground.solid = Solid.Bsp;
25
+ ground.movetype = MoveType.Push;
26
+ ground.origin = { x: 0, y: 0, z: -10 };
27
+ ground.mins = { x: -1000, y: -1000, z: -10 };
28
+ ground.maxs = { x: 1000, y: 1000, z: 0 };
29
+ context.entities.linkentity(ground);
30
+
31
+ const setupStairs = () => {
32
+ // Create steps
33
+ for (let i = 0; i < 5; i++) {
34
+ const step = context.entities.spawn();
35
+ step.classname = 'func_wall';
36
+ step.solid = Solid.Bsp;
37
+ step.origin = { x: 100 + i * 32, y: 0, z: i * 16 };
38
+ step.mins = { x: 0, y: -64, z: 0 };
39
+ step.maxs = { x: 32, y: 64, z: 16 };
40
+ context.entities.linkentity(step);
41
+ walls.push(step);
42
+ }
43
+ };
44
+
45
+ const setupLadder = () => {
46
+ const ladder = context.entities.spawn();
47
+ ladder.classname = 'func_wall'; // Or func_ladder if available
48
+ ladder.solid = Solid.Bsp;
49
+ ladder.origin = { x: 100, y: 0, z: 0 };
50
+ ladder.mins = { x: 0, y: -32, z: 0 };
51
+ ladder.maxs = { x: 10, y: 32, z: 200 };
52
+ // surfaceFlags is a numeric property if we cast to any or define it, usually on entity state/brush
53
+ // But Entity class might not expose it directly unless it has collision fields
54
+ // Assuming Entity has it or we just ignore for mock tests
55
+ (ladder as any).surfaceFlags = 1; // SURF_LADDER
56
+ context.entities.linkentity(ladder);
57
+ walls.push(ladder);
58
+ };
59
+
60
+ const setupPlatform = () => {
61
+ const plat = context.entities.spawn();
62
+ plat.classname = 'func_plat';
63
+ plat.solid = Solid.Bsp;
64
+ plat.movetype = MoveType.Push;
65
+ plat.origin = { x: 0, y: 0, z: 0 }; // Starts low
66
+ plat.mins = { x: -64, y: -64, z: 0 };
67
+ plat.maxs = { x: 64, y: 64, z: 10 };
68
+ // Platform logic handles movement, but for physics collision it's just a solid box moving
69
+ context.entities.linkentity(plat);
70
+ walls.push(plat);
71
+ };
72
+
73
+ if (scenarioType === 'stairs') setupStairs();
74
+ else if (scenarioType === 'ladder') setupLadder();
75
+ else if (scenarioType === 'platform') setupPlatform();
76
+
77
+ return {
78
+ ground,
79
+ walls,
80
+ setup: (ctx) => {
81
+ // Additional setup if needed
82
+ }
83
+ };
84
+ }
85
+
86
+
87
+ /**
88
+ * Simulates a single physics step for an entity.
89
+ * Uses the game's runPmove logic or manually invokes similar steps.
90
+ * This is useful for testing specific movement mechanics in isolation.
91
+ */
92
+ export function simulateMovement(entity: Entity, destination: Vec3, context: TestContext): TraceResult {
93
+ // Calculate velocity needed to reach destination in one frame (assuming 0.1s tick)
94
+ const dt = 0.1;
95
+ const delta = subtractVec3(destination, entity.origin);
96
+ const dist = lengthVec3(delta);
97
+
98
+ if (dist < 0.001) {
99
+ return createTraceMock({ fraction: 1.0, endpos: destination }) as unknown as TraceResult;
100
+ }
101
+
102
+ const dir = normalizeVec3(delta);
103
+ entity.velocity = scaleVec3(dir, dist / dt);
104
+
105
+ // Perform trace to check if movement is possible
106
+ const start = { ...entity.origin };
107
+ const end = destination;
108
+
109
+ // Check if context.entities.trace is a mocked function we can control or real one
110
+ // We assume the interface matches
111
+ const tr = context.entities.trace(start, entity.mins, entity.maxs, end, entity, (entity as any).clipmask || 0);
112
+
113
+ // Update origin if not stuck
114
+ if (!tr.startsolid && !tr.allsolid) {
115
+ // Trace result returns endpos which is where we stopped
116
+ if (tr.endpos) {
117
+ entity.origin = { ...tr.endpos };
118
+ } else {
119
+ // Fallback if trace result is simple
120
+ // In real engine trace always has endpos
121
+ entity.origin = { ...end }; // Should be tr.endpos really
122
+ }
123
+ context.entities.linkentity(entity);
124
+ }
125
+
126
+ // Return TraceResult, cast if needed as we don't import CollisionTraceResult specifically
127
+ return tr as unknown as TraceResult;
128
+ }
129
+
130
+ /**
131
+ * Simulates gravity application on an entity.
132
+ */
133
+ export function simulateGravity(entity: Entity, deltaTime: number, context: TestContext): void {
134
+ const gravity = (context.game as any).cvars?.gravity?.value ?? 800; // MockGame might not have cvars yet
135
+ if (entity.groundentity || !entity.movetype) return;
136
+
137
+ // Simple Euler integration for gravity
138
+ entity.velocity = {
139
+ x: entity.velocity.x,
140
+ y: entity.velocity.y,
141
+ z: entity.velocity.z - gravity * deltaTime
142
+ };
143
+
144
+ // Check ground after applying gravity velocity
145
+ // Simple check: trace down
146
+ const start = { ...entity.origin };
147
+ const end = { x: start.x, y: start.y, z: start.z - 0.25 };
148
+
149
+ const tr = context.entities.trace(start, entity.mins, entity.maxs, end, entity, (entity as any).clipmask || 0);
150
+
151
+ if (tr.fraction < 1.0) {
152
+ entity.groundentity = tr.ent || context.entities.world;
153
+ entity.velocity = { ...entity.velocity, z: 0 };
154
+ // Snap to ground
155
+ if (tr.endpos) {
156
+ entity.origin = { ...entity.origin, z: tr.endpos.z };
157
+ }
158
+ } else {
159
+ entity.groundentity = null; // Assigning null instead of undefined
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Simulates a jump action.
165
+ */
166
+ export function simulateJump(entity: Entity, context: TestContext): void {
167
+ if (!entity.groundentity) return;
168
+
169
+ entity.groundentity = null;
170
+ entity.velocity = { ...entity.velocity, z: 270 }; // Standard jump velocity
171
+ }
@@ -0,0 +1,232 @@
1
+ import { Entity, LevelState } from '@quake2ts/game';
2
+ import type { GameSaveFile } from '@quake2ts/game';
3
+ import { TestContext } from '../helpers.js';
4
+ import { Vec3 } from '@quake2ts/shared';
5
+
6
+ // If LevelFrameState is not exported from @quake2ts/game, we define a compatible interface here
7
+ export interface LevelFrameState {
8
+ readonly frameNumber: number;
9
+ readonly timeSeconds: number;
10
+ readonly previousTimeSeconds: number;
11
+ readonly deltaSeconds: number;
12
+ }
13
+
14
+ /**
15
+ * Interface for a mock save game object.
16
+ */
17
+ export interface MockSaveGame {
18
+ game: GameSaveFile;
19
+ entities: any[]; // Serialized entities
20
+ client: any; // Serialized client
21
+ level: LevelState;
22
+ timestamp: number;
23
+ }
24
+
25
+ /**
26
+ * Creates a mock SaveGame object.
27
+ *
28
+ * @param overrides - Optional overrides for save game properties.
29
+ * @returns A MockSaveGame object.
30
+ */
31
+ export function createMockSaveGame(overrides?: Partial<MockSaveGame>): MockSaveGame {
32
+ const defaultLevelState: LevelFrameState = {
33
+ frameNumber: 100,
34
+ timeSeconds: 10.0,
35
+ previousTimeSeconds: 9.9,
36
+ deltaSeconds: 0.1
37
+ };
38
+
39
+ const defaultLevel: LevelState = {
40
+ next_auto_save: 0,
41
+ health_bar_entities: [null, null, null, null],
42
+ intermission_angle: { x: 0, y: 0, z: 0 },
43
+ intermission_origin: { x: 0, y: 0, z: 0 },
44
+ helpmessage1: "",
45
+ helpmessage2: "",
46
+ help1changed: 0,
47
+ help2changed: 0,
48
+ mapname: 'test_map'
49
+ };
50
+
51
+ const defaultGame: GameSaveFile = {
52
+ version: 2,
53
+ timestamp: Date.now(),
54
+ map: 'test_map',
55
+ difficulty: 1,
56
+ playtimeSeconds: 100,
57
+ level: defaultLevelState,
58
+ entities: {
59
+ timeSeconds: 10.0,
60
+ pool: { capacity: 1024, activeOrder: [], freeList: [], pendingFree: [] },
61
+ entities: [],
62
+ thinks: [],
63
+ awareness: {
64
+ frameNumber: 0,
65
+ sightEntityIndex: null,
66
+ sightEntityFrame: 0,
67
+ soundEntityIndex: null,
68
+ soundEntityFrame: 0,
69
+ sound2EntityIndex: null,
70
+ sound2EntityFrame: 0,
71
+ sightClientIndex: null
72
+ },
73
+ crossLevelFlags: 0,
74
+ crossUnitFlags: 0,
75
+ level: defaultLevel
76
+ },
77
+ rng: { mt: { index: 0, state: [] } },
78
+ gameState: {},
79
+ cvars: [],
80
+ configstrings: []
81
+ };
82
+
83
+ return {
84
+ game: overrides?.game ?? defaultGame,
85
+ entities: overrides?.entities ?? [],
86
+ client: overrides?.client ?? {},
87
+ level: overrides?.level ?? defaultLevel,
88
+ timestamp: overrides?.timestamp ?? Date.now()
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Captures the current test context state as a save game snapshot.
94
+ * Note: This is a simplified snapshot for testing, not a full serialization.
95
+ *
96
+ * @param context - The test context to snapshot.
97
+ * @returns A MockSaveGame representing the current state.
98
+ */
99
+ export function createSaveGameSnapshot(context: TestContext): MockSaveGame {
100
+ const entities: any[] = [];
101
+ context.entities.forEachEntity((ent: Entity) => {
102
+ if (!ent.inUse) return;
103
+ // Simple serialization of essential fields
104
+ entities.push({
105
+ classname: ent.classname,
106
+ origin: { ...ent.origin },
107
+ angles: { ...ent.angles },
108
+ health: ent.health,
109
+ spawnflags: ent.spawnflags,
110
+ targetname: ent.targetname
111
+ });
112
+ });
113
+
114
+ // We need to construct a LevelFrameState for the save file
115
+ const levelFrameState: LevelFrameState = {
116
+ frameNumber: (context.game as any).level?.frameNumber ?? 0,
117
+ timeSeconds: (context.game as any).level?.timeSeconds ?? (context.game as any).time ?? 0,
118
+ previousTimeSeconds: 0,
119
+ deltaSeconds: 0.1
120
+ };
121
+
122
+ const currentLevel = (context.entities as any).level || {};
123
+
124
+ return {
125
+ game: {
126
+ version: 2,
127
+ timestamp: Date.now(),
128
+ map: 'snapshot_map',
129
+ difficulty: 0,
130
+ playtimeSeconds: (context.game as any).time ?? 0,
131
+ level: levelFrameState,
132
+ entities: context.entities.createSnapshot(),
133
+ rng: { mt: { index: 0, state: [] } },
134
+ gameState: {},
135
+ cvars: [],
136
+ configstrings: []
137
+ },
138
+ entities,
139
+ client: {},
140
+ level: currentLevel as LevelState,
141
+ timestamp: Date.now()
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Restores a test context from a save game snapshot.
147
+ *
148
+ * @param saveGame - The save game to restore.
149
+ * @param context - The test context to update.
150
+ */
151
+ export function restoreSaveGameSnapshot(saveGame: MockSaveGame, context: TestContext): void {
152
+ // Clear existing entities (except world/clients if needed, but for test usually clear all)
153
+ // Assuming context.entities has a way to reset or we just free all
154
+ // context.entities.reset() // if available
155
+
156
+ // Re-spawn entities
157
+ saveGame.entities.forEach(entData => {
158
+ const ent = context.entities.spawn();
159
+ ent.classname = entData.classname;
160
+ ent.origin = { ...entData.origin };
161
+ ent.angles = { ...entData.angles };
162
+ ent.health = entData.health;
163
+ ent.spawnflags = entData.spawnflags;
164
+ ent.targetname = entData.targetname;
165
+ context.entities.linkentity(ent);
166
+ });
167
+
168
+ // Restore level state
169
+ if ((context.entities as any).level) {
170
+ Object.assign((context.entities as any).level, saveGame.level);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Result of comparing two save games.
176
+ */
177
+ export interface SaveGameDiff {
178
+ entityCountDiff: number;
179
+ differentEntities: { index: number, field: string, expected: any, actual: any }[];
180
+ gameStateDiffs: string[];
181
+ }
182
+
183
+ /**
184
+ * Compares two save games and returns the differences.
185
+ * Useful for testing save/load determinism.
186
+ *
187
+ * @param a - First save game.
188
+ * @param b - Second save game.
189
+ * @returns A SaveGameDiff object.
190
+ */
191
+ export function compareSaveGames(a: MockSaveGame, b: MockSaveGame): SaveGameDiff {
192
+ const diffs: SaveGameDiff = {
193
+ entityCountDiff: a.entities.length - b.entities.length,
194
+ differentEntities: [],
195
+ gameStateDiffs: []
196
+ };
197
+
198
+ // Compare game state
199
+ const aTime = a.game.level.timeSeconds;
200
+ const bTime = b.game.level.timeSeconds;
201
+ if (aTime !== bTime) diffs.gameStateDiffs.push(`time: ${aTime} vs ${bTime}`);
202
+
203
+ const aFrame = a.game.level.frameNumber;
204
+ const bFrame = b.game.level.frameNumber;
205
+ if (aFrame !== bFrame) diffs.gameStateDiffs.push(`framenum: ${aFrame} vs ${bFrame}`);
206
+
207
+ // Compare entities (naive index matching)
208
+ const count = Math.min(a.entities.length, b.entities.length);
209
+ for (let i = 0; i < count; i++) {
210
+ const entA = a.entities[i];
211
+ const entB = b.entities[i];
212
+
213
+ if (entA.classname !== entB.classname) {
214
+ diffs.differentEntities.push({ index: i, field: 'classname', expected: entA.classname, actual: entB.classname });
215
+ }
216
+ if (entA.health !== entB.health) {
217
+ diffs.differentEntities.push({ index: i, field: 'health', expected: entA.health, actual: entB.health });
218
+ }
219
+ // Vector checks
220
+ if (!vec3Equals(entA.origin, entB.origin)) {
221
+ diffs.differentEntities.push({ index: i, field: 'origin', expected: entA.origin, actual: entB.origin });
222
+ }
223
+ }
224
+
225
+ return diffs;
226
+ }
227
+
228
+ function vec3Equals(a: Vec3, b: Vec3, epsilon = 0.001): boolean {
229
+ return Math.abs(a.x - b.x) < epsilon &&
230
+ Math.abs(a.y - b.y) < epsilon &&
231
+ Math.abs(a.z - b.z) < epsilon;
232
+ }