@looop-games/cli 0.1.35 → 0.1.37

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 CHANGED
@@ -14,6 +14,25 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.37] - 2026-09-06
18
+
19
+ - Windows: stopping a development or smoke stack terminates its worker descendants, releasing their ports and temporary folders.
20
+
21
+ - Windows: selective test runs recognize short and long names for the same project folder, and replay export accepts destinations inside the game folder.
22
+
23
+ - Test submissions with `looop test --since <base>`: ordinary checks plus explicitly triggered extended tests in `looop.tests.json`; `--list` explains selection and `--all` runs an explicit audit. Smoke stacks disable reload watchers, and module serving avoids repeated filesystem lookups within each dependency walk.
24
+
25
+ ## [0.1.36] - 2026-09-04
26
+
27
+ ### Added
28
+
29
+ - **`looop dev` keeps the sessions you play.** As you play, the room hands the dev server each slice of the recording and it lands under your game's `.looop/replays/`. Watch one back by adding `?replay=<id>` to the game's URL — the id is printed to the console when the session starts. Nothing leaves your machine, and nothing is kept when the room is not a dev room.
30
+ - **`?replay=last`** — the newest recording, resolved by the dev server, so watching the session you just played does not start with copying an id.
31
+ - **Old recordings are swept, so a game folder stops growing forever.** When `looop dev` starts it keeps the newest **20 recordings** and up to **200 MB** per game, whichever runs out first — roughly an hour of play for a busy game — and removes the oldest past that, saying on the way up how many went. To protect one, hit **keep** on its row in the toolbox's Sessions panel; a kept recording is never swept. (It still counts toward the caps: keeping means "don't delete this", not "don't charge me for it".)
32
+ - **`looop replay export` — a session you played, as a table you can query.** A recording holds what you PRESSED, not what happened: no position, no speed, no score is in it anywhere. So this replays the session through your own game, reads the world at every tick, and writes it down as Parquet — one row per tick per entity, one column per declared field, plus a second table of every input you sent. Running the game costs a few seconds once; every question you ask afterwards is free, in SQL, with DuckDB or pandas or anything else that reads Parquet. Measured on a real 27-second session: 279,367 rows x 443 columns in 10 seconds, 3.9 MB on disk, and a query answered in 0.02 s. Numbers come back bit-for-bit as the simulation held them. `npx looop replay export --help` for the schema, or `shared/docs/replay-export.md`.
33
+ - **`looop replay export` tells you when the table will not be the session you played — before it spends the time building it.** A recording holds what you pressed, not what happened, so the numbers come from running your current code on old inputs. The export now compares the recording against what you are running and says up front if the engine version or the shape of your game has moved since. While it replays it also reports how many keyframes put playback back onto the recorded state, and the divergence warning no longer claims that everything after a divergence is worthless — a drift is bounded to the stretch before the next keyframe, and saying otherwise had people binning tables that were mostly fine.
34
+ - The dev server answers the toolbox's Sessions panel — listing this game's recordings, deleting one, and pinning one. Only ever on the dev server, and deleting or pinning a recording works only from a page this dev server itself served. `looop dev` listens on every network interface so your phone can play, which means everything else on that wifi can reach it too — on a cafe or coworking network that is not a set of machines you control, and nothing there can now remove one of your sessions.
35
+
17
36
  ## [0.1.35] - 2026-09-02
18
37
 
19
38
  ### Added
package/bin/looop.mjs CHANGED
@@ -8,6 +8,7 @@ import { lane } from '../lib/lane.mjs';
8
8
  import { login, whoami } from '../lib/login.mjs';
9
9
  import { publish } from '../lib/publish.mjs';
10
10
  import { create } from '../lib/create.mjs';
11
+ import { parseTestArgs } from '../lib/test-policy.mjs';
11
12
  import { testCmd } from '../lib/test-cmd.mjs';
12
13
  import { lintCmd } from '../lib/lint.mjs';
13
14
  import { update } from '../lib/update.mjs';
@@ -30,12 +31,15 @@ Usage:
30
31
  looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
