@joystack/cli 0.0.0-stage → 0.1.43

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 (56) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +241 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +246 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +299 -0
  56. package/package.json +27 -4
@@ -0,0 +1,426 @@
1
+ // `joystack replay list`, `joystack replay export` and `joystack replay profile` — the
2
+ // sessions a game has recorded, and each one on disk as tables.
3
+ //
4
+ // This is the CLI's half: find the game, resolve the engine it is pinned to,
5
+ // hand both to the exporter or the profiler, and then say enough about what
6
+ // came out that the next step is obvious. The tables' shape and every decision
7
+ // about them live in replay-export.mjs and replay-profile.mjs; nothing here
8
+ // knows what a column is.
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+ import { realpathSync, statSync } from 'node:fs';
11
+ import { findProject } from './project.mjs';
12
+ import { streamDir } from './replay-store.mjs';
13
+ import { isGameFilePath, gitIgnored } from './game-files.mjs';
14
+ import { listSessions } from './replay-list.mjs';
15
+ import { ensureEngine } from './engine.mjs';
16
+ import { exportSession } from './replay-export.mjs';
17
+ import { parseTickRange, profileSession } from './replay-profile.mjs';
18
+ import { stateDir } from './legacy-names.mjs';
19
+
20
+ export const EXPORTS_DIR = ['.joystack', 'exports'];
21
+ const PROFILE_ENOUGH_SAMPLES = 300;
22
+
23
+ export const REPLAY_HELP = `joystack replay — work with the sessions this game has recorded
24
+
25
+ Usage:
26
+ joystack replay list [--json]
27
+ joystack replay export [<session>] [--out <dir>]
28
+ joystack replay profile [<session>] --ticks <a-b> [--repeat <n>] [--out <dir>]
29
+
30
+ <session> Which recording. Defaults to the most recent one.
31
+ --out Where the tables go. Defaults to .joystack/exports/. Anywhere outside
32
+ the game works; inside it, only a folder that is not part of the
33
+ game (under .joystack/, or one your .gitignore excludes).
34
+ --json \`list\` prints its facts as JSON.
35
+ --ticks Which ticks \`profile\` re-runs: 1195-1228, or one tick.
36
+ --repeat How many times \`profile\` runs those ticks, summing the samples.
37
+
38
+ \`list\` says what each recorded session is, without replaying it: when and for
39
+ how long, how many players, the device each measuring browser ran on (and
40
+ whether automation drove it), how many of its frames were late and where its
41
+ worst stutter in play was, and which code recorded it — the engine version,
42
+ the code fingerprint (the shape of the simulation), and the build id (the
43
+ exact game files, with the git commit they came from). A session whose build
44
+ is not the one in the folder now was played on other code. Sessions under a
45
+ second are left out; --json lists every one.
46
+
47
+ \`export\` writes the session as four Parquet tables on the same tick axis,
48
+ and a fifth that says what the state table's columns are:
49
+
50
+ <session>.state.parquet one row per tick per entity, every declared field
51
+ a column (tick/id/def/owner lead)
52
+ <session>.inputs.parquet one row per input a player sent
53
+ <session>.perf.parquet what it cost on each device that played: one row
54
+ per frame, per second, per kind per second and
55
+ per long frame (only when the session was measured)
56
+ <session>.info.parquet the session itself: its span, each player, each
57
+ browser's device, and the ticks where a table
58
+ cannot be taken at its word — not measured, or
59
+ replayed into a world nobody played
60
+ <session>.fields.parquet one row per state column and per channel: the
61
+ kind, the trait behind it, and its source file
62
+ and line (a field adds its name, type, default;
63
+ a channel, who sends on it)
64
+
65
+ A recording holds what was PRESSED, not what happened — so the export replays
66
+ the session through your game to work out where everything was, then writes it
67
+ down. That costs a while once; every question asked of the tables afterwards
68
+ is SQL.
69
+
70
+ \`profile\` re-runs the ticks you name under a CPU profiler and writes which
71
+ handlers, functions and lines spent the time: each tick's and channel
72
+ handler's share, each function credited to the handler and to the game
73
+ function it ran under, and each line samples landed on —
74
+ <session>.profile.<a>-<b>.parquet. Its shares are this machine's, never the
75
+ device's milliseconds — the perf table says what the ticks cost where they
76
+ were played; the profile says what ran in them.
77
+
78
+ Why a session stuttered, question by question, and the perf, info and profile
79
+ tables column by column:
80
+ node_modules/@joystack/engine/shared/docs/debugging.md
81
+ The state, inputs and fields tables, for measuring how someone played:
82
+ node_modules/@joystack/engine/shared/docs/replay-export.md
83
+
84
+ duckdb -c "SELECT def, count(*) FROM '.joystack/exports/<session>.state.parquet' GROUP BY def"
85
+ `;
86
+
87
+ /**
88
+ * Split `joystack replay …`'s arguments into the verb, the session, and the flags.
89
+ *
90
+ * A flag's VALUE is a positional-looking word, so dropping only the tokens that
91
+ * start with `--` leaves `tables` from `--out tables` sitting in the session
92
+ * slot — and the export then reports it cannot find a recording the creator
93
+ * never named. The same trap is why `--help` has to be answered before any of
94
+ * this is used as an id.
95
+ */
96
+ // The flags that take no value. A flag's value looks positional, so the filter
97
+ // below skips the token after a flag — which would swallow the session id that
98
+ // follows a value-less one. Every value-less flag has to be listed here.
99
+ const BOOLEAN_FLAGS = new Set(['--help', '--json']);
100
+
101
+ // Which commands read each flag. An unknown verb is left to its own error.
102
+ const KNOWN_VERBS = new Set(['list', 'export', 'profile']);
103
+ const FLAG_VERBS = { ticks: ['profile'], repeat: ['profile'], json: ['list'], out: ['export', 'profile'] };
104
+
105
+ export function parseReplayArgs(argv = []) {
106
+ // A flag's value is the next word, or follows `=` in the same one.
107
+ const flag = (name) => {
108
+ const joined = argv.find((a) => a.startsWith(`--${name}=`));
109
+ if (joined !== undefined) return joined.slice(name.length + 3);
110
+ const i = argv.indexOf(`--${name}`);
111
+ return i === -1 ? undefined : argv[i + 1];
112
+ };
113
+ const has = (name) => argv.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
114
+ const positional = argv.filter((a, i) => {
115
+ if (a.startsWith('-')) return false;
116
+ const before = argv[i - 1];
117
+ return !(before?.startsWith('--') && !before.includes('=') && !BOOLEAN_FLAGS.has(before));
118
+ });
119
+ const [verb, id] = positional;
120
+ const help = !verb || verb === 'help' || argv.includes('--help') || argv.includes('-h');
121
+ // Help is answered before any value is judged: `profile --ticks --help` asks
122
+ // how to write the ticks.
123
+ if (help) return { verb, id, help };
124
+ // A flag given to a command that does not read it is refused: ignored, it
125
+ // would look as though it had been obeyed.
126
+ for (const [name, verbs] of Object.entries(FLAG_VERBS)) {
127
+ if (has(name) && KNOWN_VERBS.has(verb) && !verbs.includes(verb)) {
128
+ throw new Error(`--${name} is for ${verbs.map((v) => `\`joystack replay ${v}\``).join(' and ')}, not \`${verb}\``);
129
+ }
130
+ }
131
+ return {
132
+ verb,
133
+ id,
134
+ out: flag('out'),
135
+ ticks: has('ticks') ? parseTickRange(flag('ticks')) : undefined,
136
+ json: argv.includes('--json'),
137
+ repeat: has('repeat') ? parseRepeat(flag('repeat')) : undefined,
138
+ help,
139
+ };
140
+ }
141
+
142
+ // `--repeat 5`: how many times `profile` runs the ticks.
143
+ function parseRepeat(text) {
144
+ const n = /^\s*\d+\s*$/.test(String(text ?? '')) ? Number(text) : NaN;
145
+ if (!Number.isSafeInteger(n) || n < 1 || n > 1000) {
146
+ throw new Error(`--repeat takes how many times to run the ticks, like 5, and ${JSON.stringify(String(text ?? ''))} is not one`);
147
+ }
148
+ return n;
149
+ }
150
+
151
+ // A table's path as the creator would type it: relative inside the game
152
+ // folder, where commands run, and in full anywhere else.
153
+ function shownPath(dir, file) {
154
+ const rel = relative(dir, file);
155
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : file;
156
+ }
157
+
158
+ // A path with every link resolved: the deepest part that exists is resolved
159
+ // on disk, and the rest, not yet created, is appended to it.
160
+ function realPathOf(p) {
161
+ const rest = [];
162
+ let at = resolve(p);
163
+ for (;;) {
164
+ try {
165
+ return join(realpathSync(at), ...rest.reverse());
166
+ } catch {
167
+ const up = dirname(at);
168
+ if (up === at) return resolve(p);
169
+ rest.push(basename(at));
170
+ at = up;
171
+ }
172
+ }
173
+ }
174
+
175
+ // Where the tables go. Anywhere outside the game folder is the creator's
176
+ // business. Inside it, only where they do not become game files: a plain
177
+ // folder in the game is uploaded by `joystack publish` and counted in the game's
178
+ // build id, and tens of megabytes of tables are neither. `.joystack/`, any
179
+ // dot-folder and anything the game's .gitignore excludes are not game files.
180
+ function outDirFor(projectDir, out) {
181
+ const outDir = out ? resolve(projectDir, out) : join(stateDir(projectDir), 'exports');
182
+ // Compared as the disk sees them: through any link, and without case where
183
+ // the disk ignores it, so a second spelling of a folder in the game is
184
+ // still in the game.
185
+ const root = realPathOf(projectDir);
186
+ const target = realPathOf(outDir);
187
+ const fold = (p) => (process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p);
188
+ if (fold(target) === fold(root) || fold(target).startsWith(fold(`${root}${sep}`))) {
189
+ const inner = target.slice(root.length).split(sep).filter(Boolean);
190
+ const probe = [...inner, 'x.parquet'].join('/');
191
+ if (isGameFilePath(probe) && !gitIgnored(root, [probe]).has(probe)) {
192
+ throw new Error(`--out ${out ?? ''}: tables written there would become part of your game — published with it, `
193
+ + "and counted in its build id. Use a folder under .joystack/, one the .gitignore of your git repository excludes, or one outside the game.");
194
+ }
195
+ }
196
+ return outDir;
197
+ }
198
+
199
+ // What reaches the terminal from a replay includes the replayer's own lines,
200
+ // which quote the recording (an event's kind, a tuned key), and a recording is
201
+ // not first-party text: an escape sequence in one would repaint the screen of
202
+ // whoever runs the command. Every line is printed without control characters.
203
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
204
+ const clean = (log) => (msg) => log(String(msg).replace(CONTROL, ''));
205
+
206
+ // How many times a replay drifted and over which ticks, as a sentence: one
207
+ // drift is one range of ticks, however many checksums failed inside it.
208
+ // `drifts` comes from the info table's `diverged` rows; without it, only the
209
+ // count of failed checks is known.
210
+ function driftSummary({ drifts = [], divergences = 0 }) {
211
+ const n = drifts.length || divergences;
212
+ const times = n === 1 ? 'once' : n === 2 ? 'twice' : `${n} times`;
213
+ const plural = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
214
+ const ranges = drifts.slice(0, 3).map((d) => {
215
+ const how = d.via === 'keyframe' ? 'no checksum ran into it; a keyframe put it back'
216
+ : Number.isSafeInteger(d.checks) ? plural(d.checks, 'failed checksum') : null;
217
+ return `ticks ${d.from}–${d.to}${how ? ` (${how})` : ''}`;
218
+ });
219
+ if (drifts.length > 3) ranges.push(`and ${drifts.length - 3} more`);
220
+ return `the replay drifted from the recording ${times}${ranges.length ? `: ${ranges.join(', ')}` : ''}`;
221
+ }
222
+
223
+ const mb = (file) => `${(statSync(file).size / 1024 / 1024).toFixed(1)} MB`;
224
+
225
+ /**
226
+ * @param {object} opts
227
+ * @param {string} [opts.cwd]
228
+ * @param {string} [opts.stream] - which recording; defaults to the most recent
229
+ * @param {string} [opts.out] - where the tables go
230
+ * @param {(msg: string) => void} [opts.log]
231
+ * @param {(dir: string, o: object) => Promise<object>} [opts.ensureEngineImpl]
232
+ * @param {(o: object) => Promise<object>} [opts.exportImpl]
233
+ */
234
+ export async function replayExport({
235
+ cwd = process.cwd(), stream, out, log: rawLog = console.log,
236
+ ensureEngineImpl = ensureEngine, exportImpl = exportSession,
237
+ } = {}) {
238
+ const log = clean(rawLog);
239
+ const project = findProject(cwd);
240
+ // The id is about to become a path to read from AND a filename to write. Every
241
+ // other door into the store runs one through `streamDir`, which refuses rather
242
+ // than sanitizes; this one is no different for being typed by hand.
243
+ if (stream) streamDir(project.dir, stream);
244
+ const outDir = outDirFor(project.dir, out);
245
+ const engine = await ensureEngineImpl(project.dir, { log });
246
+
247
+ const started = Date.now();
248
+ const res = await exportImpl({
249
+ dir: project.dir, stream, outDir, sharedDir: engine.sharedDir,
250
+ engineVersion: engine.version, log,
251
+ });
252
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
253
+
254
+ const rel = (f) => shownPath(project.dir, f);
255
+ log(`exported ${res.stream} in ${secs}s`);
256
+ log(` ${rel(res.stateFile)} ${res.rows.toLocaleString()} rows x ${res.columns} columns (${mb(res.stateFile)})`);
257
+ log(` ${rel(res.inputFile)} ${res.inputs.toLocaleString()} inputs (${mb(res.inputFile)})`);
258
+ if (res.perfFile) {
259
+ log(` ${rel(res.perfFile)} ${res.perfRows.toLocaleString()} cost rows (${mb(res.perfFile)})`);
260
+ } else {
261
+ log(' (no performance track: this session was recorded with measuring switched off)');
262
+ }
263
+ if (res.infoFile) log(` ${rel(res.infoFile)} ${res.infoRows.toLocaleString()} session info rows`);
264
+ if (res.fieldsFile) log(` ${rel(res.fieldsFile)} ${res.fieldsRows.toLocaleString()} fields and channels, each with the trait and line behind it`);
265
+ // Three different ways the tables can describe a world that is not the one
266
+ // that was played. None of them is buried: an export that looks clean and is
267
+ // not is worse than one that failed, because the answers it gives are wrong
268
+ // and confident. The info table says exactly which ticks each covers; these
269
+ // lines only say that there are some, and where to look.
270
+ if (res.divergences) {
271
+ // The cause is named only as far as the export knows it. A drift of the
272
+ // same build is a determinism fault, and blaming an edit there sends the
273
+ // reader to look for one that does not exist.
274
+ let cause;
275
+ if (res.codeChanged) {
276
+ cause = 'The game has changed since the session was recorded, and a replay runs the code as it is now.';
277
+ } else if (res.buildChanged === true) {
278
+ cause = "The game's files have changed since the session was recorded, and a replay runs the code as it is now.";
279
+ } else if (res.buildChanged === false) {
280
+ cause = 'This is the same build that recorded it, so something in the game did not replay the same way twice.';
281
+ } else {
282
+ cause = "The simulation's shape has not changed since, but this recording has no build id, so an edit "
283
+ + 'inside a tick cannot be ruled out.';
284
+ }
285
+ log(` ⚠ ${driftSummary(res)}. In those ticks (the info table's \`diverged\` rows) the state is this `
286
+ + "run's world, not the one that was played. " + cause);
287
+ }
288
+ if (res.gaps) {
289
+ log(` ⚠ the recording is missing ${res.gaps} segment(s) — the inputs in them were never stored `
290
+ + "(the info table's `missing_segment` rows)");
291
+ }
292
+ if (res.unsupported) {
293
+ log(` ⚠ ${res.unsupported} recorded event(s) cannot be reproduced by a replay, a live tune say — `
294
+ + "the world after each one is not the one that was played (the info table's `unsupported` rows)");
295
+ }
296
+ // Repeated from before the replay. That is the right place to say it first —
297
+ // the answer is known and the replay is the slow part — but a big export puts
298
+ // screens of output in between, so the line deciding whether to trust the
299
+ // table would be the one line scrolled off. Costs nothing; both values are
300
+ // already on the result.
301
+ if (res.recordedEngine && res.runningEngine && res.recordedEngine !== res.runningEngine) {
302
+ log(` ⚠ recorded by engine ${res.recordedEngine}, replayed on ${res.runningEngine}`);
303
+ }
304
+ if (res.codeChanged) {
305
+ log(' ⚠ the game has changed since this session was recorded');
306
+ } else if (res.buildChanged === true) {
307
+ log(` ⚠ the game's files have changed since this session was recorded (build ${res.recordedBuild}, now ${res.runningBuild})`);
308
+ }
309
+ if (res.undoubleable) {
310
+ log(` note: ${res.undoubleable} numeric cell(s) held NaN or ±Infinity, which Parquet has no double `
311
+ + 'for, and read as null — the same as a field the entity kind does not declare');
312
+ }
313
+ log('');
314
+ log(`Ask it something: duckdb -c "SELECT def, count(*) FROM '${rel(res.stateFile)}' GROUP BY def"`);
315
+ return res;
316
+ }
317
+
318
+ /**
319
+ * `joystack replay profile` — which functions ran in a stretch of a recorded
320
+ * session, re-run here under a CPU profiler and written as a table.
321
+ *
322
+ * @param {object} opts
323
+ * @param {string} [opts.cwd]
324
+ * @param {string} [opts.stream] - which recording; defaults to the most recent
325
+ * @param {{from: number, to: number}} [opts.ticks] - the ticks to re-run
326
+ * @param {string} [opts.out] - where the table goes
327
+ * @param {(msg: string) => void} [opts.log]
328
+ */
329
+ export async function replayProfile({
330
+ cwd = process.cwd(), stream, ticks, out, repeat = 1, log: rawLog = console.log,
331
+ ensureEngineImpl = ensureEngine, profileImpl = profileSession,
332
+ } = {}) {
333
+ const log = clean(rawLog);
334
+ const project = findProject(cwd);
335
+ if (stream) streamDir(project.dir, stream);
336
+ const outDir = outDirFor(project.dir, out);
337
+ const engine = await ensureEngineImpl(project.dir, { log });
338
+ const res = await profileImpl({ dir: project.dir, stream, ticks, outDir, sharedDir: engine.sharedDir, repeat, log });
339
+ const rel = shownPath(project.dir, res.file);
340
+ const runs = res.repeat > 1 ? `, run ${res.repeat} times` : '';
341
+ log(`profiled ${res.stream} ticks ${res.fromTick}–${res.toTick}${runs}: ${res.samples.toLocaleString()} samples`);
342
+ log(` ${rel}`);
343
+ log(' shares of THIS machine\'s samples, not the device\'s time');
344
+ // Below a few hundred samples one sample is most of a percent, and a
345
+ // ranking of functions is mostly noise.
346
+ if (res.samples < PROFILE_ENOUGH_SAMPLES) {
347
+ log(` ⚠ only ${res.samples.toLocaleString()} samples: too few for the shares to mean much. Widen --ticks, `
348
+ + 'or run the same ticks again with --repeat 5.');
349
+ }
350
+ return res;
351
+ }
352
+
353
+ // A session's start as the person who played it would read it: local time,
354
+ // to the minute.
355
+ const localTime = (iso) => {
356
+ const d = new Date(iso ?? NaN);
357
+ if (Number.isNaN(d.getTime())) return 'unknown start';
358
+ const p = (n) => String(n).padStart(2, '0');
359
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
360
+ };
361
+
362
+ /**
363
+ * `joystack replay list` — every recorded session, newest first, with what it is:
364
+ * when and how long, who played, which browsers measured it on what device,
365
+ * and which engine, code and build recorded it. Nothing is replayed.
366
+ *
367
+ * @param {object} opts
368
+ * @param {string} [opts.cwd]
369
+ * @param {boolean} [opts.json] - print the facts as JSON instead
370
+ * @param {(msg: string) => void} [opts.log]
371
+ * @param {Function} [opts.listImpl] - seam for tests
372
+ */
373
+ export async function replayList({ cwd = process.cwd(), json = false, log: rawLog = console.log, listImpl = listSessions } = {}) {
374
+ const log = clean(rawLog);
375
+ const project = findProject(cwd);
376
+ const sessions = listImpl(project.dir);
377
+ if (json) {
378
+ log(JSON.stringify(sessions, null, 2));
379
+ return sessions;
380
+ }
381
+ if (!sessions.length) {
382
+ log('no recorded sessions in .joystack/replays/ — `joystack dev` records every session played while it runs');
383
+ return sessions;
384
+ }
385
+ // A session under a second is a page opened and closed — a reload, a test
386
+ // booting the game — and never the one a question is about. `--json` still
387
+ // lists it.
388
+ const shown = sessions.filter((s) => s.seconds == null || s.seconds >= 1);
389
+ const short = sessions.length - shown.length;
390
+ log(`${shown.length} recorded session(s), newest first:`);
391
+ let unstamped = false;
392
+ for (const s of shown) {
393
+ const who = `${s.players} player${s.players === 1 ? '' : 's'}`;
394
+ const len = s.seconds == null ? 'unknown length' : `${s.seconds} s`;
395
+ log('');
396
+ log(`${s.stream} ${localTime(s.startedAt)} ${len} ${who}${s.watchable ? '' : ' (not watchable yet)'}${s.kept ? ' kept' : ''}`);
397
+ if (!s.measured) log(' not measured');
398
+ for (const d of s.measured ? s.devices : []) {
399
+ const bits = [d.cores != null ? `${d.cores} cores` : null, d.touch ? 'touch' : null, d.viewport].filter(Boolean);
400
+ log(` measured on ${d.device ?? 'unknown device'}${bits.length ? ` (${bits.join(', ')})` : ''}`
401
+ + `${d.automated ? ', driven by automation, not a person' : ''}`);
402
+ if (d.frames && d.budgetMs == null) {
403
+ log(` ${d.frames.toLocaleString('en-US')} frames, late frames unknown: none has an interval to set a budget by`);
404
+ } else if (d.frames) {
405
+ const w = d.worstStutter;
406
+ const worst = w ? `; worst stutter in play: ${Math.round(w.overMs)} ms over, ${w.frames} frame${w.frames === 1 ? '' : 's'}`
407
+ + `, ${w.fromS === w.toS ? w.fromS : `${w.fromS}–${w.toS}`} s (ticks ${w.tickFrom}–${w.tickTo})` : '';
408
+ log(` ${d.late.toLocaleString('en-US')} of ${d.frames.toLocaleString('en-US')} frames late`
409
+ + `${d.budgetMs != null ? ` (budget ${d.budgetMs} ms)` : ''}${worst}`
410
+ + `${d.partial ? ' — over the start of its track only; the track is larger than the list reads' : ''}`);
411
+ }
412
+ }
413
+ if (!s.build) unstamped = true;
414
+ const build = s.build
415
+ ? `build ${s.build}${s.buildIsCurrent === true ? ' (the build in this folder now)' : s.buildIsCurrent === false ? ' (not the build in this folder now)' : ''}`
416
+ : 'build unknown';
417
+ const commit = s.commit ? ` · commit ${s.commit.slice(0, 10)}${s.uncommitted ? ' + uncommitted changes' : ''}` : '';
418
+ log(` engine ${s.engine ?? 'unknown'} · code ${s.code ?? 'unknown'} · ${build}${commit}`);
419
+ }
420
+ log('');
421
+ if (unstamped) log('build unknown: the session has no build stamp — recorded by an older joystack, or copied in without one.');
422
+ if (short) log(`${short} session${short === 1 ? '' : 's'} under a second not shown (--json lists them).`);
423
+ log('Late frames and stutters are counted as the debugging docs\' recorded-session method counts them.');
424
+ log('Export one: joystack replay export <session>');
425
+ return sessions;
426
+ }