@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.
- package/CHANGELOG.md +17 -0
- package/bin/looop.mjs +18 -0
- package/lib/agent-surface.mjs +6 -2
- package/lib/dev.mjs +7 -0
- package/lib/replay-cmd.mjs +155 -0
- package/lib/replay-export.mjs +515 -0
- package/lib/replay-store.mjs +440 -0
- package/lib/room-server.mjs +47 -6
- package/lib/static-server.mjs +369 -2
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/lib/room-server.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
457
|
+
contents: renderV1Entry(primitives, entityComponents, { sharedDir: engine.sharedDir }),
|
|
417
458
|
resolveDir: projectDir,
|
|
418
459
|
sourcefile: 'looop-room-server.js',
|
|
419
460
|
loader: 'js',
|