31
32
  looop assets [<id>] Browse and drive your models, sounds and assets on their own
32
33
  looop lane <name> Open an isolated copy of the game to experiment in, safely
33
- looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
34
+ looop test [aspect] Run targeted checks (no aspect: conservative full run)
35
+ looop test --since <base> Submission: ordinary + triggered extended checks
36
+ looop test --all Explicit full audit; add --list to preview selection
34
37
  looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
35
38
  looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
36
39
  looop update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
37
40
  looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
38
41
  looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
42
+ looop replay export [<id>] Turn a recorded session into a table you can query (Parquet)
39
43
  looop feedback Send reports + replies under notes/feedback/; pull outcomes back in
40
44
  looop login Authenticate this machine as your Looop player account
41
45
  looop logout Forget the stored token
@@ -89,9 +93,7 @@ try {
89
93
  await create({ name: rest.find((a) => !a.startsWith('--')) });
90
94
  break;
91
95
  case 'test': {
92
- // Positional args scope the run: `looop test charge` runs only files whose
93
- // path contains "charge". Flags are left for future options (e.g. --changed).
94
- const { ok } = await testCmd({ patterns: rest.filter((a) => !a.startsWith('--')) });
96
+ const { ok } = await testCmd(parseTestArgs(rest));
95
97
  process.exit(ok ? 0 : 1);
96
98
  }
97
99
  case 'lint': {
@@ -124,6 +126,23 @@ try {
124
126
  await bakeModel(glb);
125
127
  break;
126
128
  }
129
+ case 'replay': {
130
+ // `replay` is the accessor for a recorded session; `export` is its first
131
+ // verb. Watching one back needs no command at all — it is `?replay=<id>`
132
+ // on the game's URL, or the toolbox's Sessions panel.
133
+ const { REPLAY_HELP, replayExport, parseReplayArgs } = await import('../lib/replay-cmd.mjs');
134
+ // Parsed in the lib so the argv split is under test — a flag's value must
135
+ // never slide into the session slot, and --help must be answered before
136
+ // anything is read as an id.
137
+ const { verb, id, out, help } = parseReplayArgs(rest);
138
+ if (help) {
139
+ console.log(REPLAY_HELP);
140
+ break;
141
+ }
142
+ if (verb !== 'export') throw new Error(`Unknown replay command: ${verb} — try: looop replay export`);
143
+ await replayExport({ stream: id, out });
144
+ break;
145
+ }
127
146
  case 'publish':
128
147
  await publish({ slug: flag('slug') });
129
148
  break;
package/lib/dev.mjs CHANGED
@@ -8,12 +8,14 @@
8
8
  // Multiplayer runs from minute one — the platform is not optional (the Next
9
9
  // analogy deliberately breaks there). NO_MP=1 opts out for purely-SP work.
10
10
  import { spawn } from 'node:child_process';
11
+ import { terminateChildTree } from './process-tree.mjs';
11
12
  import { existsSync, readFileSync } from 'node:fs';
12
13
  import { tmpdir } from 'node:os';
13
14
  import { join, dirname, basename } from 'node:path';
14
15
  import { findProject } from './project.mjs';
15
16
  import { ensureEngine } from './engine.mjs';
16
17
  import { createStaticServer } from './static-server.mjs';
18
+ import { pruneOnStart } from './replay-store.mjs';
17
19
  import { createLlmShim, DEFAULT_API_BASE } from './llm-shim.mjs';
18
20
  import { getToken, getApiBase } from './config.mjs';
19
21
  import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
@@ -49,7 +51,7 @@ export function partykitBin() {
49
51
  // and publish disagreed and a creator's primitive ran in production but not on
50
52
  // their own machine. One question, one answer, both commands.
51
53
 
52
- export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', log = console.log } = {}) {
54
+ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, log = console.log } = {}) {
53
55
  const project = findProject(cwd);
54
56
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
55
57
  // the platform's login-gated registry (no-op when the pinned version is in).
@@ -62,6 +64,11 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
62
64
  const { ensureBaked } = await import('./model-bake.mjs');
63
65
  await ensureBaked({ dir: project.dir, log });
64
66
 
67
+ // Recordings accumulate on the creator's own disk — every session played
68
+ // under `looop dev` writes one — so the oldest go here, before anything can
69
+ // be watching one. The caps and the keep marker live in replay-store.mjs.
70
+ pruneOnStart({ projectDir: project.dir, log });
71
+
65
72
  // Which ports, and may we take them? An explicit --port is obeyed as given
66
73
  // (takeover included). With no flag we step around anyone else's stack —
67
74
  // another lane, another game, an unrelated app — and only ever reclaim our
@@ -106,19 +113,8 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
106
113
  const ownServer = roomServerCwd !== engine.roomServerDir;
107
114
 
108
115
  const stop = () => {
109
- for (const c of children) {
110
- // partykit is npx-style: node wrapper forks workerd. Kill the tree.
111
- try {
112
- process.kill(-c.pid, 'SIGTERM');
113
- } catch {
114
- try {
115
- c.kill('SIGTERM');
116
- } catch {
117
- /* gone */
118
- }
119
- }
120
- }
121
116
  for (const s of servers) s.close();
117
+ for (const c of children) terminateChildTree(c);
122
118
  };
123
119
 
124
120
  // ─── static ───
@@ -153,14 +149,15 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
153
149
  { url: '/shared/', dir: overridesDir },
154
150
  { url: '/shared/', dir: engine.sharedDir },
155
151
  ],
156
- watchDirs: [project.dir, engine.sharedDir],
152
+ watchDirs: watch ? [project.dir, engine.sharedDir] : [],
153
+ injectReload: watch,
157
154
  // Present only for a framework-v2 game — injects the looop import map + the
158
155
  // lowered skeleton into the entry HTML so startGame() boots in the browser.
159
156
  skeleton: roomSkeleton,
160
157
  });
