@origingame/engine 0.2.0 → 0.3.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/bin/og.mjs +1 -1
- package/dist/web/origin-engine.d.ts +219 -5
- package/dist/web/origin-engine.js +1344 -125
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +68 -3
- package/dist/web-publish/origin-engine-lanes.js +1007 -22
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +162 -5
- package/dist/web-publish/origin-engine.js +1004 -19
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +52 -3
- package/dist/web-publish-2d/origin-engine-lanes.js +1007 -22
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +162 -5
- package/dist/web-publish-2d/origin-engine.js +1004 -19
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +52 -3
- package/knowledge/looks/catalogue.json +40 -40
- package/knowledge/package.json +1 -1
- package/knowledge/scaffold/distribution.json +14 -0
- package/knowledge/scaffold/origin-engine.json +2 -2
- package/knowledge/scaffold/template/README.md +2 -1
- package/knowledge/scaffold/template/assets/audio/sting.ogt +10 -0
- package/knowledge/scaffold/template/assets/timelines/intro.ogt +101 -0
- package/knowledge/scaffold/template/assets/timelines/narration.srt +11 -0
- package/knowledge/scaffold/template/host.d.ts +46 -0
- package/knowledge/scaffold/template/package.json +1 -1
- package/knowledge/scaffold/template/project.ogt +1 -1
- package/knowledge/scaffold/template/src/main.ts +1 -1
- package/knowledge/scaffold/template/tests/frames.ts +134 -0
- package/knowledge/scaffold/template/tests/intro.og.ts +65 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +30 -36
- package/knowledge/scaffold/template/tsconfig.json +1 -1
- package/knowledge/scripts/mount.ts +2 -1
- package/knowledge/scripts/validate.ts +19 -10
- package/knowledge/skills/og-build/SKILL.md +8 -5
- package/knowledge/skills/og-core/SKILL.md +11 -0
- package/knowledge/skills/og-game/SKILL.md +877 -8
- package/knowledge/skills/og-platform-sdk/SKILL.md +1 -0
- package/knowledge/skills/og-taste/SKILL.md +82 -73
- package/knowledge/skills/og-ui/SKILL.md +65 -50
- package/knowledge/wiki/ai.md +20 -20
- package/knowledge/wiki/anim.md +384 -41
- package/knowledge/wiki/atmosphere.md +36 -13
- package/knowledge/wiki/audio.md +18 -18
- package/knowledge/wiki/camera.md +19 -13
- package/knowledge/wiki/commands.md +19 -13
- package/knowledge/wiki/debug.md +414 -0
- package/knowledge/wiki/game.md +8 -8
- package/knowledge/wiki/i18n.md +7 -7
- package/knowledge/wiki/index.md +262 -7
- package/knowledge/wiki/input.md +47 -47
- package/knowledge/wiki/load.md +236 -68
- package/knowledge/wiki/math.md +2607 -0
- package/knowledge/wiki/nav.md +34 -34
- package/knowledge/wiki/net.md +340 -0
- package/knowledge/wiki/path.md +402 -0
- package/knowledge/wiki/physics.md +95 -95
- package/knowledge/wiki/platform.md +463 -29
- package/knowledge/wiki/probe.md +681 -67
- package/knowledge/wiki/reflect.md +15 -15
- package/knowledge/wiki/render.md +3893 -1896
- package/knowledge/wiki/rng.md +14 -14
- package/knowledge/wiki/stream.md +15 -15
- package/knowledge/wiki/system.md +8 -8
- package/knowledge/wiki/systems.md +2 -2
- package/knowledge/wiki/terrain.md +19 -19
- package/knowledge/wiki/tilemap.md +13 -13
- package/knowledge/wiki/time.md +4 -4
- package/knowledge/wiki/timeline.md +396 -0
- package/knowledge/wiki/transform.md +8 -8
- package/knowledge/wiki/tween.md +17 -17
- package/knowledge/wiki/ui.md +559 -629
- package/knowledge/wiki/video.md +157 -0
- package/knowledge/wiki/world.md +36 -36
- package/package.json +4 -3
- package/schema/api.schema.json +21707 -14725
- package/shell/page.ts +26 -2
- package/shell/platform.ts +46 -0
- package/shell/player.ts +7 -0
- package/src/depth.ts +71 -0
- package/src/e6.ts +32 -1
- package/src/env.d.ts +5 -1
- package/src/generated/abi.ts +189 -3
- package/src/index.ts +25 -4
- package/src/light.ts +18 -0
- package/src/math.ts +622 -0
- package/src/node/index.ts +17 -0
- package/src/node/playtest-run.ts +114 -18
- package/src/node/polish.ts +13 -3
- package/src/node/render.ts +291 -0
- package/src/node/run.ts +86 -5
- package/src/p2.ts +19 -1
- package/src/playtest.ts +57 -4
- package/src/runtime.ts +13 -3
- package/src/targets.ts +28 -0
- package/src/trace.ts +169 -0
- package/src/ui-kit/huds.ts +34 -34
- package/src/ui-kit/menus.ts +12 -12
- package/src/ui-kit/settings.ts +2 -2
- package/src/ui-kit/store.ts +18 -18
- package/src/ui-kit/story.ts +6 -4
- package/src/ui-kit/util.ts +8 -8
- package/src/web/shell.ts +58 -0
- package/src/web/worker.ts +103 -2
- package/types/bindings.json +9101 -6647
- package/types/index.d.ts +15503 -11564
package/src/node/playtest-run.ts
CHANGED
|
@@ -16,10 +16,12 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|
|
16
16
|
import { basename, dirname, join, resolve } from "node:path";
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
import { bundleGame } from "./build.ts";
|
|
19
|
-
import { FrameUnchecked, PLAYTEST_CLOCK, type PlaytestChecks, type PlaytestDesc, type PlaytestRun } from "../playtest.ts";
|
|
19
|
+
import { FrameUnchecked, PLAYTEST_CLOCK, type PlaytestChecks, type PlaytestDesc, type PlaytestLockstep, type PlaytestRun } from "../playtest.ts";
|
|
20
|
+
import { loadNativeHost, native } from "./index.ts";
|
|
20
21
|
|
|
21
22
|
declare const Bun: {
|
|
22
23
|
spawnSync(cmd: string[], o?: { cwd?: string; stdout?: string; stderr?: string }): { exitCode: number; stdout: Uint8Array; stderr: Uint8Array };
|
|
24
|
+
spawn(cmd: string[], o?: { stdout?: string; stderr?: string }): { exited: Promise<number>; stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array> };
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
const argv = process.argv.slice(2);
|
|
@@ -45,7 +47,7 @@ mkdirSync(out, { recursive: true });
|
|
|
45
47
|
const t0 = performance.now();
|
|
46
48
|
const failures: string[] = [];
|
|
47
49
|
let checks = 0;
|
|
48
|
-
// Checks made inside t.frame (with a renderer): what a run without one leaves out.
|
|
50
|
+
// Checks made inside t.frame and region or frame expectations (with a renderer): what a run without one leaves out.
|
|
49
51
|
let frameChecks = 0;
|
|
50
52
|
// Every screenshot taken without a renderer, by the file it would have written, in order.
|
|
51
53
|
const unchecked: string[] = [];
|
|
@@ -84,7 +86,7 @@ if (!b.ok) {
|
|
|
84
86
|
finish();
|
|
85
87
|
}
|
|
86
88
|
const desc = ((await import(pathToFileURL(bundled).href)) as { default?: PlaytestDesc }).default;
|
|
87
|
-
if (!desc ||
|
|
89
|
+
if (!desc || !desc.commands) {
|
|
88
90
|
failures.push(`${file} has no default export: export default playtest({ name, commands, check })`);
|
|
89
91
|
finish();
|
|
90
92
|
}
|
|
@@ -94,40 +96,118 @@ result.title = desc!.name;
|
|
|
94
96
|
// The host's clock is pinned (PlaytestDesc.clock), so og.platform.now is
|
|
95
97
|
// reproducible; `store: "keep"` shares one local store file between them,
|
|
96
98
|
// kept under a dot name, which no step's output may take (an output named
|
|
97
|
-
// `platform.json` once replaced it: hollowbell FINDINGS #18).
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
99
|
+
// `platform.json` once replaced it: hollowbell FINDINGS #18). In a room each
|
|
100
|
+
// place has its own (`platform-<place>.json`): its processes run at once,
|
|
101
|
+
// and a `then` step relaunches every place on its own store.
|
|
102
|
+
const storeOf = (place: number): string | undefined => {
|
|
103
|
+
if (desc!.store !== "keep") return undefined;
|
|
104
|
+
const path = join(out, ".store", desc!.room && place > 0 ? `platform-${place}.json` : "platform.json");
|
|
105
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
106
|
+
return path;
|
|
107
|
+
};
|
|
108
|
+
const command = (commands: string, dir: string, clock: number, room?: { url: string; ticks?: number | undefined; place: number }) => {
|
|
101
109
|
const c = ["bun", join(here, "run.ts"), "--entry", entry!, "--commands", commands, "--out", dir, "--json", "--clock", String(clock)];
|
|
110
|
+
if (room) c.push("--room", room.url);
|
|
111
|
+
if (room?.ticks !== undefined) c.push("--ticks", String(room.ticks));
|
|
102
112
|
if (root) c.push("--root", root);
|
|
103
113
|
if (desc!.seed !== undefined) c.push("--seed", String(desc!.seed));
|
|
104
114
|
if (desc!.size) c.push("--size", desc!.size);
|
|
115
|
+
const store = storeOf(room?.place ?? 0);
|
|
105
116
|
if (store) c.push("--platform-store", store);
|
|
106
117
|
return c;
|
|
107
118
|
};
|
|
108
|
-
type
|
|
119
|
+
type ExpectResult = { tick: number; expect: string; kind: string; status: "pass" | "fail" | "unchecked"; expected: unknown; observed: unknown; reason: string };
|
|
120
|
+
type Summary = {
|
|
121
|
+
tick: number;
|
|
122
|
+
hash: string;
|
|
123
|
+
netHash?: string;
|
|
124
|
+
lockstep?: PlaytestLockstep;
|
|
125
|
+
entities: number;
|
|
126
|
+
quit: boolean;
|
|
127
|
+
tier: string;
|
|
128
|
+
outputs: string[];
|
|
129
|
+
warnings: string[];
|
|
130
|
+
unchecked?: string[];
|
|
131
|
+
expectations?: { failed: number; results: ExpectResult[] };
|
|
132
|
+
};
|
|
109
133
|
const spawn = (c: string[], what: string): Summary | null => {
|
|
110
134
|
const r = Bun.spawnSync(c, { stdout: "pipe", stderr: "pipe" });
|
|
111
|
-
|
|
112
|
-
|
|
135
|
+
return summarise(r.exitCode, new TextDecoder().decode(r.stdout), new TextDecoder().decode(r.stderr), what);
|
|
136
|
+
};
|
|
137
|
+
function summarise(exitCode: number, stdout: string, stderr: string, what: string): Summary | null {
|
|
113
138
|
let summary: Summary | null = null;
|
|
114
139
|
try {
|
|
115
140
|
summary = JSON.parse(stdout.trim().split("\n").pop() ?? "");
|
|
116
141
|
} catch {
|
|
117
142
|
summary = null;
|
|
118
143
|
}
|
|
119
|
-
|
|
120
|
-
|
|
144
|
+
// A run whose only failure is its expectations exits 1 with its summary: each is a check.
|
|
145
|
+
const expectedOnly = exitCode === 1 && summary !== null && (summary.expectations?.failed ?? 0) > 0;
|
|
146
|
+
if ((exitCode !== 0 && !expectedOnly) || !summary) {
|
|
147
|
+
failures.push(`${what} failed (exit ${exitCode}): ${stderr.trim() || stdout.trim()}`);
|
|
121
148
|
return null;
|
|
122
149
|
}
|
|
150
|
+
for (const e of summary.expectations?.results ?? []) {
|
|
151
|
+
if (e.status === "unchecked") unchecked.push(`${what}: expect:${e.expect}`);
|
|
152
|
+
else {
|
|
153
|
+
checks++;
|
|
154
|
+
// A region or frame expectation read the frame: without a renderer it is unchecked.
|
|
155
|
+
if (e.kind === "region" || e.kind === "frame") frameChecks++;
|
|
156
|
+
if (e.status === "fail") failures.push(`${what}: expect:${e.expect} failed at tick ${e.tick}: expected ${JSON.stringify(e.expected)}, observed ${JSON.stringify(e.observed)}; ${e.reason}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
123
159
|
return summary;
|
|
124
|
-
}
|
|
160
|
+
}
|
|
125
161
|
const clock = desc!.clock ?? PLAYTEST_CLOCK;
|
|
126
|
-
|
|
162
|
+
|
|
163
|
+
// PlaytestDesc.room: a relay in this process, and every peer's process at
|
|
164
|
+
// once, each joined to the room (the first is the run, the others its peers).
|
|
165
|
+
// A `then` step relaunches every place at once into a room of its own
|
|
166
|
+
// (`<name>.then-<n>`), each running the step's script: lockstep keeps the
|
|
167
|
+
// steps on the room's shared ticks, so a `quit` ends every place on one tick.
|
|
168
|
+
const room = desc!.room;
|
|
169
|
+
const peerSummaries: Summary[] = [];
|
|
170
|
+
let summary: Summary | null;
|
|
171
|
+
let playRoom: ((name: string, scripts: (place: number) => string, dirOf: (place: number) => string, clock: number, what: string) => Promise<Summary[]>) | null = null;
|
|
172
|
+
if (room) {
|
|
173
|
+
const players = Math.floor(room.players);
|
|
174
|
+
if (!(players >= 2 && players <= 16)) failures.push(`room.players is ${room.players}: a room playtest takes 2 to 16 processes`);
|
|
175
|
+
if ((desc!.runs ?? 1) > 1) failures.push("a room playtest takes no `runs`: its processes play at once (a relaunch is a `then` step)");
|
|
176
|
+
const name = room.name ?? "playtest";
|
|
177
|
+
if (room.ticks !== undefined && !(Number.isInteger(room.ticks) && room.ticks > 0)) failures.push(`room.ticks is ${room.ticks}: the tick every process stops at, a whole number above 0 (or leave it out and end the script with quit)`);
|
|
178
|
+
if (!/^[A-Za-z0-9._-]{1,54}$/.test(name)) failures.push(`room.name '${name}' is not a room id: 1 to 54 of letters, digits, '.', '_' and '-' (a then step's room adds .then-<n>)`);
|
|
179
|
+
if (failures.length > 0) finish();
|
|
180
|
+
loadNativeHost();
|
|
181
|
+
const relay = JSON.parse(native().og_node_relay_start(0)) as { ok: boolean; value?: number; reason?: string };
|
|
182
|
+
if (!relay.ok) {
|
|
183
|
+
failures.push(`the playtest's relay did not start: ${relay.reason}`);
|
|
184
|
+
finish();
|
|
185
|
+
}
|
|
186
|
+
playRoom = async (roomName, scripts, dirOf, at, what) => {
|
|
187
|
+
const url = `ws://127.0.0.1:${relay.value}/${roomName}`;
|
|
188
|
+
const results = await Promise.all(
|
|
189
|
+
Array.from({ length: players }, async (_, i) => {
|
|
190
|
+
const dir = dirOf(i);
|
|
191
|
+
mkdirSync(dir, { recursive: true });
|
|
192
|
+
const p = Bun.spawn(command(scripts(i), dir, at, { url, ticks: room.ticks, place: i }), { stdout: "pipe", stderr: "pipe" });
|
|
193
|
+
const [code, stdout, stderr] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
194
|
+
return summarise(code, stdout, stderr, `${what} (peer ${i})`);
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
if (results.some((r) => r === null)) finish();
|
|
198
|
+
return results as Summary[];
|
|
199
|
+
};
|
|
200
|
+
const results = await playRoom(name, (i) => room.commands?.[i] ?? desc!.commands, (i) => (i === 0 ? out : join(out, `peer-${i}`)), clock, "og run");
|
|
201
|
+
summary = results[0]!;
|
|
202
|
+
peerSummaries.push(...results.slice(1));
|
|
203
|
+
for (const [i, s] of peerSummaries.entries()) for (const f of s.unchecked ?? []) unchecked.push(`peer ${i + 1} ${f}`);
|
|
204
|
+
} else summary = spawn(command(desc!.commands, out, clock), "og run");
|
|
127
205
|
if (!summary) finish();
|
|
128
206
|
const rendered = summary!.tier !== "none";
|
|
129
207
|
unchecked.push(...(summary!.unchecked ?? []));
|
|
130
208
|
result = { ...result, tick: summary!.tick, hash: summary!.hash, tier: summary!.tier, warnings: summary!.warnings };
|
|
209
|
+
// A room's shared hashes, the run's first: equal ones are peers that stayed in step.
|
|
210
|
+
if (room) result = { ...result, netHashes: [summary!, ...peerSummaries].map((s) => s.netHash ?? ""), lockstep: [summary!, ...peerSummaries].map((s) => s.lockstep ?? null) };
|
|
131
211
|
// More runs of the same script (desc.runs), each in a fresh process into its
|
|
132
212
|
// own directory: only their end hashes are kept.
|
|
133
213
|
const hashes = [summary!.hash];
|
|
@@ -137,7 +217,7 @@ for (let i = 1; i < Math.max(1, Math.floor(desc!.runs ?? 1)); i++) {
|
|
|
137
217
|
}
|
|
138
218
|
result = { ...result, hashes };
|
|
139
219
|
|
|
140
|
-
const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): PlaytestRun => {
|
|
220
|
+
const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[], peers: readonly PlaytestRun[] = []): PlaytestRun => {
|
|
141
221
|
const path = (f: string) => {
|
|
142
222
|
const p = resolve(dir, f);
|
|
143
223
|
if (!existsSync(p)) throw new Error(`the run wrote no ${f} (it wrote ${s.outputs.map((o) => o.slice(dir.length + 1)).join(", ") || "nothing"})`);
|
|
@@ -156,6 +236,9 @@ const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): Playtest
|
|
|
156
236
|
files: s.outputs.map((o) => o.slice(dir.length + 1)),
|
|
157
237
|
warnings: s.warnings,
|
|
158
238
|
then: later,
|
|
239
|
+
netHash: s.netHash ?? "",
|
|
240
|
+
lockstep: s.lockstep ?? null,
|
|
241
|
+
peers,
|
|
159
242
|
json: <T>(f: string) => JSON.parse(readFileSync(path(f), "utf8")) as T,
|
|
160
243
|
text: (f: string) => readFileSync(path(f), "utf8"),
|
|
161
244
|
png: (f: string) => {
|
|
@@ -183,17 +266,30 @@ const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): Playtest
|
|
|
183
266
|
},
|
|
184
267
|
};
|
|
185
268
|
};
|
|
186
|
-
// The later processes (desc.then), in order, after the script and its runs
|
|
269
|
+
// The later processes (desc.then), in order, after the script and its runs;
|
|
270
|
+
// in a room every place again, at once (their runs are each step's `peers`).
|
|
187
271
|
const later: PlaytestRun[] = [];
|
|
188
272
|
for (const [i, step] of (desc!.then ?? []).entries()) {
|
|
189
273
|
const dir = join(out, `then-${i + 1}`);
|
|
190
274
|
mkdirSync(dir, { recursive: true });
|
|
275
|
+
if (playRoom) {
|
|
276
|
+
const peerDir = (p: number) => join(dir, `peer-${p}`);
|
|
277
|
+
const all = await playRoom(`${room!.name ?? "playtest"}.then-${i + 1}`, () => step.commands, (p) => (p === 0 ? dir : peerDir(p)), step.clock ?? clock, `then[${i}]`);
|
|
278
|
+
for (const [p, s] of all.entries()) for (const f of s.unchecked ?? []) unchecked.push(`then[${i}]${p > 0 ? ` peer ${p}` : ""} ${f}`);
|
|
279
|
+
later.push(runOf(all[0]!, dir, [], all.slice(1).map((s, p) => runOf(s, peerDir(p + 1), []))));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
191
282
|
const s = spawn(command(step.commands, dir, step.clock ?? clock), `then[${i}]`);
|
|
192
283
|
if (!s) finish();
|
|
193
284
|
for (const f of s!.unchecked ?? []) unchecked.push(`then[${i}] ${f}`);
|
|
194
285
|
later.push(runOf(s!, dir, []));
|
|
195
286
|
}
|
|
196
|
-
const run = runOf(
|
|
287
|
+
const run = runOf(
|
|
288
|
+
summary!,
|
|
289
|
+
out,
|
|
290
|
+
later,
|
|
291
|
+
peerSummaries.map((s, i) => runOf(s, join(out, `peer-${i + 1}`), [])),
|
|
292
|
+
);
|
|
197
293
|
// Every file a run took a screenshot for without a renderer, by name and by path.
|
|
198
294
|
const missedPaths = new Set<string>([...(summary!.unchecked ?? []).map((f) => resolve(out, f)), ...later.flatMap((r, i) => r.unchecked.map((f) => resolve(join(out, `then-${i + 1}`), f)))]);
|
|
199
295
|
const missedNames = new Set<string>([...(summary!.unchecked ?? []), ...later.flatMap((r) => r.unchecked)]);
|
|
@@ -240,7 +336,7 @@ function missedFrame(e: unknown): string | null {
|
|
|
240
336
|
return null;
|
|
241
337
|
}
|
|
242
338
|
try {
|
|
243
|
-
await desc!.check(run, t);
|
|
339
|
+
if (desc!.check) await desc!.check(run, t);
|
|
244
340
|
} catch (e) {
|
|
245
341
|
const frame = missedFrame(e);
|
|
246
342
|
if (frame !== null) notes.push(`the check read ${frame}, a frame taken without a renderer, and stopped there: the checks after it did not run (put frame checks in t.frame so every other check runs)`);
|
package/src/node/polish.ts
CHANGED
|
@@ -78,6 +78,8 @@ interface UiDump {
|
|
|
78
78
|
tick: number;
|
|
79
79
|
/** The surface the CSS-pixel bounds are on (dpr: device pixels per CSS pixel). */
|
|
80
80
|
surface?: { width: number; height: number; dpr: number } | null;
|
|
81
|
+
/** The configured UI style's name: "" is the engine's placeholder, prototype. */
|
|
82
|
+
style?: string | null;
|
|
81
83
|
elements: Array<{ id: string; parent: string; kind?: string; disabled?: boolean; clipped?: boolean; text: string; x: number; y: number; width: number; height: number }>;
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -626,6 +628,14 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
|
|
|
626
628
|
const tickIndex = new Map(s.map((p, i) => [p.tick, i]));
|
|
627
629
|
const findings: Finding[] = [];
|
|
628
630
|
|
|
631
|
+
// The UI's style: a game that shows UI in the engine's placeholder has
|
|
632
|
+
// not chosen its own look (og-taste §1).
|
|
633
|
+
const dressed = rec.shots
|
|
634
|
+
.filter((shot) => shot.ui && existsSync(shot.ui))
|
|
635
|
+
.map((shot) => JSON.parse(readFileSync(shot.ui!, "utf8")) as UiDump)
|
|
636
|
+
.filter((ui) => ui.elements.length > 0 && typeof ui.style === "string");
|
|
637
|
+
if (dressed.some((ui) => ui.style === "")) findings.push({ measure: "UI style", where: rec.playtest, detail: "the UI draws in prototype, the engine's placeholder style (greys, square corners), not a style of the game's own", fix: "og-taste §1: name a style in the game's look (`ui \"<slug>\"` or `ui \"styles/<name>/style.ogt\"`), or extend a catalogue preset, which names one" });
|
|
638
|
+
|
|
629
639
|
// Frames: contrast, text size, HUD.
|
|
630
640
|
const shots: PlaytestMeasures["shots"] = [];
|
|
631
641
|
const allTexts: Array<TextBox & { shot: string }> = [];
|
|
@@ -660,14 +670,14 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
|
|
|
660
670
|
}
|
|
661
671
|
const contrastPass = allTexts.filter((b) => b.contrast >= 4.5);
|
|
662
672
|
const worstContrast = [...allTexts].sort((a, b) => a.contrast - b.contrast).slice(0, 3).map((b) => ({ shot: b.shot, id: b.id, text: b.text.slice(0, 40), ratio: b.contrast }));
|
|
663
|
-
for (const w of worstContrast.filter((w) => w.ratio < 4.5)) findings.push({ measure: "contrast", where: `${rec.playtest} ${w.shot}: \`${w.id}\` "${w.text}"`, detail: `${w.ratio}:1 between ink and ground (body text wants 4.5:1)`, fix: "og-taste §3 (value before hue): darken the ground or lighten the ink in the
|
|
673
|
+
for (const w of worstContrast.filter((w) => w.ratio < 4.5)) findings.push({ measure: "contrast", where: `${rec.playtest} ${w.shot}: \`${w.id}\` "${w.text}"`, detail: `${w.ratio}:1 between ink and ground (body text wants 4.5:1)`, fix: "og-taste §3 (value before hue): darken the ground or lighten the ink in the style's palette, never per call" });
|
|
664
674
|
const sized = allTexts.filter((b) => b.ink > 0);
|
|
665
675
|
// Ink lines of at least 9 px are a 13 px face (x-height and caps to descenders).
|
|
666
676
|
const sizePass = sized.filter((b) => b.ink >= 9);
|
|
667
677
|
const smallest = [...sized].sort((a, b) => a.ink - b.ink).slice(0, 3).map((b) => ({ shot: b.shot, id: b.id, text: b.text.slice(0, 40), ink: b.ink }));
|
|
668
678
|
for (const w of smallest.filter((w) => w.ink < 9)) findings.push({ measure: "text size", where: `${rec.playtest} ${w.shot}: \`${w.id}\` "${w.text}"`, detail: `ink lines ${w.ink} CSS px tall (a 13 px face's capitals and digits are about 9)`, fix: "og-taste §4 and §10.6: use the ramp's body step or larger for anything read on a phone" });
|
|
669
679
|
if (gameplayShots > 0 && (worstShare > 15 || worstCentre > 5))
|
|
670
|
-
findings.push({ measure: "HUD occupancy", where: `${rec.playtest}`, detail: `the HUD covers up to ${worstShare} % of the surface and ${worstCentre} % of the central third`, fix: "og-taste §2 (the play surface is the hero; HUD weight):
|
|
680
|
+
findings.push({ measure: "HUD occupancy", where: `${rec.playtest}`, detail: `the HUD covers up to ${worstShare} % of the surface and ${worstCentre} % of the central third`, fix: "og-taste §2 (the play surface is the hero; HUD weight): HUD boxes in the corners, the centre third free" });
|
|
671
681
|
|
|
672
682
|
// UI motion, transitions and sounds.
|
|
673
683
|
let screenChanges = 0, withMotion = 0, motionTicks = 0;
|
|
@@ -728,7 +738,7 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
|
|
|
728
738
|
else if (median(lat) > 1) findings.push({ measure: "input latency", where: rec.playtest, detail: `the median input is answered ${median(lat)} ticks after the tick it landed (0 is the same tick)`, fix: "og-feel §1: read input and act in the same tick; buffer, do not queue" });
|
|
729
739
|
}
|
|
730
740
|
if (screenChanges > 0 && withMotion < screenChanges) findings.push({ measure: "screen transitions", where: rec.playtest, detail: `${screenChanges - withMotion} of ${screenChanges} screen changes had no motion in the 20 ticks around them`, fix: "og-taste §5 (screens change through a transition): og.ui.screenTransition, one kind per game" });
|
|
731
|
-
if (presses > 0 && pressesWithSound < presses) findings.push({ measure: "UI sounds", where: rec.playtest, detail: `${presses - pressesWithSound} of ${presses} UI presses played no sound on the ui bus`, fix: "og-taste §6 (feedback) and og-audio §3: give the
|
|
741
|
+
if (presses > 0 && pressesWithSound < presses) findings.push({ measure: "UI sounds", where: rec.playtest, detail: `${presses - pressesWithSound} of ${presses} UI presses played no sound on the ui bus`, fix: "og-taste §6 (feedback) and og-audio §3: give the style or og.ui.configure its press and open sounds" });
|
|
732
742
|
|
|
733
743
|
// Damage facts and their feedback.
|
|
734
744
|
const moments: number[] = [];
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// `og render` on the native host (docs/design.md §2.9): the game run
|
|
2
|
+
// headless at its own tick, one frame read back every `rate / fps` ticks,
|
|
3
|
+
// streamed to `og` (crates/og-cli/src/render.rs), which writes the frames,
|
|
4
|
+
// encodes the video and draws the contact sheet. One process of two kinds:
|
|
5
|
+
//
|
|
6
|
+
// bun src/node/render.ts --entry <game.js> [--root <project>] [--seed S] [--size WxH]
|
|
7
|
+
// [--commands "t:action[:args];..."] --fps F [--from <seconds>] [--warmup W]
|
|
8
|
+
// [--clock <UTC ms>] [--locale <tag>] [--out <dir>]
|
|
9
|
+
// a range: --first I --count N [--restore <snapshot>] [--audio <out.wav>]
|
|
10
|
+
// the leader: --leader --end N [--snapshot <frame>:<file>]... [--audio <out.wav>]
|
|
11
|
+
//
|
|
12
|
+
// Frame i of the render shows the world at tick T0 + i·k, where T0 is
|
|
13
|
+
// round(from · rate) and k = rate / fps (the fps must divide the tick rate).
|
|
14
|
+
// - A range sends frames I to I + N - 1, after W warm-up frames on the same
|
|
15
|
+
// grid before it (drawn, not sent: the renderer's history, TAA and the
|
|
16
|
+
// adapted exposure, is then that of a run that drew them). Ticks between
|
|
17
|
+
// frames are simulated without drawing. With --restore it starts from the
|
|
18
|
+
// leader's render snapshot, taken at the first warm-up frame's tick,
|
|
19
|
+
// instead of simulating from tick 0. With --audio the capture mixer's
|
|
20
|
+
// WAV from tick 0 to the range's end is written there.
|
|
21
|
+
// - The leader (`og render --jobs N`) simulates to frame N's tick without
|
|
22
|
+
// drawing, writing a render snapshot at the first warm-up tick of each
|
|
23
|
+
// --snapshot frame, and the WAV.
|
|
24
|
+
//
|
|
25
|
+
// Stdout carries records only: a little-endian u32 length, that many bytes
|
|
26
|
+
// of JSON, then the JSON's `bytes` of payload. A frame is
|
|
27
|
+
// `{kind:"frame", index, tick, time, width, height, sha256, bytes}` with its
|
|
28
|
+
// RGBA8 rows; the last record is `{kind:"end", ...}`. Everything else
|
|
29
|
+
// (the engine's log, warnings) goes to stderr. Outputs of the script's own
|
|
30
|
+
// steps (screenshot, dump_*) are written under --out.
|
|
31
|
+
|
|
32
|
+
import { mkdirSync, readFileSync, writeFileSync, writeSync } from "node:fs";
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
35
|
+
import { pathToFileURL } from "node:url";
|
|
36
|
+
import { loadNativeHost, native } from "./index.ts";
|
|
37
|
+
import { openAudio } from "./audio.ts";
|
|
38
|
+
import { nativePlatform } from "./platform.ts";
|
|
39
|
+
import { createEngine, fixedClock, type Engine, type GameToken } from "../runtime.ts";
|
|
40
|
+
|
|
41
|
+
declare const Bun: { sleepSync(ms: number): void };
|
|
42
|
+
|
|
43
|
+
const argv = process.argv.slice(2);
|
|
44
|
+
let entry = "";
|
|
45
|
+
let root: string | undefined;
|
|
46
|
+
let seed: number | undefined;
|
|
47
|
+
let commands: string | undefined;
|
|
48
|
+
let size = { width: 1280, height: 720 };
|
|
49
|
+
let fps = 0;
|
|
50
|
+
let fromSeconds = 0;
|
|
51
|
+
let first: number | undefined;
|
|
52
|
+
let count: number | undefined;
|
|
53
|
+
let warmup = 0;
|
|
54
|
+
let restore: string | undefined;
|
|
55
|
+
let audioOut: string | undefined;
|
|
56
|
+
let leader = false;
|
|
57
|
+
let end: number | undefined;
|
|
58
|
+
let clock: number | undefined;
|
|
59
|
+
let locale: string | undefined;
|
|
60
|
+
let out: string | undefined;
|
|
61
|
+
const snapshots: { frame: number; file: string }[] = [];
|
|
62
|
+
for (let i = 0; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i]!;
|
|
64
|
+
const next = () => {
|
|
65
|
+
const v = argv[++i];
|
|
66
|
+
if (v === undefined) fail(`${a} needs a value`);
|
|
67
|
+
return v!;
|
|
68
|
+
};
|
|
69
|
+
const whole = (v: string) => {
|
|
70
|
+
const n = Number(v);
|
|
71
|
+
if (!Number.isInteger(n) || n < 0) fail(`${a} takes a whole number, not '${v}'`);
|
|
72
|
+
return n;
|
|
73
|
+
};
|
|
74
|
+
if (a === "--entry") entry = next();
|
|
75
|
+
else if (a === "--root") root = resolve(next());
|
|
76
|
+
else if (a === "--seed") seed = whole(next());
|
|
77
|
+
else if (a === "--commands") commands = next();
|
|
78
|
+
else if (a === "--size") {
|
|
79
|
+
const m = /^(\d+)x(\d+)$/.exec(next());
|
|
80
|
+
if (!m) fail("--size takes WIDTHxHEIGHT, such as 1280x720");
|
|
81
|
+
size = { width: Number(m![1]), height: Number(m![2]) };
|
|
82
|
+
} else if (a === "--fps") fps = whole(next());
|
|
83
|
+
else if (a === "--from") {
|
|
84
|
+
fromSeconds = Number(next());
|
|
85
|
+
if (!(Number.isFinite(fromSeconds) && fromSeconds >= 0)) fail("--from takes seconds, 0 or more");
|
|
86
|
+
} else if (a === "--first") first = whole(next());
|
|
87
|
+
else if (a === "--count") count = whole(next());
|
|
88
|
+
else if (a === "--warmup") warmup = whole(next());
|
|
89
|
+
else if (a === "--restore") restore = resolve(next());
|
|
90
|
+
else if (a === "--audio") audioOut = resolve(next());
|
|
91
|
+
else if (a === "--leader") leader = true;
|
|
92
|
+
else if (a === "--end") end = whole(next());
|
|
93
|
+
else if (a === "--snapshot") {
|
|
94
|
+
const v = next();
|
|
95
|
+
const at = v.indexOf(":");
|
|
96
|
+
if (at <= 0) fail("--snapshot takes <frame>:<file>");
|
|
97
|
+
snapshots.push({ frame: whole(v.slice(0, at)), file: resolve(v.slice(at + 1)) });
|
|
98
|
+
} else if (a === "--clock") clock = Number(next());
|
|
99
|
+
else if (a === "--locale") locale = next();
|
|
100
|
+
else if (a === "--out") out = resolve(next());
|
|
101
|
+
else fail(`unknown argument '${a}'`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fail(reason: string): never {
|
|
105
|
+
process.stderr.write(`og render: ${reason}\n`);
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!entry) fail("no game entry: pass --entry <file>");
|
|
110
|
+
if (fps <= 0) fail("--fps takes frames per second, such as 30");
|
|
111
|
+
if (leader ? end === undefined : first === undefined || count === undefined) fail("a range takes --first and --count, the leader --end");
|
|
112
|
+
|
|
113
|
+
/** One record on stdout: length, JSON, payload. */
|
|
114
|
+
function record(header: Record<string, unknown>, payload?: Uint8Array): void {
|
|
115
|
+
const json = new TextEncoder().encode(JSON.stringify({ ...header, bytes: payload?.length ?? 0 }));
|
|
116
|
+
const len = new Uint8Array(4);
|
|
117
|
+
new DataView(len.buffer).setUint32(0, json.length, true);
|
|
118
|
+
writeAll(len);
|
|
119
|
+
writeAll(json);
|
|
120
|
+
if (payload) writeAll(payload);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeAll(bytes: Uint8Array): void {
|
|
124
|
+
let at = 0;
|
|
125
|
+
while (at < bytes.length) {
|
|
126
|
+
try {
|
|
127
|
+
at += writeSync(1, bytes, at, bytes.length - at);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// A pipe that is full answers EAGAIN on a non-blocking descriptor: wait.
|
|
130
|
+
if ((e as { code?: string }).code !== "EAGAIN") throw e;
|
|
131
|
+
Bun.sleepSync(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
loadNativeHost();
|
|
137
|
+
const extras = native();
|
|
138
|
+
const mod = (await import(pathToFileURL(resolve(entry)).href)) as { default?: GameToken };
|
|
139
|
+
if (!mod.default) fail(`${entry} has no default export: export default og.game({ ... })`);
|
|
140
|
+
const projectText = root ? (() => {
|
|
141
|
+
try {
|
|
142
|
+
return readFileSync(join(root, "project.ogt"), "utf8");
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
})() : undefined;
|
|
147
|
+
const profile = projectText ? (/^\s*profile\s+"(2d|3d)"/m.exec(projectText)?.[1] as "2d" | "3d" | undefined) : undefined;
|
|
148
|
+
const created = createEngine(mod.default!, {
|
|
149
|
+
...(seed === undefined ? {} : { seed }),
|
|
150
|
+
...(root ? { root } : {}),
|
|
151
|
+
...(projectText === undefined ? {} : { project: projectText }),
|
|
152
|
+
...(profile ? { profile } : {}),
|
|
153
|
+
...(locale ? { locale } : {}),
|
|
154
|
+
deferStart: true,
|
|
155
|
+
});
|
|
156
|
+
if (!created.ok) fail(`${entry}: ${created.reason}`);
|
|
157
|
+
const engine: Engine = created.value;
|
|
158
|
+
if (clock !== undefined) engine.setClock(fixedClock(engine, clock));
|
|
159
|
+
engine.attachPlatform(nativePlatform(undefined));
|
|
160
|
+
|
|
161
|
+
const rate = engine.rate;
|
|
162
|
+
if (rate % fps !== 0) {
|
|
163
|
+
const divisors = [];
|
|
164
|
+
for (let d = 1; d <= rate; d++) if (rate % d === 0) divisors.push(d);
|
|
165
|
+
const below = divisors.filter((d) => d < fps).pop();
|
|
166
|
+
const above = divisors.find((d) => d > fps);
|
|
167
|
+
fail(`--fps ${fps} does not divide the game's tick rate ${rate}: a frame would fall between ticks; the nearest that do are ${[below, above].filter((d) => d !== undefined).join(" and ")}`);
|
|
168
|
+
}
|
|
169
|
+
const step = rate / fps;
|
|
170
|
+
const tickMs = 1000 / rate;
|
|
171
|
+
const t0 = Math.round(fromSeconds * rate);
|
|
172
|
+
/** The tick frame `i` shows. */
|
|
173
|
+
const tickOf = (i: number): number => t0 + i * step;
|
|
174
|
+
/** The tick a range starting at frame `i` starts drawing at (its first warm-up frame). */
|
|
175
|
+
const warmTick = (i: number): number => Math.max(0, tickOf(i) - warmup * step);
|
|
176
|
+
|
|
177
|
+
// The renderer: the leader attaches one too, so GameDesc.start sees the same
|
|
178
|
+
// play surface (og.render.capabilities) in every process of the render.
|
|
179
|
+
const attached = JSON.parse(extras.og_node_attach_offscreen(engine.id, size.width, size.height)) as { ok: boolean; value?: string; reason?: string };
|
|
180
|
+
if (!attached.ok && !leader) fail(`no renderer (${attached.reason}): og render needs a headless renderer (og doctor)`);
|
|
181
|
+
const tier = attached.ok ? attached.value! : "none";
|
|
182
|
+
const started = engine.advance(0);
|
|
183
|
+
if (!started.ok) fail(started.reason);
|
|
184
|
+
|
|
185
|
+
const sink = (() => {
|
|
186
|
+
const opened = openAudio(engine.id, audioOut ? "capture" : "null");
|
|
187
|
+
if (!opened.ok) fail(`audio: ${opened.reason}`);
|
|
188
|
+
return opened.value;
|
|
189
|
+
})();
|
|
190
|
+
|
|
191
|
+
const warnings: string[] = [];
|
|
192
|
+
function drain(): void {
|
|
193
|
+
for (const o of engine.takeOutputs()) {
|
|
194
|
+
if (!out) continue;
|
|
195
|
+
const path = resolve(out, o.file);
|
|
196
|
+
const inside = relative(out, path);
|
|
197
|
+
if (inside === "" || inside.startsWith(".." + sep) || inside === ".." || isAbsolute(inside)) continue;
|
|
198
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
199
|
+
writeFileSync(path, o.kind === "png" || o.kind === "wav" ? Uint8Array.from(atob(o.data), (c) => c.charCodeAt(0)) : o.data);
|
|
200
|
+
process.stderr.write(`tick ${o.tick} ${o.action}: wrote ${path}\n`);
|
|
201
|
+
}
|
|
202
|
+
for (const l of engine.takeLog()) {
|
|
203
|
+
const line = `${l.warning ? "WARN" : "INFO"} tick ${l.tick}: ${l.text}`;
|
|
204
|
+
if (l.warning) warnings.push(line);
|
|
205
|
+
process.stderr.write(line + "\n");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
drain();
|
|
209
|
+
|
|
210
|
+
if (commands !== undefined) {
|
|
211
|
+
const report = engine.probe.commands(commands) as { warnings: string[] } | null;
|
|
212
|
+
drain();
|
|
213
|
+
if (!report) fail("--commands: the script is malformed (the reason is above)");
|
|
214
|
+
for (const w of report!.warnings) {
|
|
215
|
+
warnings.push(`WARN ${w}`);
|
|
216
|
+
process.stderr.write(`WARN ${w}\n`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (restore) {
|
|
220
|
+
const r = JSON.parse(extras.og_node_render_restore(engine.id, readFileSync(restore))) as { ok: boolean; value?: number; reason?: string };
|
|
221
|
+
if (!r.ok) fail(`--restore ${restore}: ${r.reason}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let quit = false;
|
|
225
|
+
const tick = (): number => engine.probe.tick();
|
|
226
|
+
|
|
227
|
+
/** Simulate to tick `t` without drawing. */
|
|
228
|
+
function simulate(t: number): void {
|
|
229
|
+
const n = t - tick();
|
|
230
|
+
if (n <= 0) return;
|
|
231
|
+
const r = engine.advance(n);
|
|
232
|
+
if (!r.ok) fail(r.reason);
|
|
233
|
+
sink.pump();
|
|
234
|
+
drain();
|
|
235
|
+
// The probe takes no tick after a `quit` step.
|
|
236
|
+
if (tick() < t) quit = true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Bring the world to tick `t` and draw it there. */
|
|
240
|
+
function drawAt(t: number): void {
|
|
241
|
+
if (t > tick()) simulate(t - 1);
|
|
242
|
+
if (quit) return;
|
|
243
|
+
const answer = engine.frame(t > tick() ? tickMs : 0);
|
|
244
|
+
if (!answer.ok) fail(answer.reason ?? "the frame failed");
|
|
245
|
+
sink.pump();
|
|
246
|
+
drain();
|
|
247
|
+
if (answer.quit) quit = true;
|
|
248
|
+
if (!answer.rendered) fail("the frame was not drawn");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (leader) {
|
|
252
|
+
for (const s of [...snapshots].sort((a, b) => a.frame - b.frame)) {
|
|
253
|
+
const at = warmTick(s.frame);
|
|
254
|
+
simulate(at);
|
|
255
|
+
if (tick() !== at) fail(`the world stopped at tick ${tick()} before snapshot tick ${at} (a quit step?)`);
|
|
256
|
+
const blob = extras.og_node_render_snapshot(engine.id);
|
|
257
|
+
if (blob.length === 0) fail(`the engine refused a snapshot at tick ${at}`);
|
|
258
|
+
writeFileSync(s.file, blob);
|
|
259
|
+
}
|
|
260
|
+
simulate(tickOf(end!));
|
|
261
|
+
} else {
|
|
262
|
+
const from = tickOf(first!);
|
|
263
|
+
// Warm-up frames on the frame grid before the range: drawn, not sent.
|
|
264
|
+
for (let t = Math.max(tick(), warmTick(first!)); t < from && !quit; t += step) drawAt(t);
|
|
265
|
+
let sent = 0;
|
|
266
|
+
for (let i = first!; i < first! + count! && !quit; i++) {
|
|
267
|
+
const t = tickOf(i);
|
|
268
|
+
drawAt(t);
|
|
269
|
+
if (quit) break;
|
|
270
|
+
const rgba = extras.og_node_render_frame(engine.id);
|
|
271
|
+
if (rgba.length !== size.width * size.height * 4) fail(`frame ${i} read back ${rgba.length} bytes, not ${size.width}x${size.height} RGBA`);
|
|
272
|
+
const sha256 = createHash("sha256").update(rgba).digest("hex");
|
|
273
|
+
record({ kind: "frame", index: i, tick: t, time: t / rate, width: size.width, height: size.height, sha256 }, rgba);
|
|
274
|
+
sent++;
|
|
275
|
+
}
|
|
276
|
+
// The audio runs to the range's end, the last frame's span included.
|
|
277
|
+
if (audioOut && !quit) simulate(tickOf(first! + count!));
|
|
278
|
+
if (sent < count!) warnings.push(`WARN the script quit at tick ${tick()}: ${sent} of ${count} frames`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let wavSha256: string | undefined;
|
|
282
|
+
if (audioOut) {
|
|
283
|
+
const wav = sink.wav();
|
|
284
|
+
mkdirSync(dirname(audioOut), { recursive: true });
|
|
285
|
+
writeFileSync(audioOut, wav);
|
|
286
|
+
wavSha256 = createHash("sha256").update(wav).digest("hex");
|
|
287
|
+
}
|
|
288
|
+
sink.close();
|
|
289
|
+
const dump = engine.probe.dump({ what: "world" }) as unknown as { hash: string };
|
|
290
|
+
record({ kind: "end", tick: tick(), hash: dump.hash, rate, fromTick: t0, step, tier, quit, warnings, wavSha256 });
|
|
291
|
+
process.exit(0);
|