@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.
- package/README.md +454 -0
- package/dist/index.cjs +5432 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2150 -0
- package/dist/index.d.ts +2150 -0
- package/dist/index.js +5165 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
- package/src/client/helpers/hud.ts +114 -0
- package/src/client/helpers/prediction.ts +136 -0
- package/src/client/helpers/view.ts +201 -0
- package/src/client/mocks/console.ts +75 -0
- package/src/client/mocks/download.ts +48 -0
- package/src/client/mocks/input.ts +246 -0
- package/src/client/mocks/network.ts +148 -0
- package/src/client/mocks/state.ts +148 -0
- package/src/e2e/network.ts +47 -0
- package/src/e2e/playwright.ts +90 -0
- package/src/e2e/visual.ts +172 -0
- package/src/engine/helpers/pipeline-test-template.ts +113 -0
- package/src/engine/helpers/webgpu-rendering.ts +251 -0
- package/src/engine/mocks/assets.ts +129 -0
- package/src/engine/mocks/audio.ts +152 -0
- package/src/engine/mocks/buffers.ts +88 -0
- package/src/engine/mocks/lighting.ts +64 -0
- package/src/engine/mocks/particles.ts +76 -0
- package/src/engine/mocks/renderer.ts +218 -0
- package/src/engine/mocks/webgl.ts +267 -0
- package/src/engine/mocks/webgpu.ts +262 -0
- package/src/engine/rendering.ts +103 -0
- package/src/game/factories.ts +204 -0
- package/src/game/helpers/physics.ts +171 -0
- package/src/game/helpers/save.ts +232 -0
- package/src/game/helpers.ts +310 -0
- package/src/game/mocks/ai.ts +67 -0
- package/src/game/mocks/combat.ts +61 -0
- package/src/game/mocks/items.ts +166 -0
- package/src/game/mocks.ts +105 -0
- package/src/index.ts +93 -0
- package/src/server/helpers/bandwidth.ts +127 -0
- package/src/server/helpers/multiplayer.ts +158 -0
- package/src/server/helpers/snapshot.ts +241 -0
- package/src/server/mockNetDriver.ts +106 -0
- package/src/server/mockTransport.ts +50 -0
- package/src/server/mocks/commands.ts +93 -0
- package/src/server/mocks/connection.ts +139 -0
- package/src/server/mocks/master.ts +97 -0
- package/src/server/mocks/physics.ts +32 -0
- package/src/server/mocks/state.ts +162 -0
- package/src/server/mocks/transport.ts +161 -0
- package/src/setup/audio.ts +118 -0
- package/src/setup/browser.ts +249 -0
- package/src/setup/canvas.ts +142 -0
- package/src/setup/node.ts +21 -0
- package/src/setup/storage.ts +60 -0
- package/src/setup/timing.ts +142 -0
- package/src/setup/webgl.ts +8 -0
- package/src/setup/webgpu.ts +113 -0
- package/src/shared/bsp.ts +145 -0
- package/src/shared/collision.ts +64 -0
- package/src/shared/factories.ts +88 -0
- package/src/shared/math.ts +65 -0
- package/src/shared/mocks.ts +243 -0
- package/src/shared/pak-loader.ts +45 -0
- 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
|
+
}
|