@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
package/src/p2.ts ADDED
@@ -0,0 +1,35 @@
1
+ // The phase 2 modules' namespaces (IDL `terrain`): thin calls into the host's binding symbols,
2
+ // typed by the generated declarations through index.ts.
3
+
4
+ import type { HostAbi } from "./generated/abi.ts";
5
+ import { boundHost } from "./runtime.ts";
6
+
7
+ type W = { readonly id: number };
8
+
9
+ function abi(): HostAbi<Uint32Array, unknown> {
10
+ const h = boundHost();
11
+ if (!h) throw new Error("no engine host is loaded: start the game through @origingame/engine's web or node loader");
12
+ return h.abi;
13
+ }
14
+
15
+ const parse = <T>(text: string): T => JSON.parse(text) as T;
16
+ const json = (value: unknown): string | undefined => (value === undefined ? undefined : JSON.stringify(value));
17
+
18
+ export const terrain = Object.freeze({
19
+ load: (world: W, path: string) => parse(abi().og_terrain_load(world.id, path)),
20
+ sample: (world: W, desc?: unknown) => parse(abi().og_terrain_sample(world.id, json(desc))),
21
+ bounds: (world: W, data: number, origin?: unknown, quaternion?: unknown, scale?: unknown) =>
22
+ parse(abi().og_terrain_bounds(world.id, data, json(origin), json(quaternion), json(scale))),
23
+ edit: (world: W, data: number, brush?: unknown) => parse(abi().og_terrain_edit(world.id, data, json(brush))),
24
+ layer: (world: W, data: number, index: number) => parse(abi().og_terrain_layer(world.id, data, index)),
25
+ setLayer: (world: W, data: number, index: number, layer?: unknown) =>
26
+ parse(abi().og_terrain_set_layer(world.id, data, index, json(layer))),
27
+ });
28
+
29
+ export const stream = Object.freeze({
30
+ status: (world: W, group: string) => parse(abi().og_stream_status(world.id, group)),
31
+ request: (world: W, group: string) => parse(abi().og_stream_request(world.id, group)),
32
+ release: (world: W, group: string) => parse(abi().og_stream_release(world.id, group)),
33
+ budget: (world: W) => parse(abi().og_stream_budget(world.id)),
34
+ setBudget: (world: W, bytes: number) => parse(abi().og_stream_set_budget(world.id, bytes)),
35
+ });
@@ -0,0 +1,211 @@
1
+ // The local platform: og.platform's calls answered from a JSON document the
2
+ // host keeps (the native host's file, the browser's own storage). It is the
3
+ // native host's platform and the web shell's fallback when no Player
4
+ // answers. Store keys carry revisions and refuse a stale write with
5
+ // `conflict`; leaderboards keep each name's best and rank in the board's
6
+ // order; Origin Point and AI marks are recorded (a local balance, a list),
7
+ // never sent anywhere; telemetry is kept as the last 200 events.
8
+
9
+ /** One platform call, as og_platform_take lists it. */
10
+ export interface PlatformCall {
11
+ readonly request: number;
12
+ readonly method: string;
13
+ readonly args: Record<string, unknown>;
14
+ readonly tick: number;
15
+ }
16
+
17
+ /** An answer, as og_platform_answer takes it. */
18
+ export type PlatformAnswer = (
19
+ | { readonly ok: true; readonly data: unknown }
20
+ | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly retryable: boolean; readonly revision?: number } }
21
+ ) & {
22
+ /** The answering host's clock, UTC ms (a Player that sends its time); the engine's host clock fills it in otherwise. */
23
+ readonly time?: number;
24
+ };
25
+
26
+ /** What answers an engine's platform calls. */
27
+ export interface PlatformHost {
28
+ /** `player`, `local` or `none` (og.platform.info). */
29
+ readonly host: string;
30
+ readonly capabilities: readonly string[];
31
+ /** Handle calls in order; answer every one but `telemetry`, now or later. */
32
+ handle(calls: readonly PlatformCall[], answer: (request: number, result: PlatformAnswer) => void): void;
33
+ }
34
+
35
+ /** Every method the local platform answers. */
36
+ export const LOCAL_CAPABILITIES = ["store.get", "store.set", "store.list", "leaderboard.submit", "leaderboard.top", "leaderboard.around", "telemetry", "point", "ai.mark"] as const;
37
+
38
+ export interface LocalDocument {
39
+ version: 1;
40
+ player: string;
41
+ store: Record<string, { value: unknown; revision: number }>;
42
+ boards: Record<string, { order: "asc" | "desc"; entries: Array<{ name: string; score: number }> }>;
43
+ points: { balance: number; grants: Array<{ reason: string; amount: number; tick: number }> };
44
+ marks: Array<{ kind: string; label: string; model?: string; tick: number }>;
45
+ events: Array<{ name: string; attrs: Record<string, unknown>; tick: number }>;
46
+ }
47
+
48
+ /** Where the document lives: a file natively, localStorage in a browser, memory in tests. */
49
+ export interface LocalPersistence {
50
+ load(): string | null;
51
+ save(text: string): void;
52
+ }
53
+
54
+ export function emptyDocument(player = "You"): LocalDocument {
55
+ return { version: 1, player, store: {}, boards: {}, points: { balance: 0, grants: [] }, marks: [], events: [] };
56
+ }
57
+
58
+ function readDocument(text: string | null): LocalDocument {
59
+ if (!text) return emptyDocument();
60
+ try {
61
+ const d = JSON.parse(text) as Partial<LocalDocument>;
62
+ const e = emptyDocument(typeof d.player === "string" ? d.player : "You");
63
+ return { ...e, ...d, points: { ...e.points, ...(d.points ?? {}) }, version: 1 } as LocalDocument;
64
+ } catch {
65
+ return emptyDocument();
66
+ }
67
+ }
68
+
69
+ export const fail = (code: string, message: string): PlatformAnswer => ({
70
+ ok: false,
71
+ error: { code, message, retryable: ["offline", "timeout", "unavailable", "rate_limited"].includes(code) },
72
+ });
73
+
74
+ const okData = (data: unknown): PlatformAnswer => ({ ok: true, data });
75
+
76
+ /** A refused conditional write: `conflict`, with the revision the key is at now. */
77
+ export const conflict = (message: string, revision: number): PlatformAnswer => ({ ok: false, error: { code: "conflict", message, retryable: false, revision } });
78
+
79
+ /** Ranked entries of a board, best first. */
80
+ export function ranked(order: "asc" | "desc", entries: ReadonlyArray<{ name: string; score: number }>, me: string) {
81
+ const sorted = [...entries].sort((a, b) => (order === "asc" ? a.score - b.score : b.score - a.score) || (a.name < b.name ? -1 : 1));
82
+ return sorted.map((e, i) => ({ rank: i + 1, name: e.name, score: e.score, me: e.name === me }));
83
+ }
84
+
85
+ /** Answers one call against the document (mutating it). */
86
+ export function answerLocally(doc: LocalDocument, call: PlatformCall): PlatformAnswer | null {
87
+ const a = call.args;
88
+ switch (call.method) {
89
+ case "store.get": {
90
+ const key = String(a["key"]);
91
+ const slot = doc.store[key];
92
+ return okData({ key, value: slot?.value ?? null, revision: slot?.revision ?? 0 });
93
+ }
94
+ case "store.set": {
95
+ const key = String(a["key"]);
96
+ const want = Number(a["revision"] ?? 0);
97
+ const slot = doc.store[key];
98
+ const have = slot?.revision ?? 0;
99
+ // `create`: only a key never written (the Player's revision 0).
100
+ if (a["create"] === true ? have !== 0 : want > 0 && want !== have) return conflict(`'${key}' is at revision ${have}${a["create"] === true ? ", not new" : `, not ${want}`}: read it again and merge`, have);
101
+ doc.store[key] = { value: a["value"] ?? null, revision: have + 1 };
102
+ return okData({ key, revision: have + 1 });
103
+ }
104
+ case "store.list":
105
+ return okData({ keys: Object.keys(doc.store).sort().map((key) => ({ key, revision: doc.store[key]!.revision })) });
106
+ case "leaderboard.submit": {
107
+ const name = String(a["board"] ?? "main");
108
+ const board = (doc.boards[name] ??= { order: a["order"] === "asc" ? "asc" : "desc", entries: [] });
109
+ const score = Number(a["score"]);
110
+ const mine = board.entries.find((e) => e.name === doc.player);
111
+ const better = (x: number, y: number) => (board.order === "asc" ? x < y : x > y);
112
+ if (!mine) board.entries.push({ name: doc.player, score });
113
+ else if (better(score, mine.score)) mine.score = score;
114
+ const me = ranked(board.order, board.entries, doc.player).find((e) => e.me)!;
115
+ return okData({ board: name, rank: me.rank, best: me.score });
116
+ }
117
+ case "leaderboard.top":
118
+ case "leaderboard.around": {
119
+ const name = String(a["board"] ?? "main");
120
+ const board = doc.boards[name];
121
+ const count = Math.max(1, Math.min(100, Number(a["count"] ?? 10)));
122
+ const all = board ? ranked(board.order, board.entries, doc.player) : [];
123
+ const me = all.find((e) => e.me) ?? null;
124
+ let entries = all.slice(0, count);
125
+ if (call.method === "leaderboard.around" && me) {
126
+ const start = Math.max(0, Math.min(all.length - count, me.rank - 1 - Math.floor(count / 2)));
127
+ entries = all.slice(start, start + count);
128
+ }
129
+ return okData({ board: name, order: board?.order ?? "desc", entries, me });
130
+ }
131
+ case "telemetry":
132
+ doc.events.push({ name: String(a["name"]), attrs: (a["attrs"] as Record<string, unknown>) ?? {}, tick: call.tick });
133
+ if (doc.events.length > 200) doc.events.splice(0, doc.events.length - 200);
134
+ return null;
135
+ case "point": {
136
+ const amount = Math.max(1, Math.min(1000, Number(a["amount"] ?? 1)));
137
+ doc.points.balance += amount;
138
+ doc.points.grants.push({ reason: String(a["reason"]), amount, tick: call.tick });
139
+ return okData({ granted: amount, balance: doc.points.balance });
140
+ }
141
+ case "ai.mark":
142
+ doc.marks.push({ kind: String(a["kind"]), label: String(a["label"]), ...(typeof a["model"] === "string" ? { model: a["model"] } : {}), tick: call.tick });
143
+ return okData({ marked: true });
144
+ default:
145
+ return fail("invalid", `unknown platform method '${call.method}'`);
146
+ }
147
+ }
148
+
149
+ /** The local platform over a persistence: every call answered at once, the document saved after each batch. */
150
+ export function localPlatform(persistence: LocalPersistence = memoryPersistence()): PlatformHost & { document(): LocalDocument } {
151
+ let doc = readDocument(persistence.load());
152
+ return {
153
+ host: "local",
154
+ capabilities: LOCAL_CAPABILITIES,
155
+ document: () => doc,
156
+ handle(calls, answer) {
157
+ doc = readDocument(persistence.load());
158
+ const answers: Array<[number, PlatformAnswer]> = [];
159
+ for (const c of calls) {
160
+ const r = answerLocally(doc, c);
161
+ if (r) answers.push([c.request, r]);
162
+ }
163
+ persistence.save(JSON.stringify(doc, null, 2));
164
+ for (const [request, r] of answers) answer(request, r);
165
+ },
166
+ };
167
+ }
168
+
169
+ /** A document kept in memory only. */
170
+ export function memoryPersistence(initial: LocalDocument | null = null): LocalPersistence {
171
+ let text: string | null = initial ? JSON.stringify(initial) : null;
172
+ return { load: () => text, save: (t) => void (text = t) };
173
+ }
174
+
175
+ /** No platform at all: every call is `unavailable`. */
176
+ export const noPlatform: PlatformHost = {
177
+ host: "none",
178
+ capabilities: [],
179
+ handle(calls, answer) {
180
+ for (const c of calls) if (c.method !== "telemetry") answer(c.request, fail("unavailable", "no platform host is attached to this engine (the shell attaches the Player or local storage; og run a local store)"));
181
+ },
182
+ };
183
+
184
+ /** The document in the browser's localStorage under `key` (memory only where storage is refused). */
185
+ export function storagePersistence(key: string): LocalPersistence {
186
+ const store = (() => {
187
+ try {
188
+ return (globalThis as { localStorage?: { getItem(k: string): string | null; setItem(k: string, v: string): void } }).localStorage ?? null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ })();
193
+ let memory: string | null = null;
194
+ return {
195
+ load: () => {
196
+ try {
197
+ return store ? store.getItem(key) : memory;
198
+ } catch {
199
+ return memory;
200
+ }
201
+ },
202
+ save: (text) => {
203
+ memory = text;
204
+ try {
205
+ store?.setItem(key, text);
206
+ } catch {
207
+ /* storage full or refused: memory keeps it for this page */
208
+ }
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,131 @@
1
+ // Playtest scripts (`tests/*.og.ts`): a command script run headless through
2
+ // `og run --commands`, then checks over what the run wrote. `og test` runs
3
+ // every one in its own process and fails a playtest that made no check.
4
+ //
5
+ // Without a renderer (the platform's engine and probe sandboxes carry none;
6
+ // `OG_RENDERER=none` makes any machine so) the run is the same run, but a
7
+ // `screenshot` writes no PNG: the checks that read a frame go inside
8
+ // `t.frame(files, () => ...)`, which runs them with a renderer and otherwise
9
+ // reports each file "no renderer, frame unchecked" and passes. Frames are
10
+ // verified where there is a renderer: the author's machine, the
11
+ // browser-check sandbox, an executor with a GPU.
12
+ //
13
+ // import { playtest } from "@origingame/engine/playtest";
14
+ // export default playtest({
15
+ // name: "the trickster spins after S",
16
+ // commands: "0.5:type:s;2.0:screenshot:spin.png;2.2:dump_entity:trickster:t.json;2.5:quit",
17
+ // check(run, t) {
18
+ // t.ok(run.quit, "the script reached quit");
19
+ // t.ok(run.json<{ components: Record<string, { speed: number }> }>("t.json").components.Spin!.speed > 0, "Spin started");
20
+ // t.frame("spin.png", () => t.equal(run.png("spin.png").width, 1280, "the frame is 1280 wide"));
21
+ // },
22
+ // });
23
+
24
+ /** What one headless run produced. */
25
+ export interface PlaytestRun {
26
+ /** The tick the run ended on. */
27
+ readonly tick: number;
28
+ /** The world hash at the end, hex. */
29
+ readonly hash: string;
30
+ /** The end hash of every run (`PlaytestDesc.runs`), the first being `hash`: equal hashes are a replay that matched. */
31
+ readonly hashes: readonly string[];
32
+ readonly entities: number;
33
+ /** Whether a `quit` step ran. */
34
+ readonly quit: boolean;
35
+ /** The renderer tier (`metal`, `vulkan`, `webgpu`, ..., `none` without a renderer). */
36
+ readonly tier: string;
37
+ /** Whether the run had a renderer: without one (`tier` `none`) a `screenshot` writes no PNG. */
38
+ readonly rendered: boolean;
39
+ /** The screenshots the run took without a renderer, by the file each would have written: frames unchecked. */
40
+ readonly unchecked: readonly string[];
41
+ /** Every file the commands wrote, by its `Output::file_name()` (`smoke.png`, `dump_world-000132.json`). */
42
+ readonly files: readonly string[];
43
+ /** Warnings the command channel and the probe logged, each naming the fix. */
44
+ readonly warnings: readonly string[];
45
+ /** A written JSON output, parsed. Throws when the run wrote no such file. */
46
+ json<T = unknown>(file: string): T;
47
+ /** A written PNG's size. Throws when the run wrote no such file (and, for a screenshot taken without a renderer, a FrameUnchecked: read frames inside `t.frame`). */
48
+ png(file: string): { width: number; height: number; bytes: number };
49
+ /** A written text output. */
50
+ text(file: string): string;
51
+ /** A written WAV (`listen`): its rate, frames, peak and RMS (16-bit PCM read back, both channels). */
52
+ wav(file: string): { rate: number; frames: number; peak: number; rms: number };
53
+ /** The runs of PlaytestDesc.then, in order, each with its own files (empty without `then`). */
54
+ readonly then: readonly PlaytestRun[];
55
+ }
56
+
57
+ /** The checks a playtest makes; a playtest that made none fails. */
58
+ export interface PlaytestChecks {
59
+ ok(condition: unknown, what: string): void;
60
+ equal<T>(actual: T, expected: T, what: string): void;
61
+ near(actual: number, expected: number, tolerance: number, what: string): void;
62
+ /**
63
+ * The checks that read frames (`run.png`, a decoded screenshot, a comparison with
64
+ * references/): `check` runs when the playtest had a renderer. Without one it does not run,
65
+ * each of `files` is reported "no renderer, frame unchecked", and the playtest passes on its
66
+ * other checks. Every check outside `t.frame` runs either way.
67
+ */
68
+ frame(files: string | readonly string[], check: () => void): void;
69
+ }
70
+
71
+ /**
72
+ * What reading a screenshot the run took without a renderer throws. A check function stopped by
73
+ * one passes with its frame unchecked (the checks after it did not run, and the report says so):
74
+ * put frame checks inside `t.frame` so every other check runs.
75
+ */
76
+ export class FrameUnchecked extends Error {
77
+ readonly file: string;
78
+ constructor(file: string) {
79
+ super(`${file}: no renderer, frame unchecked`);
80
+ this.name = "FrameUnchecked";
81
+ this.file = file;
82
+ }
83
+ }
84
+
85
+ export interface PlaytestDesc {
86
+ /** One line on what the playtest proves. */
87
+ readonly name: string;
88
+ /** `t:action[:args];...`, as `og run --commands`. End it with `quit`. */
89
+ readonly commands: string;
90
+ /** The replay seed; omitted is GameDesc.seed. */
91
+ readonly seed?: number;
92
+ /** The render target, `WIDTHxHEIGHT` (default 1280x720). */
93
+ readonly size?: string;
94
+ /** Run the script this many times in fresh processes (default 1) and report every end hash in `run.hashes`: a determinism check. The files are the first run's. */
95
+ readonly runs?: number;
96
+ /**
97
+ * The host's clock at tick 0, UTC milliseconds (`og run --clock`): og.platform.now and the
98
+ * answers' times start there and move with the ticks, so a playtest is reproducible.
99
+ * Omitted is 1767225600000 (2026-01-01T00:00:00Z).
100
+ */
101
+ readonly clock?: number;
102
+ /**
103
+ * `keep`: every process of this playtest (the script, its `runs` and its `then` steps)
104
+ * answers og.platform from one local store, `<out>/.store/platform.json` (a dot name, which no step's output may take), empty when the playtest
105
+ * starts, so a later process sees what an earlier one saved (a relaunch). Omitted: each
106
+ * process has its own store in memory.
107
+ */
108
+ readonly store?: "keep";
109
+ /** More processes after the script, in order, each a fresh `og run` (a relaunch): their runs are `run.then`. */
110
+ readonly then?: readonly PlaytestStep[];
111
+ readonly check: (run: PlaytestRun, t: PlaytestChecks) => void | Promise<void>;
112
+ }
113
+
114
+ /** A later process of a playtest (`PlaytestDesc.then`). */
115
+ export interface PlaytestStep {
116
+ /** Its own script, as PlaytestDesc.commands. */
117
+ readonly commands: string;
118
+ /** Its clock at its tick 0, UTC ms; omitted is the playtest's clock. */
119
+ readonly clock?: number;
120
+ }
121
+
122
+ /** The clock a playtest's processes start at when it names none: 2026-01-01T00:00:00Z. */
123
+ export const PLAYTEST_CLOCK = 1767225600000;
124
+
125
+ /** Declare a playtest: the default export of a `tests/*.og.ts` file. */
126
+ export function playtest(desc: PlaytestDesc): PlaytestDesc {
127
+ if (!desc.name || !desc.commands || typeof desc.check !== "function") {
128
+ throw new Error("playtest({ name, commands, check }) needs all three");
129
+ }
130
+ return Object.freeze({ ...desc });
131
+ }