@origingame/engine 0.0.0-stage → 0.1.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.
Files changed (158) hide show
  1. package/bin/og.mjs +25 -0
  2. package/dist/web/origin-engine.d.ts +1233 -0
  3. package/dist/web/origin-engine.js +8628 -0
  4. package/dist/web/origin-engine_bg.wasm +0 -0
  5. package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
  6. package/dist/web-publish/origin-engine-lanes.js +8509 -0
  7. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  8. package/dist/web-publish/origin-engine.d.ts +1176 -0
  9. package/dist/web-publish/origin-engine.js +8313 -0
  10. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  11. package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
  12. package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  13. package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
  14. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  15. package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
  16. package/dist/web-publish-2d/origin-engine.js +8313 -0
  17. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  18. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
  19. package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  20. package/knowledge/README.md +31 -0
  21. package/knowledge/knowledge.json +13 -0
  22. package/knowledge/looks/catalogue.json +1871 -0
  23. package/knowledge/package.json +33 -0
  24. package/knowledge/rules/platform.json +329 -0
  25. package/knowledge/scaffold/README.md +27 -0
  26. package/knowledge/scaffold/distribution.json +48 -0
  27. package/knowledge/scaffold/origin-engine.json +19 -0
  28. package/knowledge/scaffold/template/.origin/setup +14 -0
  29. package/knowledge/scaffold/template/LICENSES.md +23 -0
  30. package/knowledge/scaffold/template/README.md +37 -0
  31. package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
  32. package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
  33. package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
  34. package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
  35. package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
  36. package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
  37. package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
  38. package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
  39. package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
  40. package/knowledge/scaffold/template/gitattributes +22 -0
  41. package/knowledge/scaffold/template/package.json +18 -0
  42. package/knowledge/scaffold/template/project.ogt +25 -0
  43. package/knowledge/scaffold/template/src/main.ts +246 -0
  44. package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
  45. package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
  46. package/knowledge/scaffold/template/tsconfig.json +17 -0
  47. package/knowledge/scripts/lib.ts +203 -0
  48. package/knowledge/scripts/mount.ts +210 -0
  49. package/knowledge/scripts/pack.ts +24 -0
  50. package/knowledge/scripts/scaffold.ts +72 -0
  51. package/knowledge/scripts/validate.ts +749 -0
  52. package/knowledge/skills/og-ai/SKILL.md +386 -0
  53. package/knowledge/skills/og-audio/SKILL.md +191 -0
  54. package/knowledge/skills/og-build/SKILL.md +330 -0
  55. package/knowledge/skills/og-core/SKILL.md +285 -0
  56. package/knowledge/skills/og-feel/SKILL.md +385 -0
  57. package/knowledge/skills/og-game/SKILL.md +1479 -0
  58. package/knowledge/skills/og-i18n/SKILL.md +220 -0
  59. package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
  60. package/knowledge/skills/og-taste/SKILL.md +537 -0
  61. package/knowledge/skills/og-ui/SKILL.md +444 -0
  62. package/knowledge/wiki/ai.md +627 -0
  63. package/knowledge/wiki/anim.md +1187 -0
  64. package/knowledge/wiki/atmosphere.md +636 -0
  65. package/knowledge/wiki/audio.md +732 -0
  66. package/knowledge/wiki/camera.md +711 -0
  67. package/knowledge/wiki/commands.md +357 -0
  68. package/knowledge/wiki/game.md +220 -0
  69. package/knowledge/wiki/i18n.md +213 -0
  70. package/knowledge/wiki/index.md +1027 -0
  71. package/knowledge/wiki/input.md +1714 -0
  72. package/knowledge/wiki/load.md +1280 -0
  73. package/knowledge/wiki/math.md +166 -0
  74. package/knowledge/wiki/nav.md +1154 -0
  75. package/knowledge/wiki/physics.md +3142 -0
  76. package/knowledge/wiki/platform.md +857 -0
  77. package/knowledge/wiki/probe.md +1692 -0
  78. package/knowledge/wiki/reflect.md +285 -0
  79. package/knowledge/wiki/render.md +3511 -0
  80. package/knowledge/wiki/rng.md +393 -0
  81. package/knowledge/wiki/stream.md +376 -0
  82. package/knowledge/wiki/system.md +197 -0
  83. package/knowledge/wiki/systems.md +43 -0
  84. package/knowledge/wiki/terrain.md +727 -0
  85. package/knowledge/wiki/tilemap.md +422 -0
  86. package/knowledge/wiki/time.md +150 -0
  87. package/knowledge/wiki/transform.md +230 -0
  88. package/knowledge/wiki/tween.md +580 -0
  89. package/knowledge/wiki/ui.md +8000 -0
  90. package/knowledge/wiki/world.md +909 -0
  91. package/package.json +68 -4
  92. package/schema/api.schema.json +29675 -0
  93. package/shell/README.md +22 -0
  94. package/shell/brand/README.md +23 -0
  95. package/shell/brand/icon-192.png +0 -0
  96. package/shell/brand/icon-512.png +0 -0
  97. package/shell/brand/mark-on-paper.svg +6 -0
  98. package/shell/brand/origin-mark.svg +5 -0
  99. package/shell/brand/origin-motion.css +251 -0
  100. package/shell/brand/tokens.css +20 -0
  101. package/shell/brand.ts +132 -0
  102. package/shell/page.ts +715 -0
  103. package/shell/platform.ts +203 -0
  104. package/shell/player.ts +238 -0
  105. package/shell/shell.css +185 -0
  106. package/shell/sw.ts +177 -0
  107. package/shell/transport.ts +515 -0
  108. package/shell/wire.ts +369 -0
  109. package/src/e6.ts +69 -0
  110. package/src/env.d.ts +100 -0
  111. package/src/generated/abi.ts +980 -0
  112. package/src/index.ts +193 -0
  113. package/src/node/audio.ts +86 -0
  114. package/src/node/build.ts +109 -0
  115. package/src/node/device.ts +981 -0
  116. package/src/node/doctor.ts +60 -0
  117. package/src/node/index.ts +102 -0
  118. package/src/node/platform.ts +24 -0
  119. package/src/node/playtest-run.ts +249 -0
  120. package/src/node/png.ts +130 -0
  121. package/src/node/polish.ts +976 -0
  122. package/src/node/run.ts +373 -0
  123. package/src/node/web-build.ts +808 -0
  124. package/src/p2.ts +35 -0
  125. package/src/platform-local.ts +211 -0
  126. package/src/playtest.ts +131 -0
  127. package/src/runtime.ts +1509 -0
  128. package/src/systems/cameras.ts +247 -0
  129. package/src/ui-kit/bind.ts +141 -0
  130. package/src/ui-kit/huds.ts +303 -0
  131. package/src/ui-kit/index.ts +36 -0
  132. package/src/ui-kit/menus.ts +187 -0
  133. package/src/ui-kit/settings.ts +131 -0
  134. package/src/ui-kit/store.ts +222 -0
  135. package/src/ui-kit/story.ts +89 -0
  136. package/src/ui-kit/util.ts +106 -0
  137. package/src/web/a11y.ts +429 -0
  138. package/src/web/audio-worklet.ts +193 -0
  139. package/src/web/audio.ts +307 -0
  140. package/src/web/basis.ts +239 -0
  141. package/src/web/gamepad.ts +123 -0
  142. package/src/web/host.ts +166 -0
  143. package/src/web/input.ts +237 -0
  144. package/src/web/lanes.ts +21 -0
  145. package/src/web/play.ts +20 -0
  146. package/src/web/rpc-ring.ts +110 -0
  147. package/src/web/settle.ts +122 -0
  148. package/src/web/shell.ts +643 -0
  149. package/src/web/text-input.ts +248 -0
  150. package/src/web/worker.ts +1002 -0
  151. package/types/bindings.json +13654 -0
  152. package/types/index.d.ts +20090 -0
  153. package/vendor/basis/LICENSE +201 -0
  154. package/vendor/basis/NOTICE +15 -0
  155. package/vendor/basis/README.md +18 -0
  156. package/vendor/basis/basis_transcoder.js +19 -0
  157. package/vendor/basis/basis_transcoder.wasm +0 -0
  158. package/README.md +0 -3
