@looop-games/cli 0.1.34 → 0.1.36

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.
@@ -0,0 +1,440 @@
1
+ // The dev recording store — where a session you just played is kept.
2
+ //
3
+ // In dev the room server, the game folder and the person who played are all on
4
+ // one machine, so a recording has no reason to make a round trip: the room
5
+ // posts each closed segment to the dev server (see sessionIngestUrlFor in
6
+ // shared/ui/room/session-ingest.js) and it lands here, under the game's
7
+ // `.looop/replays/<stream>/`. Watching a session back is then just reading
8
+ // files that are already on disk — which is what makes `?replay=<id>` a URL
9
+ // you paste rather than a fetch with credentials behind it.
10
+ //
11
+ // A PUBLISHED game is the other case entirely: its room is a Worker, the only
12
+ // durable place is the platform, and segments go to /sessions/ingest (R2 +
13
+ // D1). Nothing here is involved in that lane.
14
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+
18
+ // The stream id is minted room-side from a timestamp and a counter, and it is
19
+ // interpolated into a filesystem path — so its charset is pinned here rather
20
+ // than trusted. Anything with a separator, a dot segment, or nothing in it is
21
+ // refused outright instead of being sanitized into something plausible.
22
+ const STREAM_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
23
+
24
+ // Where a dev room posts its closed segments. The engine derives the same path
25
+ // in shared/ui/room/session-ingest.js (DEV_SEGMENT_PATH there) — the two ends
26
+ // of one wire, kept spelled out in both so neither can drift silently.
27
+ export const DEV_SEGMENT_PATH = '/__looop/segments';
28
+
29
+ // Where the dev page asks what recordings exist, deletes one, or pins one
30
+ // against the prune — the read half of the same lane. Spelled out in the
31
+ // engine's Sessions tool too (shared/ui/sessions/sessions.js), for the same
32
+ // reason as above: two ends of one wire, neither able to drift silently.
33
+ export const DEV_REPLAYS_PATH = '/__looop/replays';
34
+
35
+ export function replaysRoot(projectDir) {
36
+ return join(projectDir, '.looop', 'replays');
37
+ }
38
+
39
+ export function streamDir(projectDir, stream) {
40
+ if (!STREAM_RE.test(stream ?? '')) throw new Error(`invalid stream id: ${JSON.stringify(stream)}`);
41
+ return join(replaysRoot(projectDir), stream);
42
+ }
43
+
44
+ /**
45
+ * Persist one closed segment. The address is (stream, seq), the same pair the
46
+ * production index uses, so a room's bounded retry rewrites the same file
47
+ * rather than appending a duplicate.
48
+ * @param {string} projectDir - the game folder
49
+ * @param {{ slug?: string, room?: string, segment: object }} body - the room's POST body
50
+ * @returns {{ stored: true, stream: string, seq: number }}
51
+ */
52
+ export function writeSegment(projectDir, body) {
53
+ const segment = body?.segment;
54
+ const header = segment?.header;
55
+ if (!header || typeof header !== 'object') throw new Error('not a segment: no header');
56
+ if (header.format !== 'looop-replay-seg/1') {
57
+ throw new Error(`not a segment: format ${JSON.stringify(header.format)}`);
58
+ }
59
+ // Bounded above as well as below: `seq` names the file, and while no number's
60
+ // string form can contain a separator, an unbounded one lets a sender pick
61
+ // arbitrary names in the stream directory (`1e21`, `9007199254740992`) that
62
+ // the reader's lexical sort then misorders. A stream that reaches a million
63
+ // segments is a bug, not a session.
64
+ //
65
+ // The bound is EXCLUSIVE because the filename is six digits: an accepted
66
+ // 1000000 would write `1000000.json`, which SEGMENT_FILE_RE never matches, so
67
+ // the store would answer `stored: true` (stopping the room's retries) to a
68
+ // segment no reader can see and no retention cap can ever sweep.
69
+ if (!Number.isInteger(header.seq) || header.seq < 0 || header.seq >= 1e6) {
70
+ throw new Error('not a segment: bad seq');
71
+ }
72
+ if (!Array.isArray(segment.events)) throw new Error('not a segment: no events');
73
+ const dir = streamDir(projectDir, header.stream);
74
+ mkdirSync(dir, { recursive: true });
75
+ writeFileSync(join(dir, segmentFileName(header.seq)), JSON.stringify(segment));
76
+ updateIndex(dir, segment);
77
+ return { stored: true, stream: header.stream, seq: header.seq };
78
+ }
79
+
80
+ // ── the per-stream index ────────────────────────────────────────────────────
81
+ //
82
+ // The Sessions panel asks each recording when it was played, how long it ran
83
+ // and how many inputs it carries. Every one of those lives in the segments, so
84
+ // answering from the segments costs the whole recording — a five-minute session
85
+ // is megabytes, and the panel asks about twenty of them at once, on a phone.
86
+ // So each segment write also folds its own contribution into a small file
87
+ // beside the segments, and reading a stream's headline facts is then one small
88
+ // read instead of a full parse.
89
+ //
90
+ // The counts are held PER SEQ rather than as a running total because a segment
91
+ // can legitimately arrive twice: a room's flush retries are idempotent by
92
+ // (stream, seq) and rewrite the same file, so a running total would count that
93
+ // segment's inputs again every time the network hiccuped.
94
+ //
95
+ // Keyed as an OBJECT rather than indexed into an array, because `seq` comes off
96
+ // the wire from an endpoint nothing authenticates. An array indexed at seq
97
+ // 999999 serialises 999999 holes as `null` — so a 160-byte POST would write
98
+ // megabytes here, none of it visible to the retention caps, and every later
99
+ // listing would reduce over a million elements inside the dev server's one
100
+ // thread. An object costs one entry per segment that actually exists.
101
+ //
102
+ // The index is derived, never authoritative: it is rebuilt from the segments
103
+ // whenever it is missing or was written by an older shape, so deleting it is a
104
+ // safe thing for anyone to do and an older recording gains one on first read.
105
+ const INDEX_FILE = 'index.json';
106
+ const INDEX_VERSION = 2;
107
+
108
+ // Segments are the six-digit files; everything else in the directory (this
109
+ // index, the keep marker) is bookkeeping and is not a segment.
110
+ const SEGMENT_FILE_RE = /^\d{6}\.json$/;
111
+
112
+ function segmentFileName(seq) {
113
+ // Zero-padded so a directory listing is already in stream order — the same
114
+ // reason the R2 key is padded.
115
+ return `${String(seq).padStart(6, '0')}.json`;
116
+ }
117
+
118
+ function countInputs(segment) {
119
+ // A recorded INPUT is a `w` — an accepted wire send, the player doing
120
+ // something. Spawns, roster changes and checksums are the world's own
121
+ // bookkeeping and are not things anybody did.
122
+ return (segment?.events ?? []).reduce((n, e) => n + (e?.k === 'w' ? 1 : 0), 0);
123
+ }
124
+
125
+ function foldSegment(index, segment) {
126
+ const h = segment.header;
127
+ const seq = h.seq;
128
+ // Anchored on the SEQ alone. `startedAt` is null in a runtime with no Date
129
+ // (recorder.js writes it conditionally), and keying on "we have no stamp yet"
130
+ // would make every later segment re-anchor — so `startTick` would track the
131
+ // last segment written and the duration would collapse to one segment's span,
132
+ // or go negative.
133
+ if (seq < (index.firstSeq ?? Infinity)) {
134
+ index.firstSeq = seq;
135
+ index.startedAt = h.startedAt ?? null;
136
+ index.startTick = h.startTick ?? 0;
137
+ }
138
+ if (h.tickRate) index.tickRate = h.tickRate;
139
+ // Whether this stream can be ENTERED. A replay starts at a keyframe, so a
140
+ // stream that has not cut one yet is on disk and not yet watchable — a
141
+ // not-yet, not a corruption, and a different answer from "no such recording".
142
+ if (h.keyframe) index.hasKeyframe = true;
143
+ const end = segment.endTick ?? h.startTick ?? 0;
144
+ index.endTick = Math.max(index.endTick ?? -Infinity, end);
145
+ index.inputsBySeq[String(seq)] = countInputs(segment);
146
+ return index;
147
+ }
148
+
149
+ function emptyIndex(stream) {
150
+ return { v: INDEX_VERSION, stream, startedAt: null, firstSeq: null, startTick: 0, endTick: null,
151
+ tickRate: null, hasKeyframe: false, inputsBySeq: {} };
152
+ }
153
+
154
+ // Read every segment in a stream and fold them into a fresh index. The slow
155
+ // path, taken once per stream: on the first write, and on the first read of a
156
+ // recording made before this file existed.
157
+ function rebuildIndex(dir, stream) {
158
+ const index = emptyIndex(stream);
159
+ for (const name of readdirSync(dir).sort()) {
160
+ if (!SEGMENT_FILE_RE.test(name)) continue;
161
+ try {
162
+ const seg = JSON.parse(readFileSync(join(dir, name), 'utf8'));
163
+ if (seg?.header?.format === 'looop-replay-seg/1') foldSegment(index, seg);
164
+ } catch {
165
+ // A half-written file (the dev server was killed mid-POST) must not cost
166
+ // the rest of the session — the same tolerance stitching has.
167
+ }
168
+ }
169
+ return index;
170
+ }
171
+
172
+ function readIndex(dir) {
173
+ try {
174
+ const index = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'));
175
+ if (index?.v === INDEX_VERSION && index.inputsBySeq && typeof index.inputsBySeq === 'object'
176
+ && !Array.isArray(index.inputsBySeq)) return index;
177
+ } catch {
178
+ // Missing, half-written, or an older shape — all one case: rebuild.
179
+ }
180
+ return null;
181
+ }
182
+
183
+ function writeIndex(dir, index) {
184
+ try {
185
+ writeFileSync(join(dir, INDEX_FILE), JSON.stringify(index));
186
+ } catch {
187
+ // The index is a cache. A game folder that has gone read-only must not
188
+ // turn a recorded session into a failed POST.
189
+ }
190
+ }
191
+
192
+ function updateIndex(dir, segment) {
193
+ // Absent means rebuild rather than start over: an index deleted mid-stream
194
+ // would otherwise silently lose every segment written before it went.
195
+ const existing = readIndex(dir);
196
+ writeIndex(dir, existing ? foldSegment(existing, segment) : rebuildIndex(dir, segment.header.stream));
197
+ }
198
+
199
+ // ── the read side ───────────────────────────────────────────────────────────
200
+
201
+ // Presence is the whole marker: a recording under triage must survive the
202
+ // prune, and the flag lives in its own FILE rather than in the index because
203
+ // the index is rewritten on every segment write — a keep toggled while that
204
+ // stream is still recording would lose the race. Deleting the directory still
205
+ // takes the marker with it.
206
+ const KEEP_FILE = 'keep';
207
+
208
+ /**
209
+ * Every recording this game has on disk, newest first.
210
+ *
211
+ * Each entry carries what the Sessions panel shows: when it was played, how
212
+ * long it ran, how many inputs it holds, what it weighs, and whether it is
213
+ * pinned against the prune. Cost is one small read and a stat per file — never
214
+ * a parse of the recordings themselves (see the index above).
215
+ *
216
+ * @param {string} projectDir - the game folder
217
+ * @returns {Array<{ stream: string, startedAt: number|null, durationMs: number|null,
218
+ * inputs: number, segments: number, bytes: number, kept: boolean }>}
219
+ */
220
+ export function listRecordings(projectDir) {
221
+ const root = replaysRoot(projectDir);
222
+ if (!existsSync(root)) return [];
223
+ const out = [];
224
+ for (const stream of readdirSync(root)) {
225
+ // Only names this store could have minted. A creator copying a teammate's
226
+ // recording in calls the directory whatever they like, and listing one made
227
+ // `deleteRecording` throw on a name `streamDir` refuses — inside a prune
228
+ // that runs before `looop dev` binds a port, so the whole stack failed to
229
+ // start over a directory name. The two functions agree on the charset here.
230
+ if (!STREAM_RE.test(stream)) continue;
231
+ const dir = join(root, stream);
232
+ let names;
233
+ try {
234
+ if (!statSync(dir).isDirectory()) continue;
235
+ names = readdirSync(dir);
236
+ } catch {
237
+ continue;
238
+ }
239
+ const segments = names.filter((n) => SEGMENT_FILE_RE.test(n));
240
+ if (!segments.length) continue;
241
+ let index = readIndex(dir);
242
+ if (!index) {
243
+ index = rebuildIndex(dir, stream);
244
+ writeIndex(dir, index);
245
+ }
246
+ // Every file, not only the segments: the caps exist to bound a folder, and
247
+ // anything left out of the measurement is something the prune can never
248
+ // reclaim however much of it there is.
249
+ let bytes = 0;
250
+ for (const name of names) {
251
+ try { bytes += statSync(join(dir, name)).size; } catch { /* the file vanished mid-scan */ }
252
+ }
253
+ const ticks = index.endTick == null ? null : index.endTick - (index.startTick ?? 0);
254
+ out.push({
255
+ stream,
256
+ startedAt: index.startedAt ?? null,
257
+ durationMs: ticks != null && index.tickRate ? (ticks / index.tickRate) * 1000 : null,
258
+ inputs: Object.values(index.inputsBySeq).reduce((n, v) => n + (v ?? 0), 0),
259
+ segments: segments.length,
260
+ bytes,
261
+ // A replay enters at a keyframe. A stream a few seconds old has not cut
262
+ // one yet, so it is on disk and not yet watchable.
263
+ watchable: index.hasKeyframe === true,
264
+ kept: existsSync(join(dir, KEEP_FILE)),
265
+ });
266
+ }
267
+ // Newest first, by the wall clock the room stamped rather than by mtime: a
268
+ // copied folder keeps its play order, and the list reads the way the person
269
+ // remembers playing. Streams with no stamp sort last rather than first.
270
+ return out.sort((a, b) => (b.startedAt ?? -Infinity) - (a.startedAt ?? -Infinity));
271
+ }
272
+
273
+ /**
274
+ * The most recent recording that can actually be WATCHED, or null.
275
+ *
276
+ * Newest-that-is-watchable rather than newest: a stream cuts its first keyframe
277
+ * a few seconds in, so the very newest recording is routinely not enterable
278
+ * yet — and `?replay=last` resolving to it would answer "no recording ... it is
279
+ * recorded on the machine that played it" while a perfectly watchable session
280
+ * sits right behind it, on the machine asking.
281
+ */
282
+ export function latestRecording(projectDir) {
283
+ return listRecordings(projectDir).find((r) => r.watchable)?.stream ?? null;
284
+ }
285
+
286
+ /**
287
+ * Delete one recording, whole. Returns false when there was nothing there —
288
+ * deleting a session twice is not an error, it is the second tap.
289
+ */
290
+ export function deleteRecording(projectDir, stream) {
291
+ const dir = streamDir(projectDir, stream);
292
+ if (!existsSync(dir)) return false;
293
+ rmSync(dir, { recursive: true, force: true });
294
+ return true;
295
+ }
296
+
297
+ /**
298
+ * Pin a recording against the prune, or unpin it. Returns false when there is
299
+ * no such recording.
300
+ */
301
+ export function setKept(projectDir, stream, kept) {
302
+ const dir = streamDir(projectDir, stream);
303
+ if (!existsSync(dir)) return false;
304
+ const marker = join(dir, KEEP_FILE);
305
+ if (kept) writeFileSync(marker, '');
306
+ else rmSync(marker, { force: true });
307
+ return true;
308
+ }
309
+
310
+ // What a game's recordings may take up before the oldest start going. Two caps
311
+ // because either one alone has a case it cannot see: a game with big worlds
312
+ // hits the byte cap after a handful of sessions, and a small game would sit at
313
+ // a few megabytes while accumulating hundreds of directories.
314
+ export const MAX_RECORDINGS = 20;
315
+ export const MAX_RECORDING_BYTES = 200 * 1024 * 1024; // ~an hour of a real game at ~2.7 MB/minute
316
+
317
+ /**
318
+ * Bring a game's recordings back under the caps, oldest first.
319
+ *
320
+ * Pinned recordings are never swept, and they still COUNT against the caps —
321
+ * pinning is "do not delete this", not "do not charge me for it". Pin
322
+ * everything and the caps simply stop having anything to take, which is the
323
+ * honest outcome: the alternative silently deletes an unpinned session the
324
+ * creator only just made.
325
+ *
326
+ * @param {string} projectDir - the game folder
327
+ * @param {{ maxCount?: number, maxBytes?: number }} [caps]
328
+ * @returns {{ removed: string[], bytes: number, count: number, caps: { maxCount: number, maxBytes: number } }}
329
+ * what went, what is left, and the caps that decided it
330
+ */
331
+ export function pruneRecordings(projectDir, { maxCount = MAX_RECORDINGS, maxBytes = MAX_RECORDING_BYTES } = {}) {
332
+ // Oldest first: the sweep order is the reverse of the reading order.
333
+ const all = listRecordings(projectDir).reverse();
334
+ let count = all.length;
335
+ let bytes = all.reduce((n, r) => n + r.bytes, 0);
336
+ const removed = [];
337
+ for (const rec of all) {
338
+ if (count <= maxCount && bytes <= maxBytes) break;
339
+ if (rec.kept) continue;
340
+ // A delete that fails for its own reason (a permission, a file held open)
341
+ // costs that one recording, never the startup this runs inside.
342
+ try {
343
+ if (!deleteRecording(projectDir, rec.stream)) continue;
344
+ } catch {
345
+ continue;
346
+ }
347
+ removed.push(rec.stream);
348
+ count -= 1;
349
+ bytes -= rec.bytes;
350
+ }
351
+ return { removed, bytes, count, caps: { maxCount, maxBytes } };
352
+ }
353
+
354
+ /**
355
+ * Read a whole stream back as the single playable recording the replay
356
+ * transport consumes, or null when there is nothing enterable there.
357
+ *
358
+ * Stitching goes through the ENGINE's `stitchSegments` rather than a copy: it
359
+ * is the same reassembler the recorder's own export() uses, which is what
360
+ * keeps "segments are just a slicing of the recording" true by construction.
361
+ * It is imported from the resolved engine bundle, so a recording is always
362
+ * read back by the engine that wrote it — from `entity/stitch.js`, which is
363
+ * the reassembler alone, rather than through the recorder and its runtime
364
+ * dependencies.
365
+ *
366
+ * @param {string} projectDir - the game folder
367
+ * @param {string} stream
368
+ * @param {string} sharedDir - the engine bundle's shared/ tree
369
+ * @returns {Promise<string|null>} the recording as JSON text
370
+ */
371
+ export async function stitchStream(projectDir, stream, sharedDir) {
372
+ const dir = streamDir(projectDir, stream);
373
+ if (!existsSync(dir)) return null;
374
+ const segments = [];
375
+ for (const name of readdirSync(dir).sort()) {
376
+ if (!SEGMENT_FILE_RE.test(name)) continue;
377
+ try {
378
+ const seg = JSON.parse(readFileSync(join(dir, name), 'utf8'));
379
+ if (seg?.header?.format === 'looop-replay-seg/1') segments.push(seg);
380
+ } catch {
381
+ // A half-written file (the dev server was killed mid-POST) must not
382
+ // cost the rest of the session.
383
+ }
384
+ }
385
+ if (!segments.length) return null;
386
+ // The stitcher belongs to the ENGINE, and the engine a game is pinned to may
387
+ // predate recordings entirely — a creator on a new CLI and an old engine can
388
+ // record perfectly well (the room is the new engine's; the segments are on
389
+ // disk) and then find nothing to read them with. Said plainly here, because
390
+ // the alternative is a module-resolution error surfacing to that creator as
391
+ // "no recording", with the files sitting in front of them.
392
+ const stitcher = join(sharedDir, 'ui', 'room', 'entity', 'stitch.js');
393
+ if (!existsSync(stitcher)) {
394
+ throw new Error(
395
+ "this game's pinned engine predates session recordings, so it cannot read one back — "
396
+ + 'run `looop update` to watch this session',
397
+ );
398
+ }
399
+ const { stitchSegments } = await import(pathToFileURL(stitcher).href);
400
+ try {
401
+ return JSON.stringify(stitchSegments(segments));
402
+ } catch {
403
+ // The only way stitching refuses a contiguous range is "no keyframe
404
+ // segment in it" — a stream whose first cadence keyframe has not been cut
405
+ // yet. That is a not-yet, not a corruption.
406
+ return null;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * The prune `looop dev` runs on the way up, and the line it prints.
412
+ *
413
+ * On start rather than on write: a sweep mid-session could delete the
414
+ * recording of the session the creator is in the middle of watching back, and
415
+ * the moment a stack starts is the one moment nobody is reading anything.
416
+ * Silent deletion is not an option either — a creator who goes looking for
417
+ * Tuesday's session deserves to have been told it went, so the line names the
418
+ * count and the caps that took it.
419
+ */
420
+ export function pruneOnStart({ projectDir, log = () => {}, caps } = {}) {
421
+ // This runs before `looop dev` has bound a port. Housekeeping over old
422
+ // recordings is never a reason for the stack not to start, so whatever the
423
+ // disk does here costs the sweep and nothing else.
424
+ let result;
425
+ try {
426
+ result = pruneRecordings(projectDir, caps);
427
+ } catch (e) {
428
+ log(`→ replays could not sweep old recordings: ${e?.message ?? e}`);
429
+ return { removed: [], bytes: 0, count: 0, caps: { maxCount: MAX_RECORDINGS, maxBytes: MAX_RECORDING_BYTES } };
430
+ }
431
+ if (result.removed.length) {
432
+ // The caps the sweep ACTUALLY applied, not the module defaults: a line that
433
+ // names limits a caller overrode would be telling the creator their session
434
+ // went for a reason that was not the reason.
435
+ log(`→ replays swept ${result.removed.length} old recording(s) — keeping the newest `
436
+ + `${result.caps.maxCount} and up to ${Math.round(result.caps.maxBytes / 1024 / 1024)} MB `
437
+ + "(pin one in the toolbox's Sessions tool to keep it)");
438
+ }
439
+ return result;
440
+ }
@@ -158,6 +158,41 @@ export function renderEntityComponents(components) {
158
158
  // A relative path with forward slashes whichever host produced it.
159
159
  const posixPath = (p) => p.split('\\').join('/');
160
160
 
161
+ // The first line of every generated room module, dev and publish alike.
162
+ //
163
+ // Installing deterministic transcendental math happens by import side effect,
164
+ // so the guarantee is about ORDER: any module evaluated ahead of it can capture
165
+ // a native `Math.sin` (or precompute a table with one) and go on using it for
166
+ // the life of the process. A room whose math differs by one bit from the
167
+ // browser's diverges from every replay of its ticks and every predicted tick,
168
+ // hundreds of ticks after the difference is introduced.
169
+ //
170
+ // It is emitted by each caller rather than by the shared preamble so that
171
+ // "first" is true of the emitted text itself. The dev entry would otherwise
172
+ // inherit the install through `/shared/ui/room/server.js`, which is correct
173
+ // today only because that file's own first import is this one — a guarantee
174
+ // living in a different module, one reorder away from silently ending.
175
+ const DETERMINISTIC_MATH_MODULE = 'ui/room/deterministic-math.js';
176
+ const INSTALL_DETERMINISTIC_MATH = `import '/shared/${DETERMINISTIC_MATH_MODULE}';`;
177
+
178
+ // …but only against an engine that HAS it.
179
+ //
180
+ // The CLI and the engine are separate releases, and a game pins the engine
181
+ // exactly while taking the CLI through a caret range — so an ordinary `npm
182
+ // install` routinely produces a new CLI driving an older engine. `/shared/…`
183
+ // specifiers are resolved against that pinned engine's directory and bundled,
184
+ // not left external, so emitting this import unconditionally makes esbuild fail
185
+ // to read a file that release does not contain, and BOTH `looop dev` and
186
+ // `looop publish` die at the bundle step for a game that was building fine.
187
+ //
188
+ // Feature-detecting the file is the whole fix: an older engine simply generates
189
+ // the room it generated before. It also degrades in the right direction — such
190
+ // a room runs on native math, which is what it was already doing.
191
+ function installDeterministicMath(sharedDir) {
192
+ if (!sharedDir) return []; // no engine to interrogate: emit nothing rather than a guess
193
+ return existsSync(join(sharedDir, DETERMINISTIC_MATH_MODULE)) ? [INSTALL_DETERMINISTIC_MATH] : [];
194
+ }
195
+
161
196
  function renderV2Preamble(raw) {
162
197
  // Kind modules are game-relative paths the engine relativized on the
163
198
  // creator's machine. An engine whose framework build predates the
@@ -187,8 +222,9 @@ function renderV2Preamble(raw) {
187
222
  ];
188
223
  }
189
224
 
190
- export function renderV2Entry(skeleton) {
225
+ export function renderV2Entry(skeleton, { sharedDir } = {}) {
191
226
  return [
227
+ ...installDeterministicMath(sharedDir),
192
228
  "import LooopRoom from '/shared/ui/room/server.js';",
193
229
  ...renderV2Preamble(skeleton),
194
230
  'const BUNDLE = { ...TRIO, config: GRAPH.config };',
@@ -257,7 +293,7 @@ export async function buildV2RoomServer({ projectDir, engine, esbuildImpl }) {
257
293
 
258
294
  const result = await esbuild.build({
259
295
  stdin: {
260
- contents: renderV2Entry(skeleton),
296
+ contents: renderV2Entry(skeleton, { sharedDir: engine.sharedDir }),
261
297
  resolveDir: projectDir,
262
298
  sourcefile: 'looop-room-server.js',
263
299
  loader: 'js',
@@ -310,8 +346,9 @@ export async function buildV2RoomServer({ projectDir, engine, esbuildImpl }) {
310
346
  // and EXPORTS it as `frameworkV2Bundle`. The generated entry
311
347
  // (renderV2GameServerEntry, builder side) imports that export and wraps it with
312
348
  // the release's LooopRoom.
313
- export function renderV2RoomModule(skeleton) {
349
+ export function renderV2RoomModule(skeleton, { sharedDir } = {}) {
314
350
  return [
351
+ ...installDeterministicMath(sharedDir),
315
352
  ...renderV2Preamble(skeleton),
316
353
  'export const frameworkV2Bundle = { ...TRIO, config: GRAPH.config };',
317
354
  '',
@@ -339,7 +376,7 @@ export async function buildV2PublishModule({ projectDir, engine, esbuildImpl })
339
376
  const esbuild = esbuildImpl ?? (await import('esbuild'));
340
377
  const result = await esbuild.build({
341
378
  stdin: {
342
- contents: renderV2RoomModule(skeleton),
379
+ contents: renderV2RoomModule(skeleton, { sharedDir: engine.sharedDir }),
343
380
  resolveDir: projectDir,
344
381
  sourcefile: 'looop-v2-room.js',
345
382
  loader: 'js',
@@ -362,8 +399,12 @@ export async function buildV2PublishModule({ projectDir, engine, esbuildImpl })
362
399
  // the host the creator's primitives through the `extraPrimitives()` seam the
363
400
  // engine already reads (server.js). Identical in shape to what the publish
364
401
  // endpoint generates for the per-game Worker — one contract, two builders.
365
- function renderEntry(primitives, entityComponents) {
402
+ export function renderV1Entry(primitives, entityComponents, { sharedDir } = {}) {
366
403
  return [
404
+ // Before the barrel and before the entity components — both of which emit
405
+ // imports of the CREATOR's own simulation code, which is exactly the code
406
+ // that must not be evaluated on native transcendental math.
407
+ ...installDeterministicMath(sharedDir),
367
408
  renderBarrel(primitives),
368
409
  renderEntityComponents(entityComponents),
369
410
  "import LooopRoom from '/shared/ui/room/server.js';",
@@ -413,7 +454,7 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
413
454
  // against the game root, so `./overrides/...` means what it says and no
414
455
  // machine-specific absolute path can leak into the output.
415
456
  stdin: {
416
- contents: renderEntry(primitives, entityComponents),
457
+ contents: renderV1Entry(primitives, entityComponents, { sharedDir: engine.sharedDir }),
417
458
  resolveDir: projectDir,
418
459
  sourcefile: 'looop-room-server.js',
419
460
  loader: 'js',