161
158
  await staticServer.listen(ports.static);
162
159
  servers.push(staticServer);
163
- log(`→ static :${ports.static} (serving ${project.dir}, engine ${engine.version}, auto-reload on)`);
160
+ log(`→ static :${ports.static} (serving ${project.dir}, engine ${engine.version}, auto-reload ${watch ? 'on' : 'off'})`);
164
161
 
165
162
  // ─── multiplayer (partykit on the bundle's room code) ───
166
163
  // A single-player framework-v2 game (no config({ multiplayer: true })) runs its
@@ -181,7 +178,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
181
178
  // partykit reloader below does this as a side effect; single-player needs a
182
179
  // stripped-down version with no child to respawn. (NO_MP on a real multiplayer
183
180
  // game has no client to serve a skeleton to, so it needs none.)
184
- if (singlePlayer) {
181
+ if (singlePlayer && watch) {
185
182
  const skelWatcher = createFileWatcher({
186
183
  files: roomInputs,
187
184
  dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
@@ -220,28 +217,27 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
220
217
  return pk;
221
218
  };
222
219
  const killMp = async (pk) => {
220
+ if (pk.pid == null) return;
221
+ // Keep ownership when termination fails so stop() can retry the tree.
222
+ // Child exit callbacks cannot run between these synchronous statements.
223
+ terminateChildTree(pk);
223
224
  const idx = children.indexOf(pk);
224
225
  if (idx !== -1) children.splice(idx, 1);
225
- if (pk.pid == null) return; // never started — nothing to reap, and 'exit' will never fire
226
226
  const exited = new Promise((resolveExit) => {
227
227
  if (pk.exitCode !== null || pk.signalCode !== null) return resolveExit();
228
- pk.once('exit', resolveExit);
229
- pk.once('error', resolveExit);
228
+ const onExit = () => {
229
+ pk.removeListener('exit', onExit);
230
+ pk.removeListener('error', onExit);
231
+ resolveExit();
232
+ };
233
+ pk.once('exit', onExit);
234
+ pk.once('error', onExit);
230
235
  });
231
- try {
232
- process.kill(-pk.pid, 'SIGTERM');
233
- } catch {
234
- try {
235
- pk.kill('SIGTERM');
236
- } catch {
237
- /* gone */
238
- }
239
- }
240
236
  // partykit is a node wrapper forking workerd; if the tree ignores TERM,
241
237
  // escalate — the port must be free before the replacement binds it.
242
238
  const hardKill = setTimeout(() => {
243
239
  try {
244
- process.kill(-pk.pid, 'SIGKILL');
240
+ terminateChildTree(pk, 'SIGKILL');
245
241
  } catch {
246
242
  /* gone */
247
243
  }
@@ -309,18 +305,18 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
309
305
  },
310
306
  });
311
307
  reloader.attach(roomServerCwd, pk);
312
- roomWatcher = createFileWatcher({
308
+ roomWatcher = watch ? createFileWatcher({
313
309
  files: roomInputs,
314
310
  dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
315
311
  onChange: reloader.onChange,
316
- });
312
+ }) : null;
317
313
  // stop() (e.g. `looop test` tearing down its stack) must also stop the
318
314
  // reloader: a rebuild in flight would otherwise spawn a replacement room
319
315
  // child AFTER the children list was killed — a leaked detached process.
320
316
  servers.push({
321
317
  close: () => {
322
318
  reloader.stop();
323
- roomWatcher.close();
319
+ roomWatcher?.close();
324
320
  },
325
321
  });
326
322
  }
package/lib/inject.mjs CHANGED
@@ -137,7 +137,7 @@ function fileInfo(path) {
137
137
  return null;
138
138
  }
139
139
  const hit = parseCache.get(path);
140
- if (hit && hit.mtimeMs === st.mtimeMs) return hit;
140
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit;
141
141
  const specs = [];
142
142
  if (/\.(js|mjs)$/.test(path)) {
143
143
  let text = null;
@@ -152,35 +152,48 @@ function fileInfo(path) {
152
152
  }
153
153
  }
154
154
  }
155
- const info = { mtimeMs: st.mtimeMs, specs };
155
+ const info = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, specs };
156
156
  parseCache.set(path, info);
