@origingame/engine 0.0.0-stage → 0.1.1

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 (159) 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/gitignore +8 -0
  42. package/knowledge/scaffold/template/package.json +18 -0
  43. package/knowledge/scaffold/template/project.ogt +25 -0
  44. package/knowledge/scaffold/template/src/main.ts +246 -0
  45. package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
  46. package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
  47. package/knowledge/scaffold/template/tsconfig.json +17 -0
  48. package/knowledge/scripts/lib.ts +207 -0
  49. package/knowledge/scripts/mount.ts +210 -0
  50. package/knowledge/scripts/pack.ts +25 -0
  51. package/knowledge/scripts/scaffold.ts +72 -0
  52. package/knowledge/scripts/validate.ts +749 -0
  53. package/knowledge/skills/og-ai/SKILL.md +386 -0
  54. package/knowledge/skills/og-audio/SKILL.md +191 -0
  55. package/knowledge/skills/og-build/SKILL.md +330 -0
  56. package/knowledge/skills/og-core/SKILL.md +285 -0
  57. package/knowledge/skills/og-feel/SKILL.md +385 -0
  58. package/knowledge/skills/og-game/SKILL.md +1479 -0
  59. package/knowledge/skills/og-i18n/SKILL.md +220 -0
  60. package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
  61. package/knowledge/skills/og-taste/SKILL.md +537 -0
  62. package/knowledge/skills/og-ui/SKILL.md +444 -0
  63. package/knowledge/wiki/ai.md +627 -0
  64. package/knowledge/wiki/anim.md +1187 -0
  65. package/knowledge/wiki/atmosphere.md +636 -0
  66. package/knowledge/wiki/audio.md +732 -0
  67. package/knowledge/wiki/camera.md +711 -0
  68. package/knowledge/wiki/commands.md +357 -0
  69. package/knowledge/wiki/game.md +220 -0
  70. package/knowledge/wiki/i18n.md +213 -0
  71. package/knowledge/wiki/index.md +1027 -0
  72. package/knowledge/wiki/input.md +1714 -0
  73. package/knowledge/wiki/load.md +1280 -0
  74. package/knowledge/wiki/math.md +166 -0
  75. package/knowledge/wiki/nav.md +1154 -0
  76. package/knowledge/wiki/physics.md +3142 -0
  77. package/knowledge/wiki/platform.md +857 -0
  78. package/knowledge/wiki/probe.md +1692 -0
  79. package/knowledge/wiki/reflect.md +285 -0
  80. package/knowledge/wiki/render.md +3511 -0
  81. package/knowledge/wiki/rng.md +393 -0
  82. package/knowledge/wiki/stream.md +376 -0
  83. package/knowledge/wiki/system.md +197 -0
  84. package/knowledge/wiki/systems.md +43 -0
  85. package/knowledge/wiki/terrain.md +727 -0
  86. package/knowledge/wiki/tilemap.md +422 -0
  87. package/knowledge/wiki/time.md +150 -0
  88. package/knowledge/wiki/transform.md +230 -0
  89. package/knowledge/wiki/tween.md +580 -0
  90. package/knowledge/wiki/ui.md +8000 -0
  91. package/knowledge/wiki/world.md +909 -0
  92. package/package.json +68 -4
  93. package/schema/api.schema.json +29675 -0
  94. package/shell/README.md +22 -0
  95. package/shell/brand/README.md +23 -0
  96. package/shell/brand/icon-192.png +0 -0
  97. package/shell/brand/icon-512.png +0 -0
  98. package/shell/brand/mark-on-paper.svg +6 -0
  99. package/shell/brand/origin-mark.svg +5 -0
  100. package/shell/brand/origin-motion.css +251 -0
  101. package/shell/brand/tokens.css +20 -0
  102. package/shell/brand.ts +132 -0
  103. package/shell/page.ts +715 -0
  104. package/shell/platform.ts +203 -0
  105. package/shell/player.ts +238 -0
  106. package/shell/shell.css +185 -0
  107. package/shell/sw.ts +177 -0
  108. package/shell/transport.ts +515 -0
  109. package/shell/wire.ts +369 -0
  110. package/src/e6.ts +69 -0
  111. package/src/env.d.ts +100 -0
  112. package/src/generated/abi.ts +980 -0
  113. package/src/index.ts +193 -0
  114. package/src/node/audio.ts +86 -0
  115. package/src/node/build.ts +109 -0
  116. package/src/node/device.ts +981 -0
  117. package/src/node/doctor.ts +60 -0
  118. package/src/node/index.ts +102 -0
  119. package/src/node/platform.ts +24 -0
  120. package/src/node/playtest-run.ts +249 -0
  121. package/src/node/png.ts +130 -0
  122. package/src/node/polish.ts +976 -0
  123. package/src/node/run.ts +373 -0
  124. package/src/node/web-build.ts +808 -0
  125. package/src/p2.ts +35 -0
  126. package/src/platform-local.ts +211 -0
  127. package/src/playtest.ts +131 -0
  128. package/src/runtime.ts +1509 -0
  129. package/src/systems/cameras.ts +247 -0
  130. package/src/ui-kit/bind.ts +141 -0
  131. package/src/ui-kit/huds.ts +303 -0
  132. package/src/ui-kit/index.ts +36 -0
  133. package/src/ui-kit/menus.ts +187 -0
  134. package/src/ui-kit/settings.ts +131 -0
  135. package/src/ui-kit/store.ts +222 -0
  136. package/src/ui-kit/story.ts +89 -0
  137. package/src/ui-kit/util.ts +106 -0
  138. package/src/web/a11y.ts +429 -0
  139. package/src/web/audio-worklet.ts +193 -0
  140. package/src/web/audio.ts +307 -0
  141. package/src/web/basis.ts +239 -0
  142. package/src/web/gamepad.ts +123 -0
  143. package/src/web/host.ts +166 -0
  144. package/src/web/input.ts +237 -0
  145. package/src/web/lanes.ts +21 -0
  146. package/src/web/play.ts +20 -0
  147. package/src/web/rpc-ring.ts +110 -0
  148. package/src/web/settle.ts +122 -0
  149. package/src/web/shell.ts +643 -0
  150. package/src/web/text-input.ts +248 -0
  151. package/src/web/worker.ts +1002 -0
  152. package/types/bindings.json +13654 -0
  153. package/types/index.d.ts +20090 -0
  154. package/vendor/basis/LICENSE +201 -0
  155. package/vendor/basis/NOTICE +15 -0
  156. package/vendor/basis/README.md +18 -0
  157. package/vendor/basis/basis_transcoder.js +19 -0
  158. package/vendor/basis/basis_transcoder.wasm +0 -0
  159. package/README.md +0 -3
