@ludra/core 0.7.0 → 0.8.0

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.
@@ -9,6 +9,8 @@ export interface ValidationResult {
9
9
  * Checks a parsed scene file. Every diagnostic names the exact JSON path and,
10
10
  * where possible, a fix an agent can apply without guessing.
11
11
  */
12
- export declare function validateScene(raw: unknown): ValidationResult;
12
+ export declare function validateScene(input: unknown): ValidationResult;
13
+ /** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
14
+ export declare function mergeComponents(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, Record<string, unknown>>;
13
15
  /** Returns the candidate within a small edit distance of `input`, if any. */
14
16
  export declare function closestName(input: string, candidates: readonly string[]): string | undefined;
package/dist/validate.js CHANGED
@@ -9,18 +9,22 @@ const REQUIRED_EXAMPLES = {
9
9
  * Checks a parsed scene file. Every diagnostic names the exact JSON path and,
10
10
  * where possible, a fix an agent can apply without guessing.
11
11
  */
12
- export function validateScene(raw) {
12
+ export function validateScene(input) {
13
13
  const diagnostics = [];
14
14
  const error = (code, path, message, fix) => diagnostics.push({ severity: "error", code, path, message, fix });
15
15
  const warn = (code, path, message, fix) => diagnostics.push({ severity: "warning", code, path, message, fix });
16
16
  const result = () => {
17
17
  const ok = !diagnostics.some((d) => d.severity === "error");
18
- return { ok, diagnostics, scene: ok ? raw : undefined };
18
+ return { ok, diagnostics, scene: ok ? scene : undefined };
19
19
  };
20
- if (!isObject(raw)) {
20
+ let scene = input;
21
+ if (!isObject(input)) {
21
22
  error("E_SCENE_NOT_OBJECT", "$", "A scene must be a JSON object.", 'Start from { "entities": [] }.');
22
23
  return result();
23
24
  }
25
+ // Prefabs become plain entities first, so everything below sees the final components.
26
+ const raw = expandPrefabs(input, error);
27
+ scene = raw;
24
28
  if (raw.world !== undefined) {
25
29
  if (!isObject(raw.world)) {
26
30
  error("E_WORLD_NOT_OBJECT", "world", '"world" must be an object.', 'Use "world": { "width": 960, "height": 540 }.');
@@ -38,6 +42,10 @@ export function validateScene(raw) {
38
42
  if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
39
43
  error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
40
44
  }
45
+ const lives = raw.world.lives;
46
+ if (lives !== undefined && (typeof lives !== "number" || !Number.isInteger(lives) || lives < 1)) {
47
+ error("E_INVALID_WORLD", "world.lives", `world.lives must be a whole number of at least 1, got ${describe(lives)}.`, "Leave it out for unlimited retries.");
48
+ }
41
49
  }
42
50
  }
43
51
  if (raw.effects !== undefined)
@@ -161,7 +169,68 @@ const SENSOR_COMPONENTS = [
161
169
  ["collectible", "players would bump into it instead of picking it up"],
162
170
  ["hazard", "players would stand on it instead of being hurt"],
163
171
  ["goal", "players would bump into it instead of reaching it"],
172
+ ["checkpoint", "players would bump into it instead of passing through"],
164
173
  ];
174
+ /**
175
+ * Replaces `"prefab": "<name>"` entities with the prefab's components, with the
176
+ * entity's own components merged over them (field by field; `null` removes a
177
+ * component). Returns a new scene object; reports unknown prefabs.
178
+ */
179
+ function expandPrefabs(raw, error) {
180
+ const prefabs = raw.prefabs;
181
+ if (prefabs !== undefined) {
182
+ if (!isObject(prefabs)) {
183
+ error("E_PREFABS_NOT_OBJECT", "prefabs", '"prefabs" must map names to { "components": { ... } }.');
184
+ return raw;
185
+ }
186
+ for (const [name, prefab] of Object.entries(prefabs)) {
187
+ if (!isObject(prefab) || !isObject(prefab.components)) {
188
+ error("E_INVALID_PREFAB", `prefabs.${name}`, `Prefab "${name}" needs a "components" object.`, 'Use { "components": { "body": { ... }, "sprite": { ... } } }.');
189
+ continue;
190
+ }
191
+ for (const component of Object.keys(prefab.components)) {
192
+ if (!isComponentName(component)) {
193
+ const suggestion = closestName(component, COMPONENT_NAMES);
194
+ error("E_UNKNOWN_COMPONENT", `prefabs.${name}.components.${component}`, `Unknown component "${component}" in prefab "${name}".`, suggestion ? `Did you mean "${suggestion}"?` : undefined);
195
+ }
196
+ }
197
+ }
198
+ }
199
+ if (!Array.isArray(raw.entities))
200
+ return raw;
201
+ const known = isObject(prefabs) ? prefabs : {};
202
+ const entities = raw.entities.map((entity, i) => {
203
+ if (!isObject(entity) || entity.prefab === undefined)
204
+ return entity;
205
+ const name = entity.prefab;
206
+ const prefab = typeof name === "string" ? known[name] : undefined;
207
+ if (!isObject(prefab) || !isObject(prefab.components)) {
208
+ const names = Object.keys(known);
209
+ const suggestion = typeof name === "string" ? closestName(name, names) : undefined;
210
+ error("E_UNKNOWN_PREFAB", `entities[${i}].prefab`, `Prefab ${describe(name)} does not exist.`, suggestion ? `Did you mean "${suggestion}"?` : names.length > 0 ? `Prefabs: ${names.join(", ")}.` : "Create prefabs/<name>.prefab.json with { \"components\": { ... } }.");
211
+ return entity;
212
+ }
213
+ const { prefab: _, ...rest } = entity;
214
+ return { ...rest, components: mergeComponents(prefab.components, isObject(entity.components) ? entity.components : {}) };
215
+ });
216
+ return { ...raw, entities };
217
+ }
218
+ /** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
219
+ export function mergeComponents(base, overrides) {
220
+ const merged = {};
221
+ for (const [name, value] of Object.entries(base))
222
+ if (isObject(value))
223
+ merged[name] = structuredClone(value);
224
+ for (const [name, value] of Object.entries(overrides)) {
225
+ if (value === null)
226
+ delete merged[name];
227
+ else if (isObject(value) && isObject(merged[name]))
228
+ merged[name] = { ...merged[name], ...structuredClone(value) };
229
+ else
230
+ merged[name] = structuredClone(value);
231
+ }
232
+ return merged;
233
+ }
165
234
  function numberOr(value, fallback) {
166
235
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
167
236
  }
package/dist/world.d.ts CHANGED
@@ -43,6 +43,7 @@ export interface Entity {
43
43
  patrol?: PatrolState;
44
44
  hazard?: Hazard;
45
45
  goal?: Goal;
46
+ checkpoint?: Record<string, never>;
46
47
  emitter?: Emitter;
47
48
  camera?: Camera;
48
49
  }
@@ -80,6 +81,18 @@ export type GameEvent = {
80
81
  type: "bounce";
81
82
  entity: string;
82
83
  other: string;
84
+ } | {
85
+ type: "checkpoint";
86
+ entity: string;
87
+ checkpoint: string;
88
+ } | {
89
+ type: "gameover";
90
+ entity: string;
91
+ cause: string;
92
+ } | {
93
+ type: "goto";
94
+ entity: string;
95
+ scene: string;
83
96
  }
84
97
  /** Fired by a script with world.emit(name, data). */
85
98
  | {
@@ -100,6 +113,12 @@ export interface EntitySnapshot {
100
113
  export interface WorldOptions {
101
114
  /** Loaded script modules, keyed by the `src` path used in the scene. */
102
115
  scripts?: ScriptModules;
116
+ /** Values carried over from the previous scene, so a run keeps its score and lives. */
117
+ carry?: {
118
+ score?: number;
119
+ deaths?: number;
120
+ lives?: number;
121
+ };
103
122
  }
104
123
  export interface WorldSnapshot {
105
124
  frame: number;
@@ -107,6 +126,9 @@ export interface WorldSnapshot {
107
126
  score: number;
108
127
  deaths: number;
109
128
  won: boolean;
129
+ lost: boolean;
130
+ /** Lives left; undefined when the scene has unlimited retries. */
131
+ lives?: number;
110
132
  entities: Record<string, EntitySnapshot>;
111
133
  }
112
134
  export declare class SceneValidationError extends Error {
@@ -130,6 +152,14 @@ export declare class World {
130
152
  deaths: number;
131
153
  /** Set when a player reaches a goal. The simulation stops changing after that. */
132
154
  won: boolean;
155
+ /** Set when the game is over: out of lives, or a script called world.lose(). */
156
+ lost: boolean;
157
+ /** Lives left, or undefined for unlimited retries. */
158
+ lives: number | undefined;
159
+ /** The scene to play next (a won goal's `next`, or world.goto). Hosts that can load scenes move on to it. */
160
+ exit: string | undefined;
161
+ private readonly prefabs;
162
+ private readonly reached;
133
163
  private previousInput;
134
164
  private readonly scripts;
135
165
  private seed;
@@ -145,6 +175,8 @@ export declare class World {
145
175
  /** Validates raw scene JSON and builds a world, throwing on errors. */
146
176
  static fromJSON(raw: unknown, options?: WorldOptions): World;
147
177
  get time(): number;
178
+ /** True once nothing more happens in this scene: won, lost, or moving on to another scene. */
179
+ get over(): boolean;
148
180
  get(id: string): Entity | undefined;
149
181
  /** Advances one frame with the given actions held down. */
150
182
  step(input: ReadonlySet<Action>): void;
@@ -192,6 +224,9 @@ export declare class World {
192
224
  private updateCollectibles;
193
225
  private updateHazards;
194
226
  private updateGoals;
227
+ /** A touched checkpoint becomes the player's respawn point, standing on the checkpoint's bottom edge. */
228
+ private updateCheckpoints;
229
+ private gameOver;
195
230
  /** Sends a player back to where it started. Collected items stay collected. */
196
231
  private kill;
197
232
  }
package/dist/world.js CHANGED
@@ -2,7 +2,7 @@ import { resolveComponent, } from "./components.js";
2
2
  import { getScript, ScriptError, } from "./script.js";
3
3
  import { FIXED_DT } from "./time.js";
4
4
  import { EVENT_TYPES } from "./types.js";
5
- import { validateScene } from "./validate.js";
5
+ import { mergeComponents, validateScene } from "./validate.js";
6
6
  export class SceneValidationError extends Error {
7
7
  diagnostics;
8
8
  constructor(diagnostics) {
@@ -43,6 +43,14 @@ export class World {
43
43
  deaths = 0;
44
44
  /** Set when a player reaches a goal. The simulation stops changing after that. */
45
45
  won = false;
46
+ /** Set when the game is over: out of lives, or a script called world.lose(). */
47
+ lost = false;
48
+ /** Lives left, or undefined for unlimited retries. */
49
+ lives;
50
+ /** The scene to play next (a won goal's `next`, or world.goto). Hosts that can load scenes move on to it. */
51
+ exit;
52
+ prefabs;
53
+ reached = new Set();
46
54
  previousInput = new Set();
47
55
  scripts;
48
56
  seed;
@@ -61,6 +69,10 @@ export class World {
61
69
  this.background = scene.world?.background ?? "#1d2333";
62
70
  this.seed = scene.world?.seed ?? 1;
63
71
  this.scripts = options.scripts;
72
+ this.prefabs = scene.prefabs ?? {};
73
+ this.score = options.carry?.score ?? 0;
74
+ this.deaths = options.carry?.deaths ?? 0;
75
+ this.lives = options.carry?.lives ?? scene.world?.lives;
64
76
  this.entities = scene.entities.map((def) => this.createEntity(def));
65
77
  }
66
78
  /** Validates raw scene JSON and builds a world, throwing on errors. */
@@ -73,6 +85,10 @@ export class World {
73
85
  get time() {
74
86
  return this.frame * FIXED_DT;
75
87
  }
88
+ /** True once nothing more happens in this scene: won, lost, or moving on to another scene. */
89
+ get over() {
90
+ return this.won || this.lost || this.exit !== undefined;
91
+ }
76
92
  get(id) {
77
93
  return this.entities.find((e) => e.id === id);
78
94
  }
@@ -81,20 +97,23 @@ export class World {
81
97
  const pressed = new Set([...input].filter((action) => !this.previousInput.has(action)));
82
98
  this.previousInput = new Set(input);
83
99
  this.frame++;
84
- if (this.won)
100
+ if (this.over)
85
101
  return;
86
102
  this.updatePlatformers(input, pressed);
87
103
  this.updateTopdown(input);
88
104
  this.updateScripts(input, pressed);
89
- if (this.won)
105
+ if (this.over)
90
106
  return;
91
107
  this.updatePatrols();
92
108
  this.updateBodies();
93
109
  this.updateTouches();
94
- if (this.won)
110
+ if (this.over)
95
111
  return;
96
112
  this.updateCollectibles();
113
+ this.updateCheckpoints();
97
114
  this.updateHazards();
115
+ if (this.over)
116
+ return;
98
117
  this.updateGoals();
99
118
  }
100
119
  snapshot() {
@@ -111,7 +130,7 @@ export class World {
111
130
  if (Object.keys(e.state).length > 0)
112
131
  entities[e.id].state = JSON.parse(JSON.stringify(e.state));
113
132
  }
114
- return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
133
+ return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, lost: this.lost, lives: this.lives, entities };
115
134
  }
116
135
  emit(event) {
117
136
  this.events.push({ ...event, frame: this.frame, time: this.time });
@@ -146,9 +165,9 @@ export class World {
146
165
  e.script.started = true;
147
166
  this.callHook(e, "start");
148
167
  }
149
- if (!e.removed && !this.won)
168
+ if (!e.removed && !this.over)
150
169
  this.callHook(e, "update");
151
- if (this.won)
170
+ if (this.over)
152
171
  return;
153
172
  }
154
173
  }
@@ -324,6 +343,23 @@ export class World {
324
343
  get won() {
325
344
  return world.won;
326
345
  },
346
+ get lost() {
347
+ return world.lost;
348
+ },
349
+ get exit() {
350
+ return world.exit;
351
+ },
352
+ get lives() {
353
+ return world.lives;
354
+ },
355
+ set lives(value) {
356
+ world.guard("Setting lives");
357
+ if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value)))
358
+ throw world.misuse("world.lives must be a number.");
359
+ world.lives = value;
360
+ if (value !== undefined && value <= 0)
361
+ world.gameOver(world.current?.entity.id ?? "script", "script");
362
+ },
327
363
  set score(value) {
328
364
  world.guard("Setting score");
329
365
  world.score = value;
@@ -365,6 +401,20 @@ export class World {
365
401
  world.won = true;
366
402
  world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
367
403
  },
404
+ lose: () => {
405
+ world.guard("world.lose");
406
+ world.gameOver(world.current?.entity.id ?? "script", "script");
407
+ },
408
+ goto: (scene) => {
409
+ world.guard("world.goto");
410
+ if (typeof scene !== "string" || scene === "")
411
+ throw world.misuse('world.goto needs a scene path, e.g. "scenes/level-1.scene.json".');
412
+ if (world.exit !== undefined)
413
+ return;
414
+ world.exit = scene;
415
+ const by = world.current?.entity;
416
+ world.emit({ type: "goto", entity: by?.id ?? "script", scene, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
417
+ },
368
418
  emit: (name, data) => {
369
419
  world.guard("world.emit");
370
420
  if (typeof name !== "string" || name === "")
@@ -383,19 +433,28 @@ export class World {
383
433
  return this.scriptApi;
384
434
  }
385
435
  spawn(def) {
386
- if (typeof def !== "object" || def === null || typeof def.components !== "object") {
387
- throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
436
+ if (typeof def !== "object" || def === null || (def.prefab === undefined && typeof def.components !== "object")) {
437
+ throw this.misuse('world.spawn needs { components: { ... } } or { prefab: "name" }, e.g. { prefab: "bullet", components: { transform: { x: 0, y: 0 } } }.');
438
+ }
439
+ let components = def.components ?? {};
440
+ if (def.prefab !== undefined) {
441
+ const prefab = this.prefabs[def.prefab];
442
+ if (!prefab) {
443
+ const names = Object.keys(this.prefabs);
444
+ throw this.misuse(`world.spawn: prefab "${def.prefab}" does not exist.${names.length > 0 ? ` Prefabs: ${names.join(", ")}.` : " Create prefabs/<name>.prefab.json."}`);
445
+ }
446
+ components = mergeComponents(prefab.components, components);
388
447
  }
389
448
  const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
390
449
  if (this.get(id))
391
450
  throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
392
- const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
451
+ const { diagnostics } = validateScene({ entities: [{ id, components }] });
393
452
  const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
394
453
  if (errors.length > 0) {
395
454
  const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
396
455
  throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
397
456
  }
398
- const entity = this.createEntity({ id, components: def.components });
457
+ const entity = this.createEntity({ id, components });
399
458
  this.entities.push(entity);
400
459
  return this.handle(entity);
401
460
  }
@@ -618,15 +677,44 @@ export class World {
618
677
  if (!player)
619
678
  continue;
620
679
  this.won = true;
680
+ if (goal.goal.next)
681
+ this.exit = goal.goal.next;
621
682
  this.emit({ type: "win", entity: player.id, goal: goal.id, ...goal.transform });
622
683
  return;
623
684
  }
624
685
  }
686
+ /** A touched checkpoint becomes the player's respawn point, standing on the checkpoint's bottom edge. */
687
+ updateCheckpoints() {
688
+ for (const checkpoint of this.entities.filter((e) => e.checkpoint && e.body)) {
689
+ for (const player of this.players()) {
690
+ const key = `${player.id}>${checkpoint.id}`;
691
+ if (this.reached.has(key) || !overlaps(player, checkpoint))
692
+ continue;
693
+ this.reached.add(key);
694
+ const bottom = checkpoint.transform.y + checkpoint.body.height / 2;
695
+ player.spawn = { x: checkpoint.transform.x, y: player.platformer ? bottom - player.body.height / 2 : checkpoint.transform.y };
696
+ this.emit({ type: "checkpoint", entity: player.id, checkpoint: checkpoint.id, ...checkpoint.transform });
697
+ }
698
+ }
699
+ }
700
+ gameOver(entity, cause) {
701
+ if (this.lost)
702
+ return;
703
+ this.lost = true;
704
+ const e = this.get(entity);
705
+ this.emit({ type: "gameover", entity, cause, x: e?.transform.x ?? 0, y: e?.transform.y ?? 0 });
706
+ }
625
707
  /** Sends a player back to where it started. Collected items stay collected. */
626
708
  kill(player, cause) {
627
709
  const b = player.body;
628
710
  this.deaths++;
629
711
  this.emit({ type: "die", entity: player.id, cause, ...player.transform });
712
+ if (this.lives !== undefined) {
713
+ this.lives--;
714
+ // Out of lives: the player stays where it fell and the game is over.
715
+ if (this.lives <= 0)
716
+ return this.gameOver(player.id, cause);
717
+ }
630
718
  player.transform.x = player.spawn?.x ?? player.transform.x;
631
719
  player.transform.y = player.spawn?.y ?? player.transform.y;
632
720
  b.vx = 0;
@@ -669,6 +757,8 @@ function createEntity(def) {
669
757
  entity.hazard = resolveComponent("hazard", c.hazard);
670
758
  if (c.goal)
671
759
  entity.goal = resolveComponent("goal", c.goal);
760
+ if (c.checkpoint)
761
+ entity.checkpoint = {};
672
762
  if (c.emitter)
673
763
  entity.emitter = resolveComponent("emitter", c.emitter);
674
764
  if (c.camera)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Ludra engine runtime: scene format, validation, deterministic simulation and rendering.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/assertions.ts CHANGED
@@ -10,7 +10,7 @@ type Operator = (typeof OPERATORS)[number];
10
10
  const ENTITY_FIELDS = ["x", "y", "vx", "vy", "grounded", "exists"] as const;
11
11
  type EntityField = (typeof ENTITY_FIELDS)[number];
12
12
 
13
- const WORLD_FIELDS = ["score", "deaths", "won"] as const;
13
+ const WORLD_FIELDS = ["score", "deaths", "won", "lost", "lives"] as const;
14
14
  type WorldField = (typeof WORLD_FIELDS)[number];
15
15
 
16
16
  type Subject =
@@ -100,6 +100,9 @@ export function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot,
100
100
  case "world":
101
101
  actual = snapshot[subject.field];
102
102
  label = subject.field;
103
+ if (actual === undefined) {
104
+ return { source, pass: false, actual, time, message: `${label} is not set: the scene has unlimited retries. Add "lives" to "world" to count them.` };
105
+ }
103
106
  break;
104
107
  case "events": {
105
108
  const matches = (e: GameEvent) => (subject.builtin ? e.type === subject.name : e.type === "custom" && e.name === subject.name);
@@ -184,6 +187,8 @@ function round(n: number): number {
184
187
  /** One case in a `tests/*.test.json` file. */
185
188
  export interface TestCase {
186
189
  name: string;
190
+ /** Scene to run, relative to the project. Defaults to the entry scene in ludra.json. */
191
+ scene?: string;
187
192
  /** Input script, see `InputScript`. */
188
193
  inputs?: string;
189
194
  /** Run length, e.g. "3s". Defaults to the inputs plus one second, or the latest `at`. */
@@ -197,6 +202,8 @@ export interface TestResult {
197
202
  duration: number;
198
203
  results: AssertionResult[];
199
204
  error?: string;
205
+ /** Names of the events that fired during the run (built-in types and custom names). */
206
+ events: string[];
200
207
  }
201
208
 
202
209
  export function runTest(scene: SceneDef, test: TestCase, options: WorldOptions = {}): TestResult {
@@ -215,7 +222,7 @@ export function runTest(scene: SceneDef, test: TestCase, options: WorldOptions =
215
222
  ? secondsToFrames(typeof test.duration === "number" ? test.duration : parseDuration(test.duration))
216
223
  : Math.max(secondsToFrames(defaultDuration(script)), latestAt);
217
224
  } catch (err) {
218
- return { name: test.name, pass: false, duration: 0, results: [], error: (err as Error).message };
225
+ return { name: test.name, pass: false, duration: 0, results: [], error: (err as Error).message, events: [] };
219
226
  }
220
227
 
221
228
  const wanted = new Set(assertions.map((a) => (a.at === "end" ? frames : Math.min(a.at, frames))));
@@ -228,12 +235,13 @@ export function runTest(scene: SceneDef, test: TestCase, options: WorldOptions =
228
235
  });
229
236
  } catch (err) {
230
237
  // A throwing script fails this test, not the whole run.
231
- return { name: test.name, pass: false, duration: frames * FIXED_DT, results: [], error: (err as Error).message };
238
+ return { name: test.name, pass: false, duration: frames * FIXED_DT, results: [], error: (err as Error).message, events: [] };
232
239
  }
233
240
 
234
241
  const results = assertions.map((a) => {
235
242
  const frame = a.at === "end" ? frames : Math.min(a.at, frames);
236
243
  return evaluateAssertion(a, snapshots.get(frame)!, world.events);
237
244
  });
238
- return { name: test.name, pass: results.every((r) => r.pass), duration: frames * FIXED_DT, results };
245
+ const events = [...new Set(world.events.map((e) => (e.type === "custom" ? e.name : e.type)))];
246
+ return { name: test.name, pass: results.every((r) => r.pass), duration: frames * FIXED_DT, results, events };
239
247
  }