157
157
  return info;
158
158
  }
159
159
 
160
- // Walk in URL space: each node is a URL path, mapped to bytes through the
161
- // server's mount table. `memo` (shared across one rewrite pass) short-circuits
162
- // only entry nodes a spec repeated in one body — not intermediates.
163
- function closureStampUrl(urlPath, resolveUrl, memo) {
164
- if (memo?.has(urlPath)) return memo.get(urlPath);
160
+ // A rewrite shares resolved nodes across all import closures. Each URL is
161
+ // resolved/stat'ed once even when several roots reach it. The cache belongs to
162
+ // this synchronous rewrite only: the next request observes edits, deletions and
163
+ // newly-created overrides immediately, without waiting for the reload watcher.
164
+ function rewriteContext(resolveUrl) {
165
+ const nodes = new Map();
166
+ return {
167
+ stamps: new Map(),
168
+ node(url) {
169
+ if (!nodes.has(url)) {
170
+ const path = resolveUrl(url);
171
+ nodes.set(url, path ? fileInfo(path) : null);
172
+ }
173
+ return nodes.get(url);
174
+ },
175
+ };
176
+ }
177
+
178
+ function closureStampUrl(urlPath, context) {
179
+ if (context.stamps.has(urlPath)) return context.stamps.get(urlPath);
165
180
  let max = 0;
166
181
  const seen = new Set();
167
182
  const stack = [urlPath];
168
183
  while (stack.length) {
169
- const u = stack.pop();
170
- if (seen.has(u)) continue;
171
- seen.add(u);
172
- const fs = resolveUrl(u);
173
- if (!fs) continue;
174
- const info = fileInfo(fs);
184
+ const url = stack.pop();
185
+ if (seen.has(url)) continue;
186
+ seen.add(url);
187
+ const info = context.node(url);
175
188
  if (!info) continue;
176
- if (info.mtimeMs > max) max = info.mtimeMs;
189
+ max = Math.max(max, info.mtimeMs);
177
190
  for (const spec of info.specs) {
178
- const child = resolveSpecToUrl(spec, u);
191
+ const child = resolveSpecToUrl(spec, url);
179
192
  if (child) stack.push(child);
180
193
  }
181
194
  }
182
195
  const stamp = max ? Math.floor(max) : null;
183
- memo?.set(urlPath, stamp);
196
+ context.stamps.set(urlPath, stamp);
184
197
  return stamp;
185
198
  }
186
199
 
@@ -192,28 +205,28 @@ function closureStampUrl(urlPath, resolveUrl, memo) {
192
205
  // disk-space fallback. Resolving a relative spec beside the importer on disk
193
206
  // stamps the wrong file whenever a mount shadows it (the overrides mechanism),
194
207
  // which silently re-opens the staleness hole. No serving context → no stamp.
195
- function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, memo }) {
208
+ function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, context }) {
196
209
  if (!/\.(js|mjs|json)$/.test(relPath)) return null;
197
210
  if (baseUrl == null || !resolveUrl) return null;
198
211
  const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
199
212
  if (!urlTarget) return null;
200
- if (!resolveUrl(urlTarget)) return null; // nothing serves it — leave the spec alone
201
- const stamp = closureStampUrl(urlTarget, resolveUrl, memo);
213
+ if (!context.node(urlTarget)) return null;
214
+ const stamp = closureStampUrl(urlTarget, context);
202
215
  return stamp === null ? null : `${relPath}?v=${stamp}`;
203
216
  }