@@ -0,0 +1,808 @@
1
+ // `og build`'s web half: the exported game, a self-contained directory any
2
+ // static host serves (docs/web-export.md).
3
+ //
4
+ // bun src/node/web-build.ts --project <dir> --entry <src/main.ts> --out <dist>
5
+ // [--publish] [--json] [--settings <json>]
6
+ //
7
+ // `--settings` is project.ogt as og read and checked it (name, id, engine,
8
+ // profile and the `publish` block); without it the defaults apply. Writes
9
+ // into <dist>:
10
+ //
11
+ // index.html the player shell (shell/page.ts): boot splash, gate, error card, badge;
12
+ // `<meta name="og-engine" content="<version>">`
13
+ // play.js the shell, bundled (minified with --publish)
14
+ // game.js the engine Worker: startWorker(game), the runtime, the game
15
+ // audio-worklet.js the AudioWorklet the shell loads on the first gesture
16
+ // origin-engine.js, origin-engine_bg.wasm the web host: dist/web (every member, both physics
17
+ // profiles), or with --publish the variant of the game's profile:
18
+ // dist/web-publish (3D) or dist/web-publish-2d (2D), each with only
19
+ // its profile's physics (docs/web-export.md §Variants)
20
+ // origin-engine-lanes.js, origin-engine-lanes_bg.wasm, snippets/
21
+ // (--publish, isolation not "off") the lanes variant of the same
22
+ // profile, from the same directory: wasm atomics, rayon on a pool of
23
+ // Workers when the page is cross-origin isolated
24
+ // basis/ Binomial's Basis transcoder and its licence
25
+ // cooked/ the project's cooked bundle: the first group (everything no streaming
26
+ // group holds, the proxies included) and each streaming group's pack,
27
+ // cooked/stream/<name>.ogb; a grouped output's own file is left out
28
+ // icons/ the project's icon, or the brand tile at 192 and 512 px
29
+ // og-build.json the build manifest (the Player's contract; shape in docs/web-export.md)
30
+ // _headers COOP/COEP/CORP and caching for static hosts (Netlify/Cloudflare Pages format)
31
+ // origingame.runtime.json the platform's isolation declaration, when the project wants isolation
32
+ // manifest.webmanifest, sw.js (--publish) the PWA manifest and the offline service worker
33
+ //
34
+ // The bytes depend only on the inputs: no dates, no absolute paths, files in
35
+ // sorted order. Prints a size table (bytes and brotli -q 11, or gzip -9
36
+ // without the brotli CLI), then what a player downloads (downloadsOf: each
37
+ // page's boot, the lanes and the single-threaded page, and what comes
38
+ // later); with --json one JSON line:
39
+ // { ok, out, publish, files: [{ path, bytes, compressed, codec }], total, downloads, probe: { build, members }, manifest }
40
+
41
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
42
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
43
+ import { fileURLToPath, pathToFileURL } from "node:url";
44
+ import { bundleGame, ENGINE_ROOT } from "./build.ts";
45
+ import { bootMark, engineMark, motionSubset, shellTokens, SHELL_MOTIONS, BRAND_DIR } from "../../shell/brand.ts";
46
+
47
+ declare module "node:fs" {
48
+ export function cpSync(from: string, to: string, options?: { recursive?: boolean }): void;
49
+ export function readdirSync(path: string): string[];
50
+ export function realpathSync(path: string): string;
51
+ }
52
+ declare module "node:path" {
53
+ export function relative(from: string, to: string): string;
54
+ export function isAbsolute(path: string): boolean;
55
+ export const sep: string;
56
+ }
57
+
58
+ declare const Bun: {
59
+ build(o: { entrypoints: string[]; target: string; format: string; minify?: boolean }): Promise<{ success: boolean; logs: unknown[]; outputs: Array<{ text(): Promise<string> }> }>;
60
+ spawnSync(cmd: string[], o?: { stdin?: Uint8Array; stdout?: string; stderr?: string }): { exitCode: number; stdout: Uint8Array; stderr: Uint8Array };
61
+ gzipSync(data: Uint8Array, o?: { level?: number }): Uint8Array;
62
+ which(cmd: string): string | null;
63
+ CryptoHasher: new (algorithm: string) => { update(data: Uint8Array | string): unknown; digest(encoding: "hex"): string };
64
+ version: string;
65
+ };
66
+
67
+ const here = dirname(fileURLToPath(import.meta.url));
68
+ const SHELL_DIR = join(ENGINE_ROOT, "shell");
69
+ export const ENGINE_VERSION = (JSON.parse(readFileSync(join(ENGINE_ROOT, "package.json"), "utf8")) as { version: string }).version;
70
+
71
+ /** The manifest's file name (the platform's Player reads it). */
72
+ export const BUILD_MANIFEST = "og-build.json";
73
+ /**
74
+ * The Player transports the shell speaks: `frame` (protocol 2 over postMessage to a
75
+ * framing Player) and `page` (protocol 2 over same-origin HTTP when the page is the
76
+ * top-level document). Written as `<meta name="og-player">` and og-build.json `player`.
77
+ */
78
+ export const PLAYER_TRANSPORTS = ["frame", "page"] as const;
79
+
80
+ export interface SizeRow {
81
+ readonly path: string;
82
+ readonly bytes: number;
83
+ readonly compressed: number;
84
+ readonly codec: "brotli-11" | "gzip-9";
85
+ }
86
+
87
+ /** project.ogt's `publish` block as og checked it (crates/og-cli/src/project.rs). */
88
+ export interface PublishSettings {
89
+ readonly probe: "player" | "all";
90
+ readonly isolation: "required" | "preferred" | "off";
91
+ readonly orientation: "any" | "landscape" | "portrait";
92
+ readonly fit: "letterbox" | "fill";
93
+ /** `"16:9"` and the like; absent: the screen's own. */
94
+ readonly aspect: string | null;
95
+ readonly dprCap: number;
96
+ readonly fullscreen: "gesture" | "off";
97
+ readonly gate: "tap" | "none";
98
+ readonly madeWith: boolean;
99
+ /** A PNG relative to the project, square, at least 192 px. */
100
+ readonly icon: string | null;
101
+ /** The installed app's short name; absent: the name. */
102
+ readonly shortName: string | null;
103
+ }
104
+
105
+ export interface ProjectSettings {
106
+ readonly name: string;
107
+ readonly id: string;
108
+ /** The engine version project.ogt pins. */
109
+ readonly engine: string;
110
+ readonly profile: "2d" | "3d";
111
+ readonly publish: PublishSettings;
112
+ }
113
+
114
+ export const DEFAULT_PUBLISH: PublishSettings = {
115
+ probe: "player",
116
+ isolation: "preferred",
117
+ orientation: "any",
118
+ fit: "fill",
119
+ aspect: null,
120
+ dprCap: 2,
121
+ fullscreen: "off",
122
+ gate: "tap",
123
+ madeWith: true,
124
+ icon: null,
125
+ shortName: null,
126
+ };
127
+
128
+ /** The settings of a project read without og (tests): name, id, engine and profile; publish defaults. */
129
+ export function settingsOf(project: string): ProjectSettings {
130
+ const text = existsSync(join(project, "project.ogt")) ? readFileSync(join(project, "project.ogt"), "utf8") : "";
131
+ const word = (key: string) => new RegExp(`^\\s*${key}\\s+"([^"]*)"`, "m").exec(text)?.[1];
132
+ return {
133
+ name: word("name") ?? "Origin Engine game",
134
+ id: word("id") ?? "game",
135
+ engine: word("engine") ?? ENGINE_VERSION,
136
+ profile: word("profile") === "2d" ? "2d" : "3d",
137
+ publish: DEFAULT_PUBLISH,
138
+ };
139
+ }
140
+
141
+ export interface BuildFile {
142
+ readonly path: string;
143
+ readonly bytes: number;
144
+ readonly sha256: string;
145
+ }
146
+
147
+ /** og-build.json (docs/web-export.md §The manifest). */
148
+ export interface BuildManifest {
149
+ readonly format: 1;
150
+ readonly engine: { readonly name: "origin-engine"; readonly version: string; readonly pinned: string };
151
+ readonly build: "publish" | "dev";
152
+ readonly game: { readonly name: string; readonly id: string; readonly profile: "2d" | "3d" };
153
+ readonly entry: "index.html";
154
+ readonly worker: "game.js";
155
+ readonly wasm: "origin-engine_bg.wasm";
156
+ /**
157
+ * Which engine module `wasm` is (docs/web-export.md §Variants): `full`
158
+ * (a dev build: every member, both physics profiles), or the publish
159
+ * variant of the game's profile, `3d` or `2d`, which has only that
160
+ * profile's physics. The lanes variant, when there is one, is the same
161
+ * profile's (its physics and the publish members, with wasm atomics).
162
+ */
163
+ readonly variant: "full" | "3d" | "2d";
164
+ /**
165
+ * The lanes variant (docs/web-export.md §Lanes): the same engine built with
166
+ * wasm atomics, which the shell boots instead when the page is cross-origin
167
+ * isolated. Null when the build has none (a dev build, isolation "off").
168
+ */
169
+ readonly lanes: { readonly glue: "origin-engine-lanes.js"; readonly wasm: "origin-engine-lanes_bg.wasm" } | null;
170
+ readonly probe: { readonly protocol: 5; readonly wire: 1; readonly build: string; readonly members: readonly string[] };
171
+ readonly crossOriginIsolation: "required" | "preferred" | "off";
172
+ readonly display: {
173
+ readonly fit: "letterbox" | "fill";
174
+ readonly aspect: string | null;
175
+ readonly orientation: "any" | "landscape" | "portrait";
176
+ readonly dprCap: number;
177
+ readonly fullscreen: "gesture" | "off";
178
+ readonly gate: "tap" | "none";
179
+ readonly madeWith: boolean;
180
+ };
181
+ readonly icons: ReadonlyArray<{ readonly src: string; readonly sizes: string; readonly type: "image/png"; readonly source: "project" | "brand" }>;
182
+ readonly pwa: { readonly manifest: string; readonly serviceWorker: string } | null;
183
+ /** The Player protocol the shell speaks and over which transports (origingame docs/player-protocol.md §The top-level engine page). */
184
+ readonly player: { readonly protocol: 2; readonly transports: readonly ("frame" | "page")[] };
185
+ /** sha256 over the files' paths and hashes: the build's identity (the service worker's cache name). */
186
+ readonly version: string;
187
+ readonly files: readonly BuildFile[];
188
+ readonly total: { readonly files: number; readonly bytes: number };
189
+ /**
190
+ * The bundle split by streaming group: the first group is what the boot
191
+ * fetches and the service worker precaches; each other group is one pack
192
+ * the engine fetches when the group loads (and the service worker caches
193
+ * then). Null for a bundle without streaming groups.
194
+ */
195
+ readonly stream: {
196
+ readonly first: { readonly files: number; readonly bytes: number };
197
+ readonly groups: ReadonlyArray<StreamGroupFile>;
198
+ } | null;
199
+ }
200
+
201
+ /** One streaming group's pack in og-build.json. */
202
+ export interface StreamGroupFile {
203
+ readonly name: string;
204
+ /** Its pack, a path of `files`. */
205
+ readonly file: string;
206
+ readonly bytes: number;
207
+ readonly sha256: string;
208
+ readonly priority: number;
209
+ /** What its assets take once loaded. */
210
+ readonly memory: number;
211
+ readonly assets: readonly string[];
212
+ }
213
+
214
+ /** A cooked manifest's streaming groups and the files of their outputs (written by og cook; crates/og-cook/src/runtime.rs). */
215
+ function streamGroupsOf(manifest: string): { groups: Array<{ name: string; file: string; priority: number; memory: number; assets: string[] }>; grouped: string[] } {
216
+ const groups: Array<{ name: string; file: string; priority: number; memory: number; assets: string[] }> = [];
217
+ const grouped: string[] = [];
218
+ let output: { file?: string; group?: boolean } | null = null;
219
+ let group: (typeof groups)[number] | null = null;
220
+ const unquote = (v: string) => JSON.parse(v) as string;
221
+ for (const line of manifest.split("\n")) {
222
+ let m: RegExpExecArray | null;
223
+ if ((m = /^stream ("(?:[^"\\]|\\.)*") \{$/.exec(line))) {
224
+ group = { name: unquote(m[1]!), file: "", priority: 0, memory: 0, assets: [] };
225
+ groups.push(group);
226
+ } else if (/^\S/.test(line)) group = null;
227
+ if (group) {
228
+ if ((m = /^ file ("(?:[^"\\]|\\.)*")$/.exec(line))) group.file = unquote(m[1]!);
229
+ else if ((m = /^ priority (-?\d+)$/.exec(line))) group.priority = Number(m[1]);
230
+ else if ((m = /^ memory (\d+)$/.exec(line))) group.memory = Number(m[1]);
231
+ else if ((m = /^ asset ("(?:[^"\\]|\\.)*")$/.exec(line))) group.assets.push(unquote(m[1]!));
232
+ continue;
233
+ }
234
+ if (/^ output /.test(line)) output = {};
235
+ else if (output && /^ \}$/.test(line)) {
236
+ if (output.group && output.file) grouped.push(output.file);
237
+ output = null;
238
+ } else if (output && (m = /^ file ("(?:[^"\\]|\\.)*")$/.exec(line))) output.file = unquote(m[1]!);
239
+ else if (output && /^ group "/.test(line)) output.group = true;
240
+ }
241
+ return { groups, grouped };
242
+ }
243
+
244
+ /** Files, raw bytes and compressed bytes (the size table's codec). */
245
+ export interface DownloadSum {
246
+ readonly files: number;
247
+ readonly bytes: number;
248
+ readonly compressed: number;
249
+ }
250
+
251
+ /**
252
+ * What a player downloads, as the size gate counts it (each file's brotli
253
+ * -q 11 size, gzip -9 without the brotli CLI): the boot of each page the
254
+ * build serves, everything fetched before its first frame, and what comes
255
+ * later. A page takes one engine module: the lanes module when the page is
256
+ * cross-origin isolated and the build ships it, else the single-threaded
257
+ * one (docs/web-export.md §Lanes). The WebGPU and WebGL2 pages fetch the
258
+ * same files. `total` (every file of the directory) is none of these: it
259
+ * counts both engine modules, the licences, the icons and the manifest.
260
+ */
261
+ export interface Downloads {
262
+ /** The boot of the single-threaded page, and of the lanes page when the build ships lanes. */
263
+ readonly boot: { readonly single: DownloadBoot; readonly lanes: DownloadBoot | null };
264
+ readonly later: {
265
+ /** Each streaming group's pack, fetched when its group loads. */
266
+ readonly packs: DownloadSum;
267
+ /** The chunks of the sounds cooked in chunks after their first, fetched ahead of their playheads. */
268
+ readonly chunks: DownloadSum;
269
+ /** The AudioWorklet, fetched on the first gesture. */
270
+ readonly worklet: DownloadSum;
271
+ /** The service worker, the web manifest and the icons (a publish build's PWA, after the first frame). */
272
+ readonly pwa: DownloadSum;
273
+ };
274
+ }
275
+
276
+ /** One page's boot: its total and its parts. */
277
+ export interface DownloadBoot extends DownloadSum {
278
+ readonly engine: DownloadSum;
279
+ /** The cooked first group (everything no streaming group holds; a chunked sound's first chunk is its own output). */
280
+ readonly cooked: DownloadSum;
281
+ /** index.html, play.js, game.js. */
282
+ readonly shell: DownloadSum;
283
+ /** The Basis transcoder (fetched when the first group has Basis textures). */
284
+ readonly basis: DownloadSum;
285
+ }
286
+
287
+ /** What each page of a build downloads (Downloads), from its size table. */
288
+ export function downloadsOf(files: readonly SizeRow[]): Downloads {
289
+ const rows = files.map((f) => ({ ...f, path: f.path.split("\\").join("/") }));
290
+ const sum = (test: (path: string) => boolean): DownloadSum =>
291
+ rows.filter((f) => test(f.path)).reduce((a, f) => ({ files: a.files + 1, bytes: a.bytes + f.bytes, compressed: a.compressed + f.compressed }), { files: 0, bytes: 0, compressed: 0 });
292
+ const add = (...parts: DownloadSum[]): DownloadSum => parts.reduce((a, b) => ({ files: a.files + b.files, bytes: a.bytes + b.bytes, compressed: a.compressed + b.compressed }), { files: 0, bytes: 0, compressed: 0 });
293
+ const isPack = (p: string) => /^cooked\/stream\/[^/]+\.ogb$/.test(p);
294
+ const cooked = sum((p) => p.startsWith("cooked/") && !isPack(p) && !SOUND_CHUNK.test(p));
295
+ const shell = sum((p) => /^(index\.html|play\.js|game\.js)$/.test(p));
296
+ const basis = sum((p) => /^basis\/basis_transcoder\.(js|wasm)$/.test(p));
297
+ const page = (engine: DownloadSum): DownloadBoot => ({ ...add(engine, cooked, shell, basis), engine, cooked, shell, basis });
298
+ const lanes = sum((p) => LANES_ENGINE.test(p));
299
+ return {
300
+ boot: { single: page(sum((p) => SINGLE_ENGINE.test(p))), lanes: lanes.files > 0 ? page(lanes) : null },
301
+ later: {
302
+ packs: sum(isPack),
303
+ chunks: sum((p) => SOUND_CHUNK.test(p)),
304
+ worklet: sum((p) => p === "audio-worklet.js"),
305
+ pwa: sum((p) => p === "sw.js" || p === "manifest.webmanifest" || p.startsWith("icons/")),
306
+ },
307
+ };
308
+ }
309
+
310
+ /** The download lines `og build` prints under its size table. */
311
+ export function downloadLines(d: Downloads, codec: SizeRow["codec"]): string {
312
+ const kb = (n: number) => `${(n / 1000).toFixed(1)} kB`;
313
+ const boot = (name: string, b: DownloadBoot) =>
314
+ ` ${name.padEnd(30)} ${String(b.files).padStart(4)} files ${String(b.compressed).padStart(10)} B (engine ${kb(b.engine.compressed)}, cooked ${kb(b.cooked.compressed)}, shell ${kb(b.shell.compressed)}, basis ${kb(b.basis.compressed)})`;
315
+ const lines = [`download (${codec}, what a player fetches; a WebGPU and a WebGL2 page fetch the same files):`];
316
+ if (d.boot.lanes) lines.push(boot("boot, lanes page (isolated)", d.boot.lanes));
317
+ lines.push(boot("boot, single-threaded page", d.boot.single));
318
+ const later = [
319
+ d.later.packs.files ? `${d.later.packs.files} streaming packs ${d.later.packs.compressed} B (as their groups load)` : "",
320
+ d.later.chunks.files ? `${d.later.chunks.files} sound chunks ${d.later.chunks.compressed} B (ahead of their playheads)` : "",
321
+ d.later.worklet.files ? `the audio worklet ${d.later.worklet.compressed} B (first gesture)` : "",
322
+ d.later.pwa.files ? `the service worker and icons ${d.later.pwa.compressed} B` : "",
323
+ ].filter(Boolean);
324
+ if (later.length) lines.push(` later: ${later.join("; ")}`);
325
+ return lines.join("\n") + "\n";
326
+ }
327
+
328
+ export interface WebBuild {
329
+ readonly ok: boolean;
330
+ readonly out: string;
331
+ readonly publish: boolean;
332
+ readonly files: SizeRow[];
333
+ readonly total: { bytes: number; compressed: number };
334
+ /** What each page downloads (boot and later); `total` is every file of the directory. */
335
+ readonly downloads?: Downloads;
336
+ readonly probe: { build: string; members: string[] } | null;
337
+ readonly manifest?: BuildManifest;
338
+ readonly reason?: string;
339
+ }
340
+
341
+ function walk(dir: string): string[] {
342
+ const out: string[] = [];
343
+ for (const name of readdirSync(dir).sort()) {
344
+ const path = join(dir, name);
345
+ if (statSync(path).isDirectory()) out.push(...walk(path));
346
+ else out.push(path);
347
+ }
348
+ return out;
349
+ }
350
+
351
+ function sha256(data: Uint8Array | string): string {
352
+ const h = new Bun.CryptoHasher("sha256");
353
+ h.update(data);
354
+ return h.digest("hex");
355
+ }
356
+
357
+ // Facts that depend only on a file's bytes and are slow to recompute (brotli
358
+ // -q 11 of the 8 MB wasm is about 12 s; the host's hello needs a bun process
359
+ // that loads it) are kept by sha256 in dist/.build-cache.json across builds,
360
+ // so a test that builds four times or a `og build` after an edit to game code
361
+ // pays for the engine once. The cache keeps its newest 2000 entries.
362
+ const CACHE_FILE = join(here, "..", "..", "dist", ".build-cache.json");
363
+ let cache: Record<string, unknown> | null = null;
364
+ function cacheGet(key: string): unknown {
365
+ if (!cache) {
366
+ try {
367
+ cache = JSON.parse(readFileSync(CACHE_FILE, "utf8")) as Record<string, unknown>;
368
+ } catch {
369
+ cache = {};
370
+ }
371
+ }
372
+ return cache[key];
373
+ }
374
+ function cachePut(key: string, value: unknown): void {
375
+ cache ??= {};
376
+ delete cache[key];
377
+ cache[key] = value;
378
+ const keys = Object.keys(cache);
379
+ for (const k of keys.slice(0, Math.max(0, keys.length - 2000))) delete cache[k];
380
+ try {
381
+ mkdirSync(dirname(CACHE_FILE), { recursive: true });
382
+ writeFileSync(CACHE_FILE, JSON.stringify(cache));
383
+ } catch {
384
+ // A read-only checkout still builds; it only recomputes.
385
+ }
386
+ }
387
+
388
+ function compressedSize(bytes: Uint8Array): { size: number; codec: SizeRow["codec"] } {
389
+ const brotli = Boolean(Bun.which("brotli"));
390
+ const key = `size:${brotli ? "brotli-11" : "gzip-9"}:${sha256(bytes)}`;
391
+ const hit = cacheGet(key) as { size: number; codec: SizeRow["codec"] } | undefined;
392
+ if (hit) return hit;
393
+ let out: { size: number; codec: SizeRow["codec"] } | null = null;
394
+ if (brotli) {
395
+ const r = Bun.spawnSync(["brotli", "-q", "11", "-c"], { stdin: bytes, stdout: "pipe", stderr: "pipe" });
396
+ if (r.exitCode === 0) out = { size: r.stdout.length, codec: "brotli-11" };
397
+ }
398
+ out ??= { size: Bun.gzipSync(bytes, { level: 9 }).length, codec: "gzip-9" };
399
+ cachePut(key, out);
400
+ return out;
401
+ }
402
+
403
+ /**
404
+ * The probe build and members the copied web host serves: its own hello,
405
+ * under bun, from an engine of the game's profile (a publish variant has
406
+ * only that profile's physics).
407
+ */
408
+ async function probeOf(glue: string, wasm: string, profile: "2d" | "3d"): Promise<{ build: string; members: string[] } | null> {
409
+ const key = `probe:${profile}:${sha256(new Uint8Array(readFileSync(glue)))}:${sha256(new Uint8Array(readFileSync(wasm)))}`;
410
+ const hit = cacheGet(key) as { build: string; members: string[] } | undefined;
411
+ if (hit) return hit;
412
+ const probe = await probeOfUncached(glue, wasm, profile);
413
+ if (probe) cachePut(key, probe);
414
+ return probe;
415
+ }
416
+
417
+ async function probeOfUncached(glue: string, wasm: string, profile: "2d" | "3d"): Promise<{ build: string; members: string[] } | null> {
418
+ const script = `
419
+ const glue = await import(${JSON.stringify(pathToFileURL(glue).href)});
420
+ const { loadWebHost } = await import(${JSON.stringify(pathToFileURL(join(here, "..", "web", "host.ts")).href)});
421
+ const { og } = await import(${JSON.stringify(pathToFileURL(join(here, "..", "index.ts")).href)});
422
+ const { createEngine } = await import(${JSON.stringify(pathToFileURL(join(here, "..", "runtime.ts")).href)});
423
+ const { readFileSync } = await import("node:fs");
424
+ await loadWebHost(glue, readFileSync(${JSON.stringify(wasm)}));
425
+ const e = createEngine(og.game({}), { profile: ${JSON.stringify(profile)} });
426
+ if (!e.ok) throw new Error(e.reason);
427
+ const hello = JSON.parse(e.value.call(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "hello", params: {} }))).result;
428
+ console.log(JSON.stringify({ build: hello.build, members: hello.members }));
429
+ `;
430
+ const r = Bun.spawnSync(["bun", "-e", script], { stdout: "pipe", stderr: "pipe" });
431
+ if (r.exitCode !== 0) return null;
432
+ const line = new TextDecoder().decode(r.stdout).trim().split("\n").pop() ?? "";
433
+ try {
434
+ return JSON.parse(line) as { build: string; members: string[] };
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ /** A PNG's width and height (its IHDR), or null when the bytes are not a PNG. */
441
+ export function pngSize(bytes: Uint8Array): { width: number; height: number } | null {
442
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
443
+ if (bytes.length < 24 || sig.some((b, i) => bytes[i] !== b)) return null;
444
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
445
+ return { width: v.getUint32(16), height: v.getUint32(20) };
446
+ }
447
+
448
+ /** `"16:9"` as a ratio, or null. */
449
+ export function aspectRatio(aspect: string | null): number | null {
450
+ const m = aspect ? /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(aspect) : null;
451
+ if (!m) return null;
452
+ const r = Number(m[1]) / Number(m[2]);
453
+ return Number.isFinite(r) && r > 0 ? r : null;
454
+ }
455
+
456
+ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
457
+
458
+ /** index.html: the shell's markup with its CSS, marks and configuration inlined. */
459
+ export function pageHtml(o: { settings: ProjectSettings; publish: boolean; config: Record<string, unknown>; icon: string; pwa: boolean }): string {
460
+ const css = [shellTokens(), motionSubset(SHELL_MOTIONS), readFileSync(join(SHELL_DIR, "shell.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\n\s*/g, "\n").trim()].join("\n");
461
+ // `<` cannot appear in the JSON's strings unescaped, so the script element cannot be closed early.
462
+ const config = JSON.stringify(o.config).replace(/</g, "\\u003c");
463
+ const title = escapeHtml(o.settings.name);
464
+ return `<!doctype html>
465
+ <html lang="en">
466
+ <head>
467
+ <meta charset="utf-8">
468
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
469
+ <meta name="og-engine" content="${escapeHtml(ENGINE_VERSION)}">
470
+ <meta name="og-build" content="${o.publish ? "publish" : "dev"}">
471
+ <meta name="og-profile" content="${o.settings.profile}">
472
+ <meta name="og-player" content="${PLAYER_TRANSPORTS.join(" ")}">
473
+ <meta name="theme-color" content="#14161b">
474
+ <meta name="color-scheme" content="dark">
475
+ <meta name="mobile-web-app-capable" content="yes">
476
+ <meta name="apple-mobile-web-app-capable" content="yes">
477
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
478
+ <meta name="apple-mobile-web-app-title" content="${escapeHtml(o.settings.publish.shortName ?? o.settings.name)}">
479
+ <title>${title}</title>
480
+ <link rel="icon" href="./${o.icon}">
481
+ <link rel="apple-touch-icon" href="./${o.icon}">
482
+ ${o.pwa ? '<link rel="manifest" href="./manifest.webmanifest">\n' : ""}<link rel="modulepreload" href="./play.js">
483
+ <style>
484
+ ${css}
485
+ </style>
486
+ </head>
487
+ <body>
488
+ <div id="og-stage"><canvas id="og-canvas" tabindex="0" aria-label="${title}"></canvas></div>
489
+ <div id="og-boot" class="og-layer" role="progressbar" aria-label="Loading" aria-valuemin="0" aria-valuemax="100">
490
+ <div id="og-backdrop" aria-hidden="true"></div>
491
+ ${bootMark()}
492
+ <div class="og-boot-label" id="og-boot-label" aria-live="polite"></div>
493
+ </div>
494
+ <div id="og-gate" class="og-layer" hidden><button type="button">Play</button></div>
495
+ <div id="og-error" class="og-layer" role="alert" hidden>
496
+ <div class="og-card">
497
+ <h1></h1>
498
+ <p></p>
499
+ <details><summary>Log</summary><pre id="og-error-log"></pre></details>
500
+ <details hidden><summary>World</summary><pre id="og-error-dump"></pre></details>
501
+ <div class="og-actions"><button type="button" data-action="reload">Reload</button><button type="button" data-action="copy">Copy report</button></div>
502
+ </div>
503
+ </div>
504
+ <div id="og-install" role="dialog" aria-label="Install" hidden><span></span><button type="button"></button><button type="button"></button></div>
505
+ <button id="og-back" type="button" aria-label="Back" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m15 6-6 6 6 6"/></svg><span>Back</span></button>
506
+ <a id="og-made-with" href="https://origingame.ai" target="_blank" rel="noopener"${o.settings.publish.madeWith ? "" : " hidden"}>${engineMark()}<span>Made with Origin Engine</span></a>
507
+ <script type="application/json" id="og-config">${config}</script>
508
+ <script type="module" src="./play.js"></script>
509
+ </body>
510
+ </html>
511
+ `;
512
+ }
513
+
514
+ /** The `_headers` file: COOP/COEP when the project wants isolation, CORP and caching always. */
515
+ export function headersFile(isolation: PublishSettings["isolation"]): string {
516
+ const lines = ["/*", " Cross-Origin-Resource-Policy: cross-origin", " X-Content-Type-Options: nosniff"];
517
+ if (isolation !== "off") lines.push(" Cross-Origin-Opener-Policy: same-origin", " Cross-Origin-Embedder-Policy: require-corp");
518
+ lines.push(
519
+ "/index.html",
520
+ " Cache-Control: no-cache",
521
+ "/",
522
+ " Cache-Control: no-cache",
523
+ "/sw.js",
524
+ " Cache-Control: no-cache",
525
+ "/og-build.json",
526
+ " Cache-Control: no-cache",
527
+ "/*.wasm",
528
+ " Content-Type: application/wasm",
529
+ "/manifest.webmanifest",
530
+ " Content-Type: application/manifest+json",
531
+ );
532
+ return lines.join("\n") + "\n";
533
+ }
534
+
535
+ async function bundleScript(entry: string, minify: boolean): Promise<string | { error: string }> {
536
+ const r = await Bun.build({ entrypoints: [entry], target: "browser", format: "esm", minify });
537
+ if (!r.success) return { error: r.logs.map(String).join("\n") };
538
+ return r.outputs[0]!.text();
539
+ }
540
+
541
+ /** Files that never go into the service worker's precache (not fetched by the page). */
542
+ const NOT_PRECACHED = /(^|\/)(LICENSE|NOTICE|README\.md|_headers|origingame\.runtime\.json|og-build\.json|sw\.js)$/;
543
+ /**
544
+ * The lanes variant's files beside each profile's publish module
545
+ * (dist/web-publish for 3D, dist/web-publish-2d for 2D; scripts/build.sh);
546
+ * snippets/ holds wasm-bindgen-rayon's Worker module.
547
+ */
548
+ const LANES_FILES = ["origin-engine-lanes.js", "origin-engine-lanes_bg.wasm", "snippets"] as const;
549
+ /** Each engine variant's own files: the service worker precaches the one its page boots. */
550
+ const SINGLE_ENGINE = /^origin-engine(\.js|_bg\.wasm)$/;
551
+ const LANES_ENGINE = /^(origin-engine-lanes(\.js|_bg\.wasm)|snippets\/.*)$/;
552
+ /** A chunk of a sound cooked in chunks: fetched as its playhead nears it, cached then. */
553
+ const SOUND_CHUNK = /^cooked\/.*\.chunk\d+\.ogb$/;
554
+
555
+ /**
556
+ * Why `out` may not be wiped for a build, or null. A build replaces its
557
+ * output directory whole, so it refuses the project itself, any ancestor of
558
+ * it, a directory holding a project.ogt, and any non-empty directory that a
559
+ * previous build did not write (no og-build.json in it).
560
+ */
561
+ function outRefusal(project: string, out: string): string | null {
562
+ const real = (path: string) => (existsSync(path) ? realpathSync(path) : resolve(path));
563
+ const root = real(project);
564
+ const target = real(out);
565
+ const up = relative(target, root);
566
+ if (up === "" || !(up === ".." || up.startsWith(`..${sep}`) || isAbsolute(up))) return `${out} is the project or contains it; give an output directory inside or beside the project (default dist)`;
567
+ if (!existsSync(target)) return null;
568
+ if (!statSync(target).isDirectory()) return `${out} is a file, not a directory`;
569
+ if (existsSync(join(target, "project.ogt"))) return `${out} holds a project.ogt; give an output directory of its own (default dist)`;
570
+ if (readdirSync(target).length > 0 && !existsSync(join(target, BUILD_MANIFEST))) return `${out} is not empty and holds no ${BUILD_MANIFEST} from an earlier build, so og build will not replace it; empty or remove it, or give another output directory`;
571
+ return null;
572
+ }
573
+
574
+ export async function webBuild(project: string, entry: string, out: string, publish: boolean, given?: ProjectSettings): Promise<WebBuild> {
575
+ // Once the output directory is replaced, a failure removes what was half written,
576
+ // so the next build is not refused for a directory without og-build.json.
577
+ let replaced = false;
578
+ const fail = (reason: string): WebBuild => {
579
+ if (replaced) rmSync(out, { recursive: true, force: true });
580
+ return { ok: false, out, publish, files: [], total: { bytes: 0, compressed: 0 }, probe: null, reason };
581
+ };
582
+ const refusal = outRefusal(project, out);
583
+ if (refusal) return fail(refusal);
584
+ const settings = given ?? settingsOf(project);
585
+ const p = settings.publish;
586
+ const cooked = join(project, "cooked");
587
+ if (!existsSync(join(cooked, ".cook.ogt"))) return fail(`${cooked} has no cooked bundle; run \`og cook ${project}\` first`);
588
+ // The engine module: a dev build takes the full one; a publish build the
589
+ // variant of the game's profile, which a player downloads alone, and the
590
+ // lanes variant of that profile from the same directory (one lanes module
591
+ // per profile since owed row 71: a 2D game's lanes page never downloads
592
+ // rapier3d, nor a 3D game's rapier2d).
593
+ const variant: BuildManifest["variant"] = publish ? settings.profile : "full";
594
+ const engineDir = join(ENGINE_ROOT, "dist", variant === "2d" ? "web-publish-2d" : publish ? "web-publish" : "web");
595
+ for (const f of ["origin-engine.js", "origin-engine_bg.wasm"]) {
596
+ if (!existsSync(join(engineDir, f))) return fail(`${join(engineDir, f)} is missing: build the web host with \`bun run build\` in packages/engine (scripts/build.sh)${variant === "2d" ? "; a 2d game ships the 2D publish variant, dist/web-publish-2d" : ""}`);
597
+ }
598
+ const vendor = join(ENGINE_ROOT, "vendor", "basis");
599
+ if (!existsSync(join(vendor, "basis_transcoder.wasm"))) return fail(`${vendor} is missing the Basis transcoder`);
600
+ let projectIcon: { bytes: Uint8Array; size: number } | null = null;
601
+ if (p.icon) {
602
+ const file = resolve(project, p.icon);
603
+ if (!existsSync(file)) return fail(`project.ogt publish.icon: ${p.icon} does not exist; give a square PNG of at least 192 px, or remove the line for the brand tile`);
604
+ const bytes = new Uint8Array(readFileSync(file));
605
+ const size = pngSize(bytes);
606
+ if (!size) return fail(`project.ogt publish.icon: ${p.icon} is not a PNG; give a square PNG of at least 192 px`);
607
+ if (size.width !== size.height || size.width < 192) return fail(`project.ogt publish.icon: ${p.icon} is ${size.width}x${size.height}; give a square PNG of at least 192 px (512 px is best)`);
608
+ projectIcon = { bytes, size: size.width };
609
+ }
610
+ rmSync(out, { recursive: true, force: true });
611
+ mkdirSync(out, { recursive: true });
612
+ replaced = true;
613
+
614
+ const game = await bundleGame(entry, join(out, "game.js"), "web", publish);
615
+ if (!game.ok) return fail(`bundling ${entry}: ${game.reason}`);
616
+ for (const [file, src] of [["play.js", join(here, "..", "web", "play.ts")], ["audio-worklet.js", join(here, "..", "web", "audio-worklet.ts")]] as const) {
617
+ const text = await bundleScript(src, publish);
618
+ if (typeof text !== "string") return fail(`bundling ${file}: ${text.error}`);
619
+ writeFileSync(join(out, file), text);
620
+ }
621
+ for (const f of ["origin-engine.js", "origin-engine_bg.wasm"]) cpSync(join(engineDir, f), join(out, f));
622
+ // The lanes variant needs cross-origin isolation, which only a build that declares it can get.
623
+ const missingLanes = publish && p.isolation !== "off" ? LANES_FILES.filter((f) => !existsSync(join(engineDir, f))) : [];
624
+ const lanes = publish && p.isolation !== "off" && missingLanes.length === 0;
625
+ if (missingLanes.length > 0) {
626
+ process.stderr.write(
627
+ `og build: warning: ${join(engineDir, missingLanes[0]!)} is missing, so this build ships single-threaded (no ${variant === "2d" ? "2D" : "3D"} lanes variant); \`bun run build\` in packages/engine builds it (scripts/build.sh, docs/web-export.md §Lanes)\n`,
628
+ );
629
+ }
630
+ if (lanes) {
631
+ for (const f of LANES_FILES) cpSync(join(engineDir, f), join(out, f), { recursive: true });
632
+ }
633
+ cpSync(vendor, join(out, "basis"), { recursive: true });
634
+ cpSync(cooked, join(out, "cooked"), { recursive: true });
635
+ // The cook's own timings (og cook --report) are not the game's.
636
+ rmSync(join(out, "cooked", ".cook-times.ogt"), { force: true });
637
+ // Streaming groups: a grouped output is read from its group's pack, so its own file is not shipped.
638
+ const split = streamGroupsOf(readFileSync(join(cooked, ".cook.ogt"), "utf8"));
639
+ for (const f of split.grouped) rmSync(join(out, "cooked", f), { force: true });
640
+ const packs = new Set(split.groups.map((g) => `cooked/${g.file}`));
641
+
642
+ // Icons: the project's, or the brand tile (shell/brand, from the platform's packages/brand).
643
+ mkdirSync(join(out, "icons"), { recursive: true });
644
+ const icons: Array<BuildManifest["icons"][number]> = [];
645
+ if (projectIcon) {
646
+ writeFileSync(join(out, "icons", "icon.png"), projectIcon.bytes);
647
+ icons.push({ src: "icons/icon.png", sizes: `${projectIcon.size}x${projectIcon.size}`, type: "image/png", source: "project" });
648
+ } else {
649
+ for (const n of [192, 512]) {
650
+ cpSync(join(BRAND_DIR, `icon-${n}.png`), join(out, "icons", `icon-${n}.png`));
651
+ icons.push({ src: `icons/icon-${n}.png`, sizes: `${n}x${n}`, type: "image/png", source: "brand" });
652
+ }
653
+ }
654
+ const icon = icons[icons.length - 1]!.src;
655
+
656
+ // What the boot fetches, so its progress is the bytes received: the first
657
+ // group without the chunks of a chunked sound after its first, which the
658
+ // engine fetches ahead of the playheads (never at boot).
659
+ const wasmBytes = statSync(join(out, "origin-engine_bg.wasm")).size;
660
+ const firstGroup = walk(join(out, "cooked")).filter((f) => {
661
+ const path = relative(out, f).split("\\").join("/");
662
+ return !packs.has(path) && !SOUND_CHUNK.test(path);
663
+ });
664
+ const cookedBytes = firstGroup.filter((f) => !f.endsWith(".cook.ogt")).reduce((n, f) => n + statSync(f).size, 0);
665
+ const config = {
666
+ engine: ENGINE_VERSION,
667
+ build: publish ? "publish" : "dev",
668
+ name: settings.name,
669
+ profile: settings.profile,
670
+ // project.ogt's text: the engine takes its tick, seed, scenes and scene
671
+ // for what GameDesc leaves unset (CreateOptions.project).
672
+ ...(existsSync(join(project, "project.ogt")) ? { project: readFileSync(join(project, "project.ogt"), "utf8") } : {}),
673
+ isolation: p.isolation,
674
+ fit: p.fit,
675
+ aspect: aspectRatio(p.aspect),
676
+ orientation: p.orientation,
677
+ dprCap: p.dprCap,
678
+ fullscreen: p.fullscreen,
679
+ gate: p.gate,
680
+ madeWith: p.madeWith,
681
+ serviceWorker: publish,
682
+ expect: { wasm: wasmBytes, cooked: cookedBytes, lanes: lanes ? statSync(join(out, "origin-engine-lanes_bg.wasm")).size : 0 },
683
+ };
684
+ writeFileSync(join(out, "index.html"), pageHtml({ settings, publish, config, icon, pwa: publish }));
685
+ writeFileSync(join(out, "_headers"), headersFile(p.isolation));
686
+ if (p.isolation !== "off") writeFileSync(join(out, "origingame.runtime.json"), JSON.stringify({ version: 1, crossOriginIsolated: true }) + "\n");
687
+ if (publish) {
688
+ const webmanifest = {
689
+ id: "./",
690
+ name: settings.name,
691
+ short_name: p.shortName ?? settings.name,
692
+ start_url: "./",
693
+ scope: "./",
694
+ display: "standalone",
695
+ orientation: p.orientation,
696
+ background_color: "#14161b",
697
+ theme_color: "#14161b",
698
+ icons: icons.map((i) => ({ src: i.src, sizes: i.sizes, type: i.type, purpose: "any" })),
699
+ };
700
+ writeFileSync(join(out, "manifest.webmanifest"), JSON.stringify(webmanifest, null, 2) + "\n");
701
+ }
702
+
703
+ const fileRows = (): BuildFile[] =>
704
+ walk(out)
705
+ .map((path) => {
706
+ const bytes = new Uint8Array(readFileSync(path));
707
+ return { path: relative(out, path).split("\\").join("/"), bytes: bytes.length, sha256: sha256(bytes) };
708
+ })
709
+ .filter((f) => f.path !== BUILD_MANIFEST)
710
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
711
+ const identity = (rows: BuildFile[]) => sha256(rows.filter((f) => f.path !== "sw.js").map((f) => `${f.path}\0${f.sha256}\n`).join(""));
712
+ let version = identity(fileRows());
713
+ if (publish) {
714
+ const sw = await bundleScript(join(SHELL_DIR, "sw.ts"), true);
715
+ if (typeof sw !== "string") return fail(`bundling sw.js: ${sw.error}`);
716
+ // The first group is precached; a streaming group's pack is cached when the engine fetches it.
717
+ // So is a long sound's chunk (cooked/<sound>.chunk<n>.ogb).
718
+ const precache = fileRows().map((f) => f.path).filter((f) => !NOT_PRECACHED.test(f) && !packs.has(f) && !SOUND_CHUNK.test(f));
719
+ const chunks = fileRows().map((f) => f.path).filter((f) => SOUND_CHUNK.test(f));
720
+ const common = precache.filter((f) => !SINGLE_ENGINE.test(f) && !LANES_ENGINE.test(f));
721
+ const variants = { single: precache.filter((f) => SINGLE_ENGINE.test(f)), lanes: precache.filter((f) => LANES_ENGINE.test(f)) };
722
+ writeFileSync(join(out, "sw.js"), `const OG_SW=${JSON.stringify({ version: version.slice(0, 16), files: ["./", ...common], lazy: [...packs, ...chunks].sort(), ...variants })};\n${sw}`);
723
+ version = identity(fileRows());
724
+ }
725
+
726
+ const probe = await probeOf(join(out, "origin-engine.js"), join(out, "origin-engine_bg.wasm"), settings.profile);
727
+ const rows = fileRows();
728
+ const manifest: BuildManifest = {
729
+ format: 1,
730
+ engine: { name: "origin-engine", version: ENGINE_VERSION, pinned: settings.engine },
731
+ build: publish ? "publish" : "dev",
732
+ game: { name: settings.name, id: settings.id, profile: settings.profile },
733
+ entry: "index.html",
734
+ worker: "game.js",
735
+ wasm: "origin-engine_bg.wasm",
736
+ variant,
737
+ lanes: lanes ? { glue: "origin-engine-lanes.js", wasm: "origin-engine-lanes_bg.wasm" } : null,
738
+ probe: { protocol: 5, wire: 1, build: probe?.build ?? (publish ? "publish" : "release"), members: probe?.members ?? [] },
739
+ crossOriginIsolation: p.isolation,
740
+ display: { fit: p.fit, aspect: p.aspect, orientation: p.orientation, dprCap: p.dprCap, fullscreen: p.fullscreen, gate: p.gate, madeWith: p.madeWith },
741
+ icons,
742
+ pwa: publish ? { manifest: "manifest.webmanifest", serviceWorker: "sw.js" } : null,
743
+ player: { protocol: 2, transports: PLAYER_TRANSPORTS },
744
+ version,
745
+ files: rows,
746
+ total: { files: rows.length, bytes: rows.reduce((n, f) => n + f.bytes, 0) },
747
+ stream:
748
+ split.groups.length === 0
749
+ ? null
750
+ : {
751
+ first: { files: firstGroup.length, bytes: firstGroup.reduce((n, f) => n + statSync(f).size, 0) },
752
+ groups: split.groups.map((g) => {
753
+ const row = rows.find((f) => f.path === `cooked/${g.file}`);
754
+ return { name: g.name, file: `cooked/${g.file}`, bytes: row?.bytes ?? 0, sha256: row?.sha256 ?? "", priority: g.priority, memory: g.memory, assets: g.assets };
755
+ }),
756
+ },
757
+ };
758
+ writeFileSync(join(out, BUILD_MANIFEST), JSON.stringify(manifest, null, 2) + "\n");
759
+
760
+ const files = walk(out).map((path): SizeRow => {
761
+ const bytes = new Uint8Array(readFileSync(path));
762
+ const c = compressedSize(bytes);
763
+ return { path: relative(out, path), bytes: bytes.length, compressed: c.size, codec: c.codec };
764
+ });
765
+ const total = files.reduce((t, f) => ({ bytes: t.bytes + f.bytes, compressed: t.compressed + f.compressed }), { bytes: 0, compressed: 0 });
766
+ return { ok: true, out, publish, files, total, downloads: downloadsOf(files), probe, manifest };
767
+ }
768
+
769
+ function table(b: WebBuild): string {
770
+ const codec = b.files[0]?.codec ?? "brotli-11";
771
+ const rows = [["file", "bytes", codec], ...b.files.map((f) => [f.path, String(f.bytes), String(f.compressed)]), ["total (every file; not a download)", String(b.total.bytes), String(b.total.compressed)]];
772
+ const w = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i]!.length)));
773
+ return rows.map((r) => `${r[0]!.padEnd(w[0]!)} ${r[1]!.padStart(w[1]!)} ${r[2]!.padStart(w[2]!)}`).join("\n") + "\n";
774
+ }
775
+
776
+ if (import.meta.main) {
777
+ const argv = process.argv.slice(2);
778
+ const opt = (name: string) => {
779
+ const i = argv.indexOf(name);
780
+ return i >= 0 ? argv[i + 1] : undefined;
781
+ };
782
+ const project = opt("--project");
783
+ const entry = opt("--entry");
784
+ const out = opt("--out");
785
+ if (!project || !entry || !out) {
786
+ process.stderr.write("usage: bun src/node/web-build.ts --project <dir> --entry <src/main.ts> --out <dist> [--publish] [--json] [--settings <json>]\n");
787
+ process.exit(2);
788
+ }
789
+ const given = opt("--settings");
790
+ let settings: ProjectSettings | undefined;
791
+ if (given) {
792
+ const s = JSON.parse(given) as Omit<ProjectSettings, "publish"> & { publish?: Partial<PublishSettings> };
793
+ settings = { ...s, publish: { ...DEFAULT_PUBLISH, ...s.publish } };
794
+ }
795
+ const b = await webBuild(resolve(project!), resolve(entry!), resolve(out!), argv.includes("--publish"), settings);
796
+ if (!b.ok) {
797
+ if (argv.includes("--json")) process.stdout.write(JSON.stringify(b) + "\n");
798
+ process.stderr.write(`og build: ${b.reason}\n`);
799
+ process.exit(1);
800
+ }
801
+ if (argv.includes("--json")) process.stdout.write(JSON.stringify(b) + "\n");
802
+ else {
803
+ process.stdout.write(table(b));
804
+ if (b.downloads) process.stdout.write(downloadLines(b.downloads, b.files[0]?.codec ?? "brotli-11"));
805
+ if (b.probe) process.stdout.write(`probe: ${b.probe.build} build, ${b.probe.members.length} members: ${b.probe.members.join(", ")}\n`);
806
+ process.stdout.write(`${BUILD_MANIFEST}: ${b.manifest!.build} build of ${b.manifest!.game.name}, engine ${b.manifest!.engine.version}, isolation ${b.manifest!.crossOriginIsolation}, version ${b.manifest!.version.slice(0, 16)}\n`);
807
+ }
808
+ }