@ludra/core 0.3.1 → 0.5.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.
package/dist/world.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { resolveComponent, } from "./components.js";
2
+ import { getScript, ScriptError, } from "./script.js";
2
3
  import { FIXED_DT } from "./time.js";
4
+ import { EVENT_TYPES } from "./types.js";
3
5
  import { validateScene } from "./validate.js";
4
6
  export class SceneValidationError extends Error {
5
7
  diagnostics;
@@ -19,6 +21,8 @@ const STOMP_BOUNCE = 0.6;
19
21
  const STOMP_TOLERANCE = 8;
20
22
  /** A platformer this far below the bottom of the world has fallen out of it. */
21
23
  const FALL_MARGIN = 100;
24
+ /** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
25
+ const TOUCH_MARGIN = 1;
22
26
  const EPSILON = 1e-4;
23
27
  /**
24
28
  * The simulation. It advances only through `step`, one fixed frame at a time,
@@ -38,19 +42,29 @@ export class World {
38
42
  /** Set when a player reaches a goal. The simulation stops changing after that. */
39
43
  won = false;
40
44
  previousInput = new Set();
41
- constructor(scene) {
45
+ scripts;
46
+ seed;
47
+ spawnCount = 0;
48
+ handles = new WeakMap();
49
+ scriptApi;
50
+ scriptInput = { held: () => false, pressed: () => false };
51
+ /** The script hook currently running, for error messages and event attribution. */
52
+ current;
53
+ constructor(scene, options = {}) {
42
54
  this.width = scene.world?.width ?? 960;
43
55
  this.height = scene.world?.height ?? 540;
44
56
  this.gravity = scene.world?.gravity ?? DEFAULT_GRAVITY;
45
57
  this.background = scene.world?.background ?? "#1d2333";
46
- this.entities = scene.entities.map(createEntity);
58
+ this.seed = scene.world?.seed ?? 1;
59
+ this.scripts = options.scripts;
60
+ this.entities = scene.entities.map((def) => this.createEntity(def));
47
61
  }
48
62
  /** Validates raw scene JSON and builds a world, throwing on errors. */
49
- static fromJSON(raw) {
63
+ static fromJSON(raw, options = {}) {
50
64
  const { scene, diagnostics } = validateScene(raw);
51
65
  if (!scene)
52
66
  throw new SceneValidationError(diagnostics);
53
- return new World(scene);
67
+ return new World(scene, options);
54
68
  }
55
69
  get time() {
56
70
  return this.frame * FIXED_DT;
@@ -66,8 +80,14 @@ export class World {
66
80
  if (this.won)
67
81
  return;
68
82
  this.updatePlatformers(input, pressed);
83
+ this.updateScripts(input, pressed);
84
+ if (this.won)
85
+ return;
69
86
  this.updatePatrols();
70
87
  this.updateBodies();
88
+ this.updateTouches();
89
+ if (this.won)
90
+ return;
71
91
  this.updateCollectibles();
72
92
  this.updateHazards();
73
93
  this.updateGoals();
@@ -82,6 +102,9 @@ export class World {
82
102
  vy: e.body?.vy ?? 0,
83
103
  grounded: e.body?.grounded ?? false,
84
104
  };
105
+ // A JSON copy, so later frames can't change an earlier snapshot.
106
+ if (Object.keys(e.state).length > 0)
107
+ entities[e.id].state = JSON.parse(JSON.stringify(e.state));
85
108
  }
86
109
  return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
87
110
  }
@@ -89,8 +112,243 @@ export class World {
89
112
  this.events.push({ ...event, frame: this.frame, time: this.time });
90
113
  }
91
114
  remove(entity) {
92
- this.entities.splice(this.entities.indexOf(entity), 1);
115
+ const index = this.entities.indexOf(entity);
116
+ if (index >= 0)
117
+ this.entities.splice(index, 1);
118
+ entity.removed = true;
119
+ }
120
+ // ---------------------------------------------------------------- scripts
121
+ createEntity(def) {
122
+ const entity = createEntity(def);
123
+ const component = def.components.script;
124
+ if (component) {
125
+ const resolved = resolveComponent("script", component);
126
+ const module = getScript(this.scripts, resolved.src);
127
+ if (!module) {
128
+ throw new ScriptError({ src: resolved.src, entity: def.id, hook: "load", frame: this.frame }, "this script is not loaded. Hosts pass loaded modules as new World(scene, { scripts }); the CLI and browser runtime do this for you.");
129
+ }
130
+ entity.script = { ...resolved, params: { ...resolved.params }, module, started: false, touching: new Set() };
131
+ }
132
+ return entity;
133
+ }
134
+ updateScripts(held, pressed) {
135
+ this.scriptInput = { held: (action) => held.has(action), pressed: (action) => pressed.has(action) };
136
+ // Iterate over a copy: scripts may spawn or destroy entities.
137
+ for (const e of [...this.entities]) {
138
+ if (!e.script || e.removed)
139
+ continue;
140
+ if (!e.script.started) {
141
+ e.script.started = true;
142
+ this.callHook(e, "start");
143
+ }
144
+ if (!e.removed && !this.won)
145
+ this.callHook(e, "update");
146
+ if (this.won)
147
+ return;
148
+ }
149
+ }
150
+ /** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
151
+ updateTouches() {
152
+ for (const e of [...this.entities]) {
153
+ if (!e.script?.module.touch || !e.body || e.removed)
154
+ continue;
155
+ const now = new Set();
156
+ for (const other of this.entities) {
157
+ if (other !== e && other.body && overlaps(e, other, TOUCH_MARGIN))
158
+ now.add(other.id);
159
+ }
160
+ for (const id of now) {
161
+ const other = this.get(id);
162
+ if (other && !e.script.touching.has(id) && !e.removed && !this.won)
163
+ this.callHook(e, "touch", this.handle(other));
164
+ }
165
+ e.script.touching = now;
166
+ }
167
+ }
168
+ callHook(entity, hook, other) {
169
+ const script = entity.script;
170
+ const fn = script.module[hook];
171
+ if (!fn)
172
+ return;
173
+ const ctx = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
174
+ this.current = { entity, hook };
175
+ try {
176
+ fn.call(script.module, ctx, other);
177
+ }
178
+ catch (err) {
179
+ if (err instanceof ScriptError)
180
+ throw err;
181
+ const message = err instanceof Error ? err.message : String(err);
182
+ throw new ScriptError({ src: script.src, entity: entity.id, hook, frame: this.frame }, message, { cause: err });
183
+ }
184
+ finally {
185
+ this.current = undefined;
186
+ }
187
+ }
188
+ /** An error caused by how a script used the API, reported against the running script. */
189
+ misuse(message) {
190
+ const running = this.current;
191
+ return new ScriptError({ src: running?.entity.script?.src ?? "?", entity: running?.entity.id ?? "?", hook: running?.hook ?? "?", frame: this.frame }, message);
93
192
  }
193
+ /** A stable script-facing view of an entity. */
194
+ handle(e) {
195
+ let handle = this.handles.get(e);
196
+ if (handle)
197
+ return handle;
198
+ const world = this;
199
+ handle = {
200
+ get id() {
201
+ return e.id;
202
+ },
203
+ get x() {
204
+ return e.transform.x;
205
+ },
206
+ set x(value) {
207
+ e.transform.x = value;
208
+ },
209
+ get y() {
210
+ return e.transform.y;
211
+ },
212
+ set y(value) {
213
+ e.transform.y = value;
214
+ },
215
+ get vx() {
216
+ return e.body?.vx ?? 0;
217
+ },
218
+ set vx(value) {
219
+ if (e.body)
220
+ e.body.vx = value;
221
+ },
222
+ get vy() {
223
+ return e.body?.vy ?? 0;
224
+ },
225
+ set vy(value) {
226
+ if (e.body)
227
+ e.body.vy = value;
228
+ },
229
+ get grounded() {
230
+ return e.body?.grounded ?? false;
231
+ },
232
+ get width() {
233
+ return e.body?.width ?? e.sprite?.width ?? 0;
234
+ },
235
+ get height() {
236
+ return e.body?.height ?? e.sprite?.height ?? 0;
237
+ },
238
+ get exists() {
239
+ return !e.removed;
240
+ },
241
+ get state() {
242
+ return e.state;
243
+ },
244
+ has(component) {
245
+ return component === "transform" || (component in e && e[component] !== undefined);
246
+ },
247
+ touches(other) {
248
+ const target = world.get(typeof other === "string" ? other : other.id);
249
+ return !!target && target !== e && !e.removed && overlaps(e, target, TOUCH_MARGIN);
250
+ },
251
+ };
252
+ this.handles.set(e, handle);
253
+ return handle;
254
+ }
255
+ resolve(target) {
256
+ return this.get(typeof target === "string" ? target : target.id);
257
+ }
258
+ /** The `world` object scripts receive. */
259
+ api() {
260
+ if (this.scriptApi)
261
+ return this.scriptApi;
262
+ const world = this;
263
+ this.scriptApi = {
264
+ get time() {
265
+ return world.time;
266
+ },
267
+ get frame() {
268
+ return world.frame;
269
+ },
270
+ get width() {
271
+ return world.width;
272
+ },
273
+ get height() {
274
+ return world.height;
275
+ },
276
+ get score() {
277
+ return world.score;
278
+ },
279
+ set score(value) {
280
+ world.score = value;
281
+ },
282
+ get: (id) => {
283
+ const e = world.get(id);
284
+ return e ? world.handle(e) : undefined;
285
+ },
286
+ all: (component) => world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
287
+ player: () => {
288
+ const e = world.entities.find((candidate) => candidate.platformer);
289
+ return e ? world.handle(e) : undefined;
290
+ },
291
+ spawn: (def) => world.spawn(def),
292
+ destroy: (target) => {
293
+ const e = world.resolve(target);
294
+ if (e)
295
+ world.remove(e);
296
+ },
297
+ kill: (target) => {
298
+ const e = world.resolve(target);
299
+ if (!e)
300
+ return;
301
+ if (e.platformer && e.body)
302
+ world.kill(e, world.current?.entity.id ?? "script");
303
+ else
304
+ world.remove(e);
305
+ },
306
+ win: () => {
307
+ if (world.won)
308
+ return;
309
+ const by = world.current?.entity;
310
+ world.won = true;
311
+ world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
312
+ },
313
+ emit: (name, data) => {
314
+ if (typeof name !== "string" || name === "")
315
+ throw world.misuse("world.emit needs a non-empty event name.");
316
+ if (EVENT_TYPES.includes(name)) {
317
+ throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
318
+ }
319
+ const by = world.current?.entity;
320
+ world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
321
+ },
322
+ random: () => world.random(),
323
+ };
324
+ return this.scriptApi;
325
+ }
326
+ spawn(def) {
327
+ if (typeof def !== "object" || def === null || typeof def.components !== "object") {
328
+ throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
329
+ }
330
+ const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
331
+ if (this.get(id))
332
+ throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
333
+ const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
334
+ const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
335
+ if (errors.length > 0) {
336
+ const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
337
+ throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
338
+ }
339
+ const entity = this.createEntity({ id, components: def.components });
340
+ this.entities.push(entity);
341
+ return this.handle(entity);
342
+ }
343
+ /** mulberry32, seeded by world.seed. */
344
+ random() {
345
+ this.seed = (this.seed + 0x6d2b79f5) | 0;
346
+ let t = this.seed;
347
+ t = Math.imul(t ^ (t >>> 15), t | 1);
348
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
349
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
350
+ }
351
+ // ------------------------------------------------------------ built-ins
94
352
  players() {
95
353
  return this.entities.filter((e) => e.platformer && e.body);
96
354
  }
@@ -111,11 +369,15 @@ export class World {
111
369
  b.standingOn = undefined;
112
370
  p.bufferTimer = 0;
113
371
  p.coyoteTimer = 0;
372
+ p.jumping = true;
114
373
  this.emit({ type: "jump", entity: e.id, ...feet(e) });
115
374
  }
116
- else if (p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
375
+ else if (p.jumping && p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
376
+ // Only the player's own jumps are cut short; launches from pads, stomps or scripts are not.
117
377
  b.vy = -p.jumpSpeed * JUMP_CUT;
118
378
  }
379
+ if (b.vy >= 0)
380
+ p.jumping = false;
119
381
  p.coyoteTimer = Math.max(0, p.coyoteTimer - FIXED_DT);
120
382
  p.bufferTimer = Math.max(0, p.bufferTimer - FIXED_DT);
121
383
  }
@@ -257,12 +519,13 @@ export class World {
257
519
  b.standingOn = undefined;
258
520
  p.coyoteTimer = 0;
259
521
  p.bufferTimer = 0;
522
+ p.jumping = false;
260
523
  }
261
524
  }
262
525
  function createEntity(def) {
263
526
  const c = def.components;
264
527
  const transform = c.transform ? resolveComponent("transform", c.transform) : { x: 0, y: 0 };
265
- const entity = { id: def.id, transform };
528
+ const entity = { id: def.id, state: {}, transform };
266
529
  if (c.body)
267
530
  entity.body = { ...resolveComponent("body", c.body), grounded: false };
268
531
  if (c.sprite)
@@ -272,6 +535,7 @@ function createEntity(def) {
272
535
  ...resolveComponent("platformer", c.platformer),
273
536
  coyoteTimer: 0,
274
537
  bufferTimer: 0,
538
+ jumping: false,
275
539
  spawnX: transform.x,
276
540
  spawnY: transform.y,
277
541
  };
@@ -308,10 +572,13 @@ function isStomp(player, enemy) {
308
572
  const enemyTop = enemy.transform.y - enemy.body.height / 2;
309
573
  return previousBottom <= enemyTop + STOMP_TOLERANCE;
310
574
  }
311
- /** Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap. */
312
- export function overlaps(a, b) {
575
+ /**
576
+ * Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
577
+ * A positive `margin` grows both boxes, so touching counts too.
578
+ */
579
+ export function overlaps(a, b, margin = 0) {
313
580
  if (!a.body || !b.body)
314
581
  return false;
315
- return (Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 - EPSILON &&
316
- Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 - EPSILON);
582
+ return (Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 + margin - EPSILON &&
583
+ Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 + margin - EPSILON);
317
584
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/core",
3
- "version": "0.3.1",
3
+ "version": "0.5.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
@@ -1,8 +1,8 @@
1
1
  import { InputScript } from "./input.ts";
2
2
  import { defaultDuration, simulate } from "./simulate.ts";
3
3
  import { FIXED_DT, parseDuration, secondsToFrames } from "./time.ts";
4
- import { EVENT_TYPES, type GameEventType, type SceneDef } from "./types.ts";
5
- import { World, type GameEvent, type WorldSnapshot } from "./world.ts";
4
+ import { EVENT_TYPES, type SceneDef } from "./types.ts";
5
+ import { World, type GameEvent, type WorldOptions, type WorldSnapshot } from "./world.ts";
6
6
 
7
7
  const OPERATORS = ["<=", ">=", "==", "!=", "<", ">"] as const;
8
8
  type Operator = (typeof OPERATORS)[number];
@@ -15,14 +15,18 @@ type WorldField = (typeof WORLD_FIELDS)[number];
15
15
 
16
16
  type Subject =
17
17
  | { kind: "world"; field: WorldField }
18
- | { kind: "events"; type: GameEventType }
19
- | { kind: "entity"; id: string; field: EntityField };
18
+ /** Built-in event types, or custom events fired by scripts with world.emit(name). */
19
+ | { kind: "events"; name: string; builtin: boolean }
20
+ | { kind: "entity"; id: string; field: EntityField }
21
+ | { kind: "state"; id: string; key: string };
22
+
23
+ type Value = number | boolean | string;
20
24
 
21
25
  export interface Assertion {
22
26
  source: string;
23
27
  subject: Subject;
24
28
  operator: Operator;
25
- expected: number | boolean;
29
+ expected: Value;
26
30
  /** Frame to check at, or "end" for the last frame of the run. */
27
31
  at: number | "end";
28
32
  }
@@ -30,7 +34,7 @@ export interface Assertion {
30
34
  export class AssertionSyntaxError extends Error {}
31
35
 
32
36
  const EXAMPLES =
33
- 'Examples: "player.x > 400", "player.grounded == true at 1s", "score >= 3", "won == true", "deaths == 0", "events.jump == 1 at end", "coin1.exists == false".';
37
+ 'Examples: "player.x > 400", "player.grounded == true at 1s", "score >= 3", "won == true", "deaths == 0", "events.jump == 1 at end", "coin1.exists == false", "door.state.open == true", "boss.state.phase == \\"angry\\"", "events.shoot >= 3".';
34
38
 
35
39
  /** Parses `<subject> <op> <value> [at <time|end>]`. */
36
40
  export function parseAssertion(source: string): Assertion {
@@ -39,29 +43,32 @@ export function parseAssertion(source: string): Assertion {
39
43
  const [, lhs = "", operator, rhs = "", at] = match;
40
44
 
41
45
  let subject: Subject;
46
+ const stateMatch = /^(.+)\.state\.([\w-]+)$/.exec(lhs);
42
47
  if ((WORLD_FIELDS as readonly string[]).includes(lhs)) {
43
48
  subject = { kind: "world", field: lhs as WorldField };
44
- } else if (lhs.startsWith("events.")) {
45
- const type = lhs.slice("events.".length);
46
- if (!(EVENT_TYPES as readonly string[]).includes(type)) {
47
- throw new AssertionSyntaxError(`Unknown event "${type}" in "${source}". Events: ${EVENT_TYPES.join(", ")}.`);
48
- }
49
- subject = { kind: "events", type: type as GameEventType };
49
+ } else if (lhs.startsWith("events.") && lhs.length > "events.".length) {
50
+ const name = lhs.slice("events.".length);
51
+ subject = { kind: "events", name, builtin: (EVENT_TYPES as readonly string[]).includes(name) };
52
+ } else if (stateMatch) {
53
+ subject = { kind: "state", id: stateMatch[1]!, key: stateMatch[2]! };
50
54
  } else {
51
55
  const dot = lhs.lastIndexOf(".");
52
56
  const field = lhs.slice(dot + 1);
53
57
  if (dot <= 0 || !(ENTITY_FIELDS as readonly string[]).includes(field)) {
54
58
  throw new AssertionSyntaxError(
55
- `"${lhs}" is not something Ludra can check. Use ${WORLD_FIELDS.join(", ")}, events.<${EVENT_TYPES.join("|")}> or <entityId>.<${ENTITY_FIELDS.join("|")}>.`,
59
+ `"${lhs}" is not something Ludra can check. Use ${WORLD_FIELDS.join(", ")}, events.<name>, <entityId>.<${ENTITY_FIELDS.join("|")}> or <entityId>.state.<key>.`,
56
60
  );
57
61
  }
58
62
  subject = { kind: "entity", id: lhs.slice(0, dot), field: field as EntityField };
59
63
  }
60
64
 
61
- let expected: number | boolean;
65
+ let expected: Value;
62
66
  if (rhs === "true" || rhs === "false") expected = rhs === "true";
67
+ else if (/^".*"$/.test(rhs)) expected = rhs.slice(1, -1);
63
68
  else if (Number.isFinite(Number(rhs))) expected = Number(rhs);
64
- else throw new AssertionSyntaxError(`Expected a number or true/false after "${operator}" in "${source}", got "${rhs}".`);
69
+ else {
70
+ throw new AssertionSyntaxError(`Expected a number, true/false or a "quoted string" after "${operator}" in "${source}", got ${rhs}.`);
71
+ }
65
72
 
66
73
  let frame: number | "end" = "end";
67
74
  if (at !== undefined && at !== "end") {
@@ -78,7 +85,7 @@ export function parseAssertion(source: string): Assertion {
78
85
  export interface AssertionResult {
79
86
  source: string;
80
87
  pass: boolean;
81
- actual: number | boolean | undefined;
88
+ actual: unknown;
82
89
  time: number;
83
90
  message: string;
84
91
  }
@@ -86,7 +93,7 @@ export interface AssertionResult {
86
93
  export function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot, events: readonly GameEvent[]): AssertionResult {
87
94
  const { subject, operator, expected, source } = assertion;
88
95
  const time = snapshot.time;
89
- let actual: number | boolean | undefined;
96
+ let actual: unknown;
90
97
  let label: string;
91
98
 
92
99
  switch (subject.kind) {
@@ -94,10 +101,30 @@ export function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot,
94
101
  actual = snapshot[subject.field];
95
102
  label = subject.field;
96
103
  break;
97
- case "events":
98
- actual = events.filter((e) => e.type === subject.type && e.frame <= snapshot.frame).length;
99
- label = `events.${subject.type}`;
104
+ case "events": {
105
+ const matches = (e: GameEvent) => (subject.builtin ? e.type === subject.name : e.type === "custom" && e.name === subject.name);
106
+ actual = events.filter((e) => matches(e) && e.frame <= snapshot.frame).length;
107
+ label = `events.${subject.name}`;
108
+ break;
109
+ }
110
+ case "state": {
111
+ const entity = snapshot.entities[subject.id];
112
+ label = `${subject.id}.state.${subject.key}`;
113
+ if (!entity) {
114
+ return { source, pass: false, actual: undefined, time, message: `entity "${subject.id}" does not exist at ${time.toFixed(2)}s.` };
115
+ }
116
+ actual = entity.state?.[subject.key];
117
+ if (actual === undefined) {
118
+ return {
119
+ source,
120
+ pass: false,
121
+ actual,
122
+ time,
123
+ message: `${label} is not set at ${time.toFixed(2)}s. Scripts set it with self.state.${subject.key} = ...`,
124
+ };
125
+ }
100
126
  break;
127
+ }
101
128
  case "entity": {
102
129
  const entity = snapshot.entities[subject.id];
103
130
  label = `${subject.id}.${subject.field}`;
@@ -116,14 +143,20 @@ export function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot,
116
143
  }
117
144
 
118
145
  if (typeof actual !== typeof expected) {
119
- return { source, pass: false, actual, time, message: `${label} is a ${typeof actual}, but it is compared with ${JSON.stringify(expected)}.` };
146
+ return {
147
+ source,
148
+ pass: false,
149
+ actual,
150
+ time,
151
+ message: `${label} is ${JSON.stringify(actual)} (a ${typeof actual}), but it is compared with ${JSON.stringify(expected)}.`,
152
+ };
120
153
  }
121
- if (typeof actual === "boolean" && operator !== "==" && operator !== "!=") {
122
- return { source, pass: false, actual, time, message: `${label} is true/false, so only == and != can be used.` };
154
+ if (typeof actual !== "number" && operator !== "==" && operator !== "!=") {
155
+ return { source, pass: false, actual, time, message: `${label} is a ${typeof actual}, so only == and != can be used.` };
123
156
  }
124
157
 
125
158
  const pass = compare(actual as number, operator, expected as number);
126
- const shown = typeof actual === "number" ? round(actual) : actual;
159
+ const shown = typeof actual === "number" ? round(actual) : JSON.stringify(actual);
127
160
  return { source, pass, actual, time, message: `${label} = ${shown} at ${time.toFixed(2)}s` };
128
161
  }
129
162
 
@@ -166,7 +199,7 @@ export interface TestResult {
166
199
  error?: string;
167
200
  }
168
201
 
169
- export function runTest(scene: SceneDef, test: TestCase): TestResult {
202
+ export function runTest(scene: SceneDef, test: TestCase, options: WorldOptions = {}): TestResult {
170
203
  let script: InputScript;
171
204
  let assertions: Assertion[];
172
205
  let frames: number;
@@ -187,10 +220,16 @@ export function runTest(scene: SceneDef, test: TestCase): TestResult {
187
220
 
188
221
  const wanted = new Set(assertions.map((a) => (a.at === "end" ? frames : Math.min(a.at, frames))));
189
222
  const snapshots = new Map<number, WorldSnapshot>();
190
- const world = new World(scene);
191
- simulate(world, script, frames, (w) => {
192
- if (wanted.has(w.frame)) snapshots.set(w.frame, w.snapshot());
193
- });
223
+ let world: World;
224
+ try {
225
+ world = new World(scene, options);
226
+ simulate(world, script, frames, (w) => {
227
+ if (wanted.has(w.frame)) snapshots.set(w.frame, w.snapshot());
228
+ });
229
+ } catch (err) {
230
+ // 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 };
232
+ }
194
233
 
195
234
  const results = assertions.map((a) => {
196
235
  const frame = a.at === "end" ? frames : Math.min(a.at, frames);