204
217
 
205
218
  export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
206
- const memo = new Map();
219
+ const context = rewriteContext(resolveUrl);
207
220
  return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
208
- const v = versionedPath(path, { baseUrl, resolveUrl, memo });
221
+ const v = versionedPath(path, { baseUrl, resolveUrl, context });
209
222
  return `${prefix}${quote}${v ?? path}${quote}`;
210
223
  });
211
224
  }
212
225
 
213
226
  export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
214
- const memo = new Map();
227
+ const context = rewriteContext(resolveUrl);
215
228
  return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
216
- const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, memo });
229
+ const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context });
217
230
  return `${prefix}${v ?? path}${suffix}`;
218
231
  });
219
232
  }
@@ -0,0 +1,30 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { win32 } from 'node:path';
3
+
4
+ // PartyKit owns worker descendants. Windows has no Unix process groups, so
5
+ // killing its wrapper alone can leave a worker holding ports and directories.
6
+ export function terminateChildTree(child, signal = 'SIGTERM', {
7
+ platform = process.platform, exec = execFileSync, kill = process.kill,
8
+ } = {}) {
9
+ if (child.pid == null) return;
10
+ if (platform === 'win32') {
11
+ if (child.exitCode != null || child.signalCode != null) return;
12
+ const taskkill = win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe');
13
+ try {
14
+ exec(taskkill, ['/PID', String(child.pid), '/T', '/F'], {
15
+ stdio: 'pipe', timeout: 5000, windowsHide: true,
16
+ });
17
+ } catch (error) {
18
+ // An already-exited child is harmless; a live tree that could not be
19
+ // terminated must fail shutdown rather than silently leak workers.
20
+ try { kill(child.pid, 0); } catch (probe) {
21
+ if (probe.code === 'ESRCH') return;
22
+ }
23
+ throw error;
24
+ }
25
+ return;
26
+ }
27
+ try { kill(-child.pid, signal); } catch {
28
+ child.kill(signal);
29
+ }
30
+ }
@@ -0,0 +1,155 @@
1
+ // `looop replay export` — the recorded session, on disk as a table.
2
+ //
3
+ // This is the CLI's half: find the game, resolve the engine it is pinned to,
4
+ // hand both to the exporter, and then say enough about what came out that the
5
+ // next step is obvious. The table's shape and every decision about it live in
6
+ // replay-export.mjs; nothing here knows what a column is.
7
+ import { join, relative, resolve, sep } from 'node:path';
8
+ import { statSync } from 'node:fs';
9
+ import { findProject } from './project.mjs';
10
+ import { streamDir } from './replay-store.mjs';
11
+ import { ensureEngine } from './engine.mjs';
12
+ import { exportSession } from './replay-export.mjs';
13
+
14
+ export const EXPORTS_DIR = ['.looop', 'exports'];
15
+
16
+ export const REPLAY_HELP = `looop replay — work with the sessions this game has recorded
17
+
18
+ Usage:
19
+ looop replay export [<session>] [--out <dir>]
20
+
21
+ <session> Which recording. Defaults to the most recent one.
22
+ --out Where the tables go. Defaults to .looop/exports/
23
+
24
+ Writes two Parquet tables:
25
+
26
+ <session>.state.parquet one row per tick per entity, every declared field
27
+ a column (def/id/owner/tick lead)
28
+ <session>.inputs.parquet one row per input the player sent
29
+
30
+ A recording holds what was PRESSED, not what happened — so the export replays
31
+ the session through your game to work out where everything was, then writes it
32
+ down. That costs a few seconds once; every question you ask of the table
33
+ afterwards is free.
34
+
35
+ duckdb -c "SELECT def, count(*) FROM '.looop/exports/<session>.state.parquet' GROUP BY def"
36
+ `;
37
+
38
+ /**
39
+ * Split `looop replay …`'s arguments into the verb, the session, and the flags.
40
+ *
41
+ * A flag's VALUE is a positional-looking word, so dropping only the tokens that
42
+ * start with `--` leaves `tables` from `--out tables` sitting in the session
43
+ * slot — and the export then reports it cannot find a recording the creator
44
+ * never named. The same trap is why `--help` has to be answered before any of
45
+ * this is used as an id.
46
+ */
47
+ export function parseReplayArgs(argv = []) {
48
+ const flag = (name) => {
49
+ const i = argv.indexOf(`--${name}`);
50
+ return i === -1 ? undefined : argv[i + 1];
51
+ };
52
+ const positional = argv.filter((a, i) => !a.startsWith('-') && !argv[i - 1]?.startsWith('--'));
53
+ const [verb, id] = positional;
54
+ return {
55
+ verb,
56
+ id,
57
+ out: flag('out'),
58
+ help: !verb || verb === 'help' || argv.includes('--help') || argv.includes('-h'),
59
+ };
60
+ }
61
+
62
+ const mb = (file) => `${(statSync(file).size / 1024 / 1024).toFixed(1)} MB`;
63
+
64
+ /**
65
+ * @param {object} opts
66
+ * @param {string} [opts.cwd]
67
+ * @param {string} [opts.stream] - which recording; defaults to the most recent
68
+ * @param {string} [opts.out] - where the tables go
69
+ * @param {(msg: string) => void} [opts.log]
70
+ * @param {(dir: string, o: object) => Promise<object>} [opts.ensureEngineImpl]
71
+ * @param {(o: object) => Promise<object>} [opts.exportImpl]
72
+ */
73
+ export async function replayExport({
74
+ cwd = process.cwd(), stream, out, log = console.log,
75
+ ensureEngineImpl = ensureEngine, exportImpl = exportSession,
76
+ } = {}) {
77
+ const project = findProject(cwd);
78
+ // The id is about to become a path to read from AND a filename to write. Every
79
+ // other door into the store runs one through `streamDir`, which refuses rather
80
+ // than sanitizes; this one is no different for being typed by hand.
81
+ if (stream) streamDir(project.dir, stream);
82
+ const outDir = out ? resolve(project.dir, out) : join(project.dir, ...EXPORTS_DIR);
83
+ // The tables are tens of megabytes and this creates directories to hold them.
84
+ // A path that climbs out of the game folder is a typo, not an intention.
85
+ if (outDir !== project.dir && !outDir.startsWith(`${resolve(project.dir)}${sep}`)) {
86
+ throw new Error(`--out must be inside the game folder, and ${out} is not`);
87
+ }
88
+ const engine = await ensureEngineImpl(project.dir, { log });
89
+
90
+ const started = Date.now();
91
+ const res = await exportImpl({
92
+ dir: project.dir, stream, outDir, sharedDir: engine.sharedDir,
93
+ engineVersion: engine.version, log,
94
+ });
95
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
96
+
97
+ const rel = (f) => relative(project.dir, f);
98
+ log(`exported ${res.stream} in ${secs}s`);
99
+ log(` ${rel(res.stateFile)} ${res.rows.toLocaleString()} rows x ${res.columns} columns (${mb(res.stateFile)})`);
100
+ log(` ${rel(res.inputFile)} ${res.inputs.toLocaleString()} inputs (${mb(res.inputFile)})`);
101
+ // Three different ways the table can describe a world that is not the one
102
+ // that was played. None of them is buried: an export that looks clean and is
103
+ // not is worse than one that failed, because the answers it gives are wrong
104
+ // and confident.
105
+ if (res.divergences) {
106
+ // What bounds the damage is the keyframe: the stream carries the room's
107
+ // complete true state roughly every 30 s, and playback restores it, so a
108
+ // drift owns one keyframe interval rather than everything downstream.
109
+ // Saying otherwise sends a reader to bin a table that was mostly fine.
110
+ //
111
+ // Only a re-anchor AFTER the last divergence says that last drift was
112
+ // undone; earlier ones belong to earlier drifts. And a re-anchor is recorded
113
+ // only when the restore actually changed the world, so zero of them does not
114
+ // mean nothing corrected it — a drift that came right again on its own also
115
+ // records none. Claiming "the drift ran to the end of the session" from a
116
+ // zero count states something this code cannot know.
117
+ const corrected = res.lastReanchorTick != null
118
+ && res.lastDivergenceTick != null
119
+ && res.lastReanchorTick > res.lastDivergenceTick;
120
+ const putBack = corrected
121
+ ? ` Playback was put back onto the recorded state at tick ${res.lastReanchorTick}, after the last of them.`
122
+ : ' Nothing is recorded as putting it back after the last one, so treat the rows from there to the end as this run\'s.';
123
+ log(` ⚠ the replay parted from the recording ${res.divergences} time(s) — from each of those `
124
+ + 'ticks until the next keyframe, the rows are this run\'s world rather than the recorded one.'
125
+ + putBack
126
+ + ' The usual cause is that the game changed since the session was recorded; a replay runs '
127
+ + 'the code as it is now.');
128
+ }
129
+ if (res.gaps) {
130
+ log(` ⚠ the recording is missing ${res.gaps} segment(s) — the inputs in those gaps were never `
131
+ + 'stored, so the world after each one is not the session that was played');
132
+ }
133
+ if (res.unsupported) {
134
+ log(` ⚠ ${res.unsupported} recorded event(s) cannot be reproduced by a replay (a live tune, say) `
135
+ + '— the world after each one is not the session that was played');
136
+ }
137
+ // Repeated from before the replay. That is the right place to say it first —
138
+ // the answer is known and the replay is the slow part — but a big export puts
139
+ // screens of output in between, so the line deciding whether to trust the
140
+ // table would be the one line scrolled off. Costs nothing; both values are
141
+ // already on the result.
142
+ if (res.recordedEngine && res.runningEngine && res.recordedEngine !== res.runningEngine) {
143
+ log(` ⚠ recorded by engine ${res.recordedEngine}, replayed on ${res.runningEngine}`);
144
+ }
145
+ if (res.codeChanged) {
146
+ log(' ⚠ the game has changed since this session was recorded');
147
+ }
148
+ if (res.undoubleable) {
149
+ log(` note: ${res.undoubleable} numeric cell(s) held NaN or ±Infinity, which Parquet has no double `
150
+ + 'for, and read as null — the same as a field the entity kind does not declare');
151
+ }
152
+ log('');
153
+ log(`Ask it something: duckdb -c "SELECT def, count(*) FROM '${rel(res.stateFile)}' GROUP BY def"`);
154
+ return res;
155
+ }