@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/assertions.d.ts +15 -7
- package/dist/assertions.js +60 -23
- package/dist/browser.d.ts +23 -0
- package/dist/browser.js +92 -6
- package/dist/components.d.ts +11 -1
- package/dist/components.js +9 -0
- package/dist/effects.d.ts +1 -1
- package/dist/effects.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/script.d.ts +108 -0
- package/dist/script.js +87 -0
- package/dist/types.d.ts +7 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +19 -7
- package/dist/world.d.ts +59 -6
- package/dist/world.js +278 -11
- package/package.json +1 -1
- package/src/assertions.ts +68 -29
- package/src/browser.ts +104 -6
- package/src/components.ts +18 -1
- package/src/effects.ts +3 -1
- package/src/index.ts +1 -0
- package/src/script.ts +184 -0
- package/src/types.ts +7 -2
- package/src/validate.ts +31 -7
- package/src/world.ts +306 -13
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
312
|
-
|
|
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
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
|
|
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
|
-
|
|
19
|
-
| { kind: "
|
|
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:
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
99
|
-
|
|
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 {
|
|
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
|
|
122
|
-
return { source, pass: false, actual, time, message: `${label} is
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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);
|