@@ -0,0 +1,60 @@
1
+ // `og doctor`'s engine checks, as one JSON line: does this package's native
2
+ // addon load, is the web host's wasm built, and can this machine render a
3
+ // frame headless (Metal on a Mac, Vulkan on a GPU). No renderer is a
4
+ // supported configuration, not a problem: the playtests' frames are then
5
+ // unchecked (OG_RENDERER=none makes any machine so).
6
+ //
7
+ // bun src/node/doctor.ts
8
+
9
+ import { existsSync, readFileSync, statSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { addonPath, loadNativeHost, native } from "./index.ts";
13
+ import { createEngine, type GameToken } from "../runtime.ts";
14
+ import { og } from "../index.ts";
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const pkg = join(here, "..", "..");
18
+ const version = (JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")) as { version: string }).version;
19
+
20
+ const report: Record<string, unknown> = { package: pkg, version };
21
+ const addon = addonPath();
22
+ try {
23
+ if (!existsSync(addon)) throw new Error(`no addon at ${addon}`);
24
+ loadNativeHost(addon);
25
+ report.addon = { ok: true, path: addon, views: native().og_node_views_mode() };
26
+ } catch (e) {
27
+ report.addon = { ok: false, path: addon, reason: e instanceof Error ? e.message : String(e) };
28
+ }
29
+ const wasm = join(pkg, "dist", "web", "origin-engine_bg.wasm");
30
+ report.wasm = existsSync(wasm) ? { ok: true, path: wasm, bytes: statSync(wasm).size } : { ok: false, path: wasm };
31
+ const publishWasm = join(pkg, "dist", "web-publish", "origin-engine_bg.wasm");
32
+ report.publishWasm = existsSync(publishWasm) ? { ok: true, path: publishWasm, bytes: statSync(publishWasm).size } : { ok: false, path: publishWasm };
33
+ // The 2D publish variant: what `og build --publish` ships for a 2d game (docs/web-export.md §Variants).
34
+ const publishWasm2d = join(pkg, "dist", "web-publish-2d", "origin-engine_bg.wasm");
35
+ report.publishWasm2d = existsSync(publishWasm2d) ? { ok: true, path: publishWasm2d, bytes: statSync(publishWasm2d).size } : { ok: false, path: publishWasm2d };
36
+ // The lanes variants (wasm atomics), one per physics profile beside its
37
+ // publish wasm: `og build` ships the one of the game's profile for cross-origin
38
+ // isolated pages (docs/web-export.md §Lanes, §Variants).
39
+ const lanesWasm = join(pkg, "dist", "web-publish", "origin-engine-lanes_bg.wasm");
40
+ report.lanesWasm = existsSync(lanesWasm) ? { ok: true, path: lanesWasm, bytes: statSync(lanesWasm).size } : { ok: false, path: lanesWasm };
41
+ const lanesWasm2d = join(pkg, "dist", "web-publish-2d", "origin-engine-lanes_bg.wasm");
42
+ report.lanesWasm2d = existsSync(lanesWasm2d) ? { ok: true, path: lanesWasm2d, bytes: statSync(lanesWasm2d).size } : { ok: false, path: lanesWasm2d };
43
+
44
+ if ((report.addon as { ok: boolean }).ok) {
45
+ try {
46
+ const t0 = performance.now();
47
+ const created = createEngine(og.game({}) as unknown as GameToken);
48
+ if (!created.ok) throw new Error(created.reason);
49
+ const attached = JSON.parse(native().og_node_attach_offscreen(created.value.id, 64, 64)) as { ok: boolean; value?: string; reason?: string };
50
+ if (!attached.ok) throw new Error(attached.reason);
51
+ const frame = created.value.probe.capture(64, 64) as { width: number } | null;
52
+ if (!frame) throw new Error("the renderer attached but produced no frame");
53
+ report.renderer = { ok: true, tier: attached.value, ms: +(performance.now() - t0).toFixed(0) };
54
+ } catch (e) {
55
+ report.renderer = { ok: false, reason: e instanceof Error ? e.message : String(e) };
56
+ }
57
+ } else {
58
+ report.renderer = { ok: false, reason: "needs the native addon" };
59
+ }
60
+ process.stdout.write(JSON.stringify(report) + "\n");
@@ -0,0 +1,102 @@
1
+ // The native host loader: the napi addon (origin-engine.node, built from
2
+ // crates/og-host-node) under bun or node, headless. `loadGame` imports a game
3
+ // bundle and assembles its engine; `og run --headless` drives it (run.ts).
4
+
5
+ import { createRequire } from "node:module";
6
+ import { existsSync } from "node:fs";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import {
10
+ ARRAYS,
11
+ bindHost,
12
+ boundHost,
13
+ type ColumnArray,
14
+ createEngine,
15
+ type CreateOptions,
16
+ type Engine,
17
+ type GameToken,
18
+ type HostBinding,
19
+ type Result,
20
+ } from "../runtime.ts";
21
+ import type { HostAbi } from "../generated/abi.ts";
22
+
23
+ /**
24
+ * The addon the native loader uses: `OG_NATIVE_ADDON`, else this package's
25
+ * `native/origin-engine.<platform>-<arch>.node` (a checkout's build), else the
26
+ * per-platform package npm installed through optionalDependencies
27
+ * (`@origingame/engine-<platform>-<arch>/origin-engine.node`).
28
+ */
29
+ export function addonPath(): string {
30
+ const fromEnv = process.env.OG_NATIVE_ADDON;
31
+ if (fromEnv) return resolve(fromEnv);
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const platform = `${process.platform}-${process.arch}`;
34
+ const local = join(here, "..", "..", "native", `origin-engine.${platform}.node`);
35
+ if (existsSync(local)) return local;
36
+ try {
37
+ const require = createRequire(import.meta.url) as unknown as { resolve(id: string): string };
38
+ return require.resolve(`@origingame/engine-${platform}/origin-engine.node`);
39
+ } catch {
40
+ return local;
41
+ }
42
+ }
43
+
44
+ /** The addon's own symbols beyond the shared ABI. */
45
+ export interface NativeExtras {
46
+ og_node_views_mode(): string;
47
+ og_node_attach_offscreen(engine: number, width: number, height: number): string;
48
+ /** Waits for the offscreen renderer's GPU work (frame-time benches). */
49
+ og_node_wait_gpu(engine: number): string;
50
+ og_node_probe_listen(engine: number, port: number): string;
51
+ og_node_probe_serve(engine: number): number;
52
+ og_node_probe_close(engine: number): boolean;
53
+ }
54
+
55
+ let extras: NativeExtras | null = null;
56
+ const arrays = new WeakMap<ArrayBuffer, ColumnArray>();
57
+
58
+ /** Load the addon and bind the runtime to it (once per process). */
59
+ export function loadNativeHost(path: string = addonPath()): HostBinding {
60
+ const bound = boundHost();
61
+ if (bound) return bound;
62
+ if (!existsSync(path)) {
63
+ throw new Error(`no native addon at ${path}: build it with \`bun run build\` in packages/engine (scripts/build.sh), or point OG_NATIVE_ADDON at one`);
64
+ }
65
+ const addon = createRequire(import.meta.url)(path) as HostAbi<Uint32Array, unknown> & NativeExtras;
66
+ extras = addon;
67
+ const binding: HostBinding = {
68
+ abi: addon,
69
+ name: "node",
70
+ columns(raw, kinds, _count) {
71
+ if (!Array.isArray(raw) || raw.length === 0) return null;
72
+ // The host hands out the same buffer while a column stays put: so does this.
73
+ return (raw as ArrayBuffer[]).map((buffer, i) => {
74
+ const kind = kinds[i]!.array;
75
+ const known = arrays.get(buffer);
76
+ if (known && known.constructor.name === kind && known.byteLength === buffer.byteLength) return known;
77
+ const made = new ARRAYS[kind](buffer);
78
+ arrays.set(buffer, made);
79
+ return made;
80
+ });
81
+ },
82
+ };
83
+ bindHost(binding);
84
+ return binding;
85
+ }
86
+
87
+ /** The addon's extras (view mode, offscreen renderer), once loaded. */
88
+ export function native(): NativeExtras {
89
+ if (!extras) loadNativeHost();
90
+ return extras!;
91
+ }
92
+
93
+ /** Import a game bundle (its default export is `og.game(...)`) and assemble its engine (`options.root`: the project whose cooked/ it opens). */
94
+ export async function loadGame(entry: string, options: CreateOptions = {}): Promise<Result<Engine>> {
95
+ loadNativeHost();
96
+ const mod = (await import(pathToFileURL(resolve(entry)).href)) as { default?: GameToken };
97
+ if (!mod.default) return { ok: false, reason: `${entry} has no default export: export default og.game({ ... })` };
98
+ return createEngine(mod.default, options);
99
+ }
100
+
101
+ export { createEngine };
102
+ export type { Engine, Result };
@@ -0,0 +1,24 @@
1
+ // The native host's platform: og.platform's calls answered by the local
2
+ // platform (../platform-local.ts) from a JSON file, or from memory. `og run`
3
+ // keeps it in memory unless `--platform-store <file>` names a file, so a
4
+ // scripted run (a playtest) never depends on an earlier one.
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { localPlatform, memoryPersistence, type LocalPersistence, type PlatformHost } from "../platform-local.ts";
9
+
10
+ /** The document in a file, created on the first save. */
11
+ export function filePersistence(path: string): LocalPersistence {
12
+ return {
13
+ load: () => (existsSync(path) ? readFileSync(path, "utf8") : null),
14
+ save: (text) => {
15
+ mkdirSync(dirname(path), { recursive: true });
16
+ writeFileSync(path, text);
17
+ },
18
+ };
19
+ }
20
+
21
+ /** The native platform: a file's store when `path` is given, else memory. */
22
+ export function nativePlatform(path?: string): PlatformHost & { document(): unknown } {
23
+ return localPlatform(path ? filePersistence(path) : memoryPersistence());
24
+ }
@@ -0,0 +1,249 @@
1
+ // Runs one playtest (`tests/*.og.ts`) for `og test`: the script's commands
2
+ // through `og run --headless --commands` in a fresh process (its `runs` and
3
+ // `then` steps in more), then its checks over the files the runs wrote. A
4
+ // playtest that made no check fails.
5
+ //
6
+ // Without a renderer (tier `none`: the platform's engine and probe
7
+ // sandboxes, OG_RENDERER=none) every check runs as before except those
8
+ // inside `t.frame`, and each screenshot the runs took is reported
9
+ // "no renderer, frame unchecked": one line per frame and a count, never a
10
+ // failure. `--result <file>` also writes the result line there (og test
11
+ // sums the unchecked frames from it).
12
+ //
13
+ // bun src/node/playtest-run.ts --file <tests/x.og.ts> --entry <game bundle> [--root <project>] [--out <dir>] [--json] [--result <file>]
14
+
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { basename, dirname, join, resolve } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import { bundleGame } from "./build.ts";
19
+ import { FrameUnchecked, PLAYTEST_CLOCK, type PlaytestChecks, type PlaytestDesc, type PlaytestRun } from "../playtest.ts";
20
+
21
+ declare const Bun: {
22
+ spawnSync(cmd: string[], o?: { cwd?: string; stdout?: string; stderr?: string }): { exitCode: number; stdout: Uint8Array; stderr: Uint8Array };
23
+ };
24
+
25
+ const argv = process.argv.slice(2);
26
+ const opt = (name: string) => {
27
+ const i = argv.indexOf(name);
28
+ return i >= 0 ? argv[i + 1] : undefined;
29
+ };
30
+ const file = opt("--file");
31
+ const entry = opt("--entry");
32
+ const root = opt("--root");
33
+ const resultFile = opt("--result");
34
+ const asJson = argv.includes("--json");
35
+ if (!file || !entry) {
36
+ process.stderr.write("usage: bun src/node/playtest-run.ts --file <tests/x.og.ts> --entry <game bundle> [--root <project>] [--out <dir>] [--json]\n");
37
+ process.exit(2);
38
+ }
39
+ const here = dirname(fileURLToPath(import.meta.url));
40
+ const name = basename(file!).replace(/\.og\.ts$/, "");
41
+ const out = resolve(opt("--out") ?? join(root ?? dirname(file!), "og-out", "test", name));
42
+ rmSync(out, { recursive: true, force: true });
43
+ mkdirSync(out, { recursive: true });
44
+
45
+ const t0 = performance.now();
46
+ const failures: string[] = [];
47
+ let checks = 0;
48
+ // Checks made inside t.frame (with a renderer): what a run without one leaves out.
49
+ let frameChecks = 0;
50
+ // Every screenshot taken without a renderer, by the file it would have written, in order.
51
+ const unchecked: string[] = [];
52
+ // Notes on unchecked frames beyond the per-file lines (a check stopped by one).
53
+ const notes: string[] = [];
54
+ let result: Record<string, unknown> = {};
55
+
56
+ function finish(): never {
57
+ // A playtest whose every check reads a frame has nothing to check without a renderer: it passes, and says so.
58
+ const ok = failures.length === 0 && (checks > 0 || unchecked.length > 0);
59
+ if (checks === 0 && failures.length === 0) {
60
+ if (unchecked.length > 0) notes.push("every check of this playtest reads a frame: nothing was checked without a renderer");
61
+ else failures.push("the playtest made no check: call t.ok, t.equal or t.near at least once");
62
+ }
63
+ const line = { playtest: name, file, ok, checks, frameChecks, unchecked, failures, notes, out, ms: +(performance.now() - t0).toFixed(0), ...result };
64
+ if (resultFile) {
65
+ mkdirSync(dirname(resolve(resultFile)), { recursive: true });
66
+ writeFileSync(resultFile, JSON.stringify(line) + "\n");
67
+ }
68
+ if (asJson) process.stdout.write(JSON.stringify(line) + "\n");
69
+ else {
70
+ const frames = unchecked.length > 0 ? `, ${unchecked.length} frame${unchecked.length === 1 ? "" : "s"} unchecked` : "";
71
+ process.stdout.write(`${ok ? "PASS" : "FAIL"} ${name} (${checks} checks${frames}, ${line.ms} ms)${(line as { title?: string }).title ? ": " + (line as { title?: string }).title : ""}\n`);
72
+ for (const f of failures) process.stdout.write(` - ${f}\n`);
73
+ for (const u of unchecked) process.stdout.write(` ~ ${u}: no renderer, frame unchecked\n`);
74
+ for (const n of notes) process.stdout.write(` ~ ${n}\n`);
75
+ }
76
+ process.exit(ok ? 0 : 1);
77
+ }
78
+
79
+ // The playtest module, bundled like the game so `@origingame/engine/playtest` resolves.
80
+ const bundled = join(out, ".playtest.js");
81
+ const b = await bundleGame(file!, bundled, "native");
82
+ if (!b.ok) {
83
+ failures.push(`the playtest does not bundle: ${b.reason}`);
84
+ finish();
85
+ }
86
+ const desc = ((await import(pathToFileURL(bundled).href)) as { default?: PlaytestDesc }).default;
87
+ if (!desc || typeof desc.check !== "function") {
88
+ failures.push(`${file} has no default export: export default playtest({ name, commands, check })`);
89
+ finish();
90
+ }
91
+ result.title = desc!.name;
92
+
93
+ // Every process of the playtest: the script, its `runs` and its `then` steps.
94
+ // The host's clock is pinned (PlaytestDesc.clock), so og.platform.now is
95
+ // reproducible; `store: "keep"` shares one local store file between them,
96
+ // 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
+ const store = desc!.store === "keep" ? join(out, ".store", "platform.json") : undefined;
99
+ if (store) mkdirSync(dirname(store), { recursive: true });
100
+ const command = (commands: string, dir: string, clock: number) => {
101
+ const c = ["bun", join(here, "run.ts"), "--entry", entry!, "--commands", commands, "--out", dir, "--json", "--clock", String(clock)];
102
+ if (root) c.push("--root", root);
103
+ if (desc!.seed !== undefined) c.push("--seed", String(desc!.seed));
104
+ if (desc!.size) c.push("--size", desc!.size);
105
+ if (store) c.push("--platform-store", store);
106
+ return c;
107
+ };
108
+ type Summary = { tick: number; hash: string; entities: number; quit: boolean; tier: string; outputs: string[]; warnings: string[]; unchecked?: string[] };
109
+ const spawn = (c: string[], what: string): Summary | null => {
110
+ const r = Bun.spawnSync(c, { stdout: "pipe", stderr: "pipe" });
111
+ const stdout = new TextDecoder().decode(r.stdout);
112
+ const stderr = new TextDecoder().decode(r.stderr);
113
+ let summary: Summary | null = null;
114
+ try {
115
+ summary = JSON.parse(stdout.trim().split("\n").pop() ?? "");
116
+ } catch {
117
+ summary = null;
118
+ }
119
+ if (r.exitCode !== 0 || !summary) {
120
+ failures.push(`${what} failed (exit ${r.exitCode}): ${stderr.trim() || stdout.trim()}`);
121
+ return null;
122
+ }
123
+ return summary;
124
+ };
125
+ const clock = desc!.clock ?? PLAYTEST_CLOCK;
126
+ const summary = spawn(command(desc!.commands, out, clock), "og run");
127
+ if (!summary) finish();
128
+ const rendered = summary!.tier !== "none";
129
+ unchecked.push(...(summary!.unchecked ?? []));
130
+ result = { ...result, tick: summary!.tick, hash: summary!.hash, tier: summary!.tier, warnings: summary!.warnings };
131
+ // More runs of the same script (desc.runs), each in a fresh process into its
132
+ // own directory: only their end hashes are kept.
133
+ const hashes = [summary!.hash];
134
+ for (let i = 1; i < Math.max(1, Math.floor(desc!.runs ?? 1)); i++) {
135
+ const again = spawn(command(desc!.commands, join(out, `.run-${i + 1}`), clock), `run ${i + 1} of ${desc!.runs}`);
136
+ if (again) hashes.push(again.hash);
137
+ }
138
+ result = { ...result, hashes };
139
+
140
+ const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): PlaytestRun => {
141
+ const path = (f: string) => {
142
+ const p = resolve(dir, f);
143
+ if (!existsSync(p)) throw new Error(`the run wrote no ${f} (it wrote ${s.outputs.map((o) => o.slice(dir.length + 1)).join(", ") || "nothing"})`);
144
+ return p;
145
+ };
146
+ const missed = new Set(s.unchecked ?? []);
147
+ return {
148
+ tick: s.tick,
149
+ hash: s.hash,
150
+ hashes: dir === out ? hashes : [s.hash],
151
+ entities: s.entities,
152
+ quit: s.quit,
153
+ tier: s.tier,
154
+ rendered: s.tier !== "none",
155
+ unchecked: [...missed],
156
+ files: s.outputs.map((o) => o.slice(dir.length + 1)),
157
+ warnings: s.warnings,
158
+ then: later,
159
+ json: <T>(f: string) => JSON.parse(readFileSync(path(f), "utf8")) as T,
160
+ text: (f: string) => readFileSync(path(f), "utf8"),
161
+ png: (f: string) => {
162
+ if (missed.has(f)) throw new FrameUnchecked(f);
163
+ const bytes = readFileSync(path(f));
164
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
165
+ if (bytes.length < 24 || view.getUint32(12) !== 0x49484452) throw new Error(`${f} is not a PNG`);
166
+ return { width: view.getUint32(16), height: view.getUint32(20), bytes: bytes.length };
167
+ },
168
+ wav: (f: string) => {
169
+ const bytes = readFileSync(path(f));
170
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
171
+ if (bytes.length < 44 || view.getUint32(0) !== 0x52494646 || view.getUint32(8) !== 0x57415645) throw new Error(`${f} is not a WAV`);
172
+ const rate = view.getUint32(24, true);
173
+ const channels = view.getUint16(22, true);
174
+ const samples = (bytes.length - 44) >> 1;
175
+ let peak = 0;
176
+ let sum = 0;
177
+ for (let i = 0; i < samples; i++) {
178
+ const x = view.getInt16(44 + i * 2, true) / 32767;
179
+ peak = Math.max(peak, Math.abs(x));
180
+ sum += x * x;
181
+ }
182
+ return { rate, frames: samples / Math.max(1, channels), peak, rms: samples > 0 ? Math.sqrt(sum / samples) : 0 };
183
+ },
184
+ };
185
+ };
186
+ // The later processes (desc.then), in order, after the script and its runs.
187
+ const later: PlaytestRun[] = [];
188
+ for (const [i, step] of (desc!.then ?? []).entries()) {
189
+ const dir = join(out, `then-${i + 1}`);
190
+ mkdirSync(dir, { recursive: true });
191
+ const s = spawn(command(step.commands, dir, step.clock ?? clock), `then[${i}]`);
192
+ if (!s) finish();
193
+ for (const f of s!.unchecked ?? []) unchecked.push(`then[${i}] ${f}`);
194
+ later.push(runOf(s!, dir, []));
195
+ }
196
+ const run = runOf(summary!, out, later);
197
+ // Every file a run took a screenshot for without a renderer, by name and by path.
198
+ 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
+ const missedNames = new Set<string>([...(summary!.unchecked ?? []), ...later.flatMap((r) => r.unchecked)]);
200
+ let inFrame = false;
201
+ const t: PlaytestChecks = {
202
+ ok(condition, what) {
203
+ checks++;
204
+ if (!condition) failures.push(what);
205
+ },
206
+ equal(actual, expected, what) {
207
+ checks++;
208
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) failures.push(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
209
+ },
210
+ near(actual, expected, tolerance, what) {
211
+ checks++;
212
+ if (!(Math.abs(actual - expected) <= tolerance)) failures.push(`${what}: expected ${expected} ± ${tolerance}, got ${actual}`);
213
+ },
214
+ frame(files, check) {
215
+ const names = typeof files === "string" ? [files] : [...files];
216
+ if (!rendered) {
217
+ // The frames are listed once each, from the runs' own notes; a name no
218
+ // screenshot took is listed too, so nothing is skipped silently.
219
+ for (const f of names) if (!missedNames.has(f) && !unchecked.includes(f)) unchecked.push(f);
220
+ return;
221
+ }
222
+ if (inFrame) return check();
223
+ const before = checks;
224
+ inFrame = true;
225
+ try {
226
+ check();
227
+ } finally {
228
+ inFrame = false;
229
+ frameChecks += checks - before;
230
+ }
231
+ },
232
+ };
233
+ /** The file a frame read without a renderer was after, if `e` is one: a FrameUnchecked, or a missing screenshot read from disk. */
234
+ function missedFrame(e: unknown): string | null {
235
+ if (rendered) return null;
236
+ // By name too: a playtest's bundle carries its own copy of the class.
237
+ if (e instanceof FrameUnchecked || (e instanceof Error && e.name === "FrameUnchecked")) return (e as FrameUnchecked).file;
238
+ const err = e as { code?: string; path?: string } | null;
239
+ if (err && err.code === "ENOENT" && typeof err.path === "string" && missedPaths.has(resolve(err.path))) return err.path.slice(out.length + 1);
240
+ return null;
241
+ }
242
+ try {
243
+ await desc!.check(run, t);
244
+ } catch (e) {
245
+ const frame = missedFrame(e);
246
+ 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)`);
247
+ else failures.push(`check threw: ${e instanceof Error ? e.message : String(e)}`);
248
+ }
249
+ finish();
@@ -0,0 +1,130 @@
1
+ // PNG frames: the probe's captures decoded (8-bit RGB or RGBA,
2
+ // non-interlaced, any filter) and RGBA images encoded (`og polish` writes
3
+ // its report's frames at half size).
4
+ import { deflateSync, inflateSync } from "node:zlib";
5
+
6
+ export interface Image {
7
+ width: number;
8
+ height: number;
9
+ /** RGBA8 rows. */
10
+ rgba: Uint8Array;
11
+ }
12
+
13
+ /** An 8-bit RGB or RGBA, non-interlaced PNG, unfiltered. */
14
+ export function decodePng(bytes: Uint8Array): Image {
15
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
16
+ let at = 8;
17
+ let width = 0;
18
+ let height = 0;
19
+ let channels = 4;
20
+ const idat: Uint8Array[] = [];
21
+ while (at < bytes.length) {
22
+ const len = view.getUint32(at);
23
+ const type = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
24
+ const data = bytes.subarray(at + 8, at + 8 + len);
25
+ if (type === "IHDR") {
26
+ width = view.getUint32(at + 8);
27
+ height = view.getUint32(at + 12);
28
+ const depth = data[8]!;
29
+ const color = data[9]!;
30
+ if (depth !== 8 || (color !== 6 && color !== 2) || data[12] !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour ${color}, interlace ${data[12]})`);
31
+ channels = color === 6 ? 4 : 3;
32
+ } else if (type === "IDAT") idat.push(data);
33
+ else if (type === "IEND") break;
34
+ at += 12 + len;
35
+ }
36
+ const joined = new Uint8Array(idat.reduce((n, d) => n + d.length, 0));
37
+ idat.reduce((at, d) => (joined.set(d, at), at + d.length), 0);
38
+ const raw = inflateSync(joined);
39
+ const stride = width * channels;
40
+ const out = new Uint8Array(width * height * 4);
41
+ const prev = new Uint8Array(stride);
42
+ const cur = new Uint8Array(stride);
43
+ for (let y = 0; y < height; y++) {
44
+ const filter = raw[y * (stride + 1)]!;
45
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
46
+ for (let i = 0; i < stride; i++) {
47
+ const a = i >= channels ? cur[i - channels]! : 0;
48
+ const b = prev[i]!;
49
+ const c = i >= channels ? prev[i - channels]! : 0;
50
+ let v = line[i]!;
51
+ if (filter === 1) v += a;
52
+ else if (filter === 2) v += b;
53
+ else if (filter === 3) v += (a + b) >> 1;
54
+ else if (filter === 4) {
55
+ const p = a + b - c;
56
+ const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
57
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
58
+ }
59
+ cur[i] = v & 255;
60
+ }
61
+ for (let x = 0; x < width; x++) {
62
+ for (let k = 0; k < 4; k++) out[(y * width + x) * 4 + k] = k < channels ? cur[x * channels + k]! : 255;
63
+ }
64
+ prev.set(cur);
65
+ }
66
+ return { width, height, rgba: out };
67
+ }
68
+
69
+ /** Half the size, each pixel the mean of four. */
70
+ export function half(img: Image): Image {
71
+ const width = Math.max(1, img.width >> 1);
72
+ const height = Math.max(1, img.height >> 1);
73
+ const rgba = new Uint8Array(width * height * 4);
74
+ for (let y = 0; y < height; y++) {
75
+ for (let x = 0; x < width; x++) {
76
+ for (let k = 0; k < 4; k++) {
77
+ const at = (sx: number, sy: number) => img.rgba[(Math.min(sy, img.height - 1) * img.width + Math.min(sx, img.width - 1)) * 4 + k]!;
78
+ rgba[(y * width + x) * 4 + k] = (at(2 * x, 2 * y) + at(2 * x + 1, 2 * y) + at(2 * x, 2 * y + 1) + at(2 * x + 1, 2 * y + 1) + 2) >> 2;
79
+ }
80
+ }
81
+ }
82
+ return { width, height, rgba };
83
+ }
84
+
85
+ const CRC = (() => {
86
+ const t = new Uint32Array(256);
87
+ for (let n = 0; n < 256; n++) {
88
+ let c = n;
89
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
90
+ t[n] = c >>> 0;
91
+ }
92
+ return t;
93
+ })();
94
+
95
+ function chunk(type: string, data: Uint8Array): Uint8Array {
96
+ const out = new Uint8Array(12 + data.length);
97
+ const view = new DataView(out.buffer);
98
+ view.setUint32(0, data.length);
99
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
100
+ out.set(data, 8);
101
+ let c = 0xffffffff;
102
+ for (let i = 4; i < 8 + data.length; i++) c = CRC[(c ^ out[i]!) & 255]! ^ (c >>> 8);
103
+ view.setUint32(8 + data.length, (c ^ 0xffffffff) >>> 0);
104
+ return out;
105
+ }
106
+
107
+ /** An RGB PNG of the image (alpha dropped), rows filtered with Sub. */
108
+ export function encodePng(img: Image): Uint8Array {
109
+ const stride = img.width * 3;
110
+ const raw = new Uint8Array((stride + 1) * img.height);
111
+ for (let y = 0; y < img.height; y++) {
112
+ raw[y * (stride + 1)] = 1;
113
+ for (let x = 0; x < img.width; x++) {
114
+ for (let k = 0; k < 3; k++) {
115
+ const v = img.rgba[(y * img.width + x) * 4 + k]!;
116
+ const left = x > 0 ? img.rgba[(y * img.width + x - 1) * 4 + k]! : 0;
117
+ raw[y * (stride + 1) + 1 + x * 3 + k] = (v - left) & 255;
118
+ }
119
+ }
120
+ }
121
+ const ihdr = new Uint8Array(13);
122
+ const v = new DataView(ihdr.buffer);
123
+ v.setUint32(0, img.width);
124
+ v.setUint32(4, img.height);
125
+ ihdr.set([8, 2, 0, 0, 0], 8);
126
+ const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", new Uint8Array(0))];
127
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
128
+ parts.reduce((at, p) => (out.set(p, at), at + p.length), 0);
129
+ return out;
130
+ }