@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,308 @@
1
+ // `joystack replay list` — what each recorded session is, read off its files
2
+ // without replaying anything: when it was played and for how long, who was in
3
+ // it, which browsers measured it and on what device, and which code recorded
4
+ // it (the engine version, the code fingerprint, and the build id with its
5
+ // commit). These are the facts that decide which session a question is about,
6
+ // and whether an export of it describes the game as it is now.
7
+ //
8
+ // And, for each browser that measured, how it went: how many frames were late
9
+ // and where its worst stutter in play was, read off the performance track by
10
+ // the rules the recorded-session method in the debugging docs defines — so a
11
+ // session can be chosen without exporting every candidate. Why a stutter
12
+ // happened stays a question for the exported tables.
13
+ import { lstatSync, readdirSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { listRecordings, streamDir } from './replay-store.mjs';
16
+ import { knownHeaderToken } from './replay-export.mjs';
17
+ import { gameBuild } from './build-id.mjs';
18
+
19
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
20
+ const clean = (v, max = 80) => (typeof v === 'string' ? v.replace(CONTROL, '').slice(0, max) : null);
21
+ const RECORDING_FILE = /^\d{6}\.json$/;
22
+ const TRACK_FILE = /^perf\.([a-z0-9][a-z0-9-]{0,63})\.(\d{6})\.json$/;
23
+
24
+ // Recording files can be copied in from anywhere, so only a plain file of a
25
+ // size a segment can have is read: a link to a device or a pipe would hang
26
+ // the list, and a huge file would stall it.
27
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
28
+ const readJson = (file) => {
29
+ try {
30
+ const st = lstatSync(file);
31
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) return null;
32
+ return JSON.parse(readFileSync(file, 'utf8'));
33
+ } catch {
34
+ return null;
35
+ }
36
+ };
37
+
38
+ // The largest time a Date can hold, in milliseconds either side of 1970.
39
+ const MAX_DATE_MS = 8.64e15;
40
+ const isoOf = (ms) => (Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS ? new Date(ms).toISOString() : null);
41
+ const tickOf = (v) => (Number.isSafeInteger(v) ? v : null);
42
+
43
+ /**
44
+ * A user agent as the device and browser a person would name: "iPhone, iOS
45
+ * 18.7, Safari 26.5", "Mac, Chrome 153". Unrecognised text is returned cut
46
+ * short and without control characters.
47
+ */
48
+ export function shortDevice(ua) {
49
+ if (typeof ua !== 'string' || !ua) return null;
50
+ let device = null;
51
+ let m;
52
+ if ((m = /\((iPhone|iPad|iPod)[^)]*?OS (\d+)[_.](\d+)/.exec(ua))) device = `${m[1]}, iOS ${m[2]}.${m[3]}`;
53
+ else if ((m = /Android (\d+(?:\.\d+)?)/.exec(ua))) device = `Android ${m[1]}`;
54
+ else if (/Macintosh/.test(ua)) device = 'Mac';
55
+ else if (/Windows/.test(ua)) device = 'Windows';
56
+ else if (/Linux|X11/.test(ua)) device = 'Linux';
57
+ let browser = null;
58
+ if ((m = /Edg\/(\d+)/.exec(ua))) browser = `Edge ${m[1]}`;
59
+ else if ((m = /Firefox\/(\d+)/.exec(ua))) browser = `Firefox ${m[1]}`;
60
+ else if ((m = /(?:Chrome|CriOS)\/(\d+)/.exec(ua))) browser = `Chrome ${m[1]}`;
61
+ else if ((m = /Version\/(\d+(?:\.\d+)?).*Safari/.exec(ua))) browser = `Safari ${m[1]}`;
62
+ if (!device && !browser) return clean(ua, 60);
63
+ return [device, browser].filter(Boolean).join(', ');
64
+ }
65
+
66
+ // A stutter is late frames less than this far apart, in the measuring
67
+ // browser's milliseconds.
68
+ const STUTTER_GAP_MS = 500;
69
+ // The first seconds after a page loads are always slow — shaders compile, the
70
+ // world spawns — and a player sees that once, as loading. The worst stutter
71
+ // reported is the worst one starting at or past this mark.
72
+ const LOADING_S = 2;
73
+
74
+ // The screen's refresh interval, read off the frames: the shortest interval
75
+ // the browser drew at repeatedly, taken as the mean of every interval within
76
+ // REFRESH_WINDOW_MS above it. "Repeatedly" is at least REFRESH_SHARE of the
77
+ // frames and never fewer than REFRESH_MIN_FRAMES, so a few stray callbacks
78
+ // close together do not set it, even in a recording of a few dozen frames
79
+ // where one frame in a hundred rounds up to one. The window is
80
+ // what keeps a clock of whole milliseconds (16, 17, 16, …) reading as one
81
+ // refresh. A device that is slow for the whole session still draws its cheap
82
+ // frames on the next refresh, which is why the median frame is not the screen's.
83
+ const REFRESH_WINDOW_MS = 1.5;
84
+ const REFRESH_SHARE = 0.01;
85
+ const REFRESH_MIN_FRAMES = 3;
86
+ function refreshInterval(xs) {
87
+ const v = xs.filter((x) => Number.isFinite(x) && x > 0).sort((a, b) => a - b);
88
+ if (!v.length) return null;
89
+ const need = Math.max(REFRESH_MIN_FRAMES, Math.ceil(v.length * REFRESH_SHARE));
90
+ let hi = 0;
91
+ let sum = 0;
92
+ for (let lo = 0; lo < v.length; lo++) {
93
+ while (hi < v.length && v[hi] <= v[lo] + REFRESH_WINDOW_MS) sum += v[hi++];
94
+ if (hi - lo >= need) return sum / (hi - lo);
95
+ sum -= v[lo];
96
+ }
97
+ return null;
98
+ }
99
+ const round = (x, places) => Math.round(x * 10 ** places) / 10 ** places;
100
+
101
+ /**
102
+ * Per browser, how many of its frames were late and its worst stutter in
103
+ * play, from the perf table's frame rows. The rules are the debugging method's:
104
+ * a frame's work is its phases summed, never `wait_ms`; the budget is the
105
+ * screen's refresh interval (`refreshInterval`); a frame is late when its work passed the
106
+ * budget by more than a millisecond or its interval passed one and a half
107
+ * budgets; late frames less than half a second apart are one stutter, ranked
108
+ * by how far their frames ran over in all. A frame that ran no tick is placed
109
+ * at the last tick it had seen.
110
+ *
111
+ * @param {object[]} rows - perf table rows
112
+ * @param {{ startTick?: number, tickRate?: number|null }} clock - the session's
113
+ * first tick and tick rate, which turn ticks into seconds of play
114
+ * @returns {{ conn: string, frames: number, late: number, budgetMs: number|null,
115
+ * worstStutter: { fromS: number|null, toS: number|null, tickFrom: number|null, tickTo: number|null,
116
+ * frames: number, overMs: number } | null }[]}
117
+ */
118
+ export function lateFrames(rows, { startTick = 0, tickRate = null } = {}) {
119
+ const byConn = new Map();
120
+ for (const r of rows ?? []) {
121
+ if (r?.row !== 'frame' || typeof r.conn !== 'string' || !Number.isFinite(r.at_ms)) continue;
122
+ if (!byConn.has(r.conn)) byConn.set(r.conn, []);
123
+ byConn.get(r.conn).push(r);
124
+ }
125
+ const seconds = (tick) => (tick !== null && tickRate > 0 ? round((tick - startTick) / tickRate, 2) : null);
126
+ const out = [];
127
+ for (const [conn, frames] of byConn) {
128
+ frames.sort((a, b) => a.at_ms - b.at_ms);
129
+ const budget = refreshInterval(frames.map((f) => f.dt));
130
+ let tick = null;
131
+ let late = 0;
132
+ const stutters = [];
133
+ let cur = null;
134
+ for (const f of frames) {
135
+ if (Number.isFinite(f.tick_to)) tick = f.tick_to;
136
+ if (budget === null) continue;
137
+ const busy = ['tick_ms', 'present_ms', 'render_ms', 'sim_ms', 'other_ms'].reduce((n, k) => n + (Number.isFinite(f[k]) ? f[k] : 0), 0);
138
+ const dt = Number.isFinite(f.dt) ? f.dt : 0;
139
+ if (!(busy > budget + 1 || dt > 1.5 * budget)) continue;
140
+ late += 1;
141
+ if (!cur || f.at_ms - cur.lastAt > STUTTER_GAP_MS) {
142
+ cur = { tickFrom: null, tickTo: null, frames: 0, overMs: 0, lastAt: f.at_ms };
143
+ stutters.push(cur);
144
+ }
145
+ // The lowest and highest tick, as the method's min() and max() read
146
+ // them: a browser's ticks need not rise frame by frame.
147
+ if (tick !== null) {
148
+ cur.tickFrom = cur.tickFrom === null ? tick : Math.min(cur.tickFrom, tick);
149
+ cur.tickTo = cur.tickTo === null ? tick : Math.max(cur.tickTo, tick);
150
+ }
151
+ cur.frames += 1;
152
+ cur.overMs += Math.max(busy, dt) - budget;
153
+ cur.lastAt = f.at_ms;
154
+ }
155
+ let worst = null;
156
+ for (const st of stutters) {
157
+ const fromS = seconds(st.tickFrom);
158
+ if (fromS === null || fromS < LOADING_S) continue;
159
+ if (!worst || st.overMs > worst.overMs) worst = st;
160
+ }
161
+ out.push({
162
+ conn, frames: frames.length, late, budgetMs: budget === null ? null : round(budget, 1),
163
+ worstStutter: worst && {
164
+ fromS: seconds(worst.tickFrom), toS: seconds(worst.tickTo), tickFrom: worst.tickFrom, tickTo: worst.tickTo,
165
+ frames: worst.frames, overMs: round(worst.overMs, 1),
166
+ },
167
+ });
168
+ }
169
+ return out;
170
+ }
171
+
172
+ // The browsers that wrote a cost track, each described by the header of its
173
+ // earliest segment (every segment of one browser carries the same device).
174
+ function devicesOf(dir, names) {
175
+ const first = new Map();
176
+ for (const name of names) {
177
+ const m = TRACK_FILE.exec(name);
178
+ if (!m) continue;
179
+ const [, producer, seq] = m;
180
+ if (!first.has(producer) || seq < first.get(producer).seq) first.set(producer, { seq, name });
181
+ }
182
+ const out = [];
183
+ for (const [conn, { name }] of first) {
184
+ const d = readJson(join(dir, name))?.header?.device ?? {};
185
+ const vw = d.viewport?.width;
186
+ const vh = d.viewport?.height;
187
+ out.push({
188
+ conn,
189
+ device: shortDevice(d.userAgent),
190
+ cores: Number.isFinite(d.cores) ? d.cores : null,
191
+ touch: typeof d.touch === 'boolean' ? d.touch : null,
192
+ viewport: Number.isFinite(vw) && Number.isFinite(vh) ? `${vw}x${vh}` : null,
193
+ dpr: Number.isFinite(d.screenDpr) ? d.screenDpr : null,
194
+ automated: typeof d.automated === 'boolean' ? d.automated : null,
195
+ });
196
+ }
197
+ return out.sort((a, b) => (a.conn < b.conn ? -1 : a.conn > b.conn ? 1 : 0));
198
+ }
199
+
200
+ // How much of one session's performance track the list reads, in bytes of
201
+ // segment files. A track is written by browsers — in multiplayer, other
202
+ // people's — and can be copied in from anywhere, so the list reads a bounded
203
+ // amount and keeps only the few numbers a late-frame count needs. An hour of
204
+ // play on one device is well inside it; past it, the counts cover the start
205
+ // of the track and say so.
206
+ export const TRACK_BUDGET_BYTES = 128 * 1024 * 1024;
207
+
208
+ const PHASES = ['tick', 'present', 'render', 'sim', 'other'];
209
+ const finite = (v) => (Number.isFinite(v) ? v : null);
210
+
211
+ // A session's frames, as the minimal rows `lateFrames` reads, in segment
212
+ // order. Each browser is named by its segment files' names — the name the
213
+ // device list uses — never by what a header inside claims.
214
+ function trackFrames(dir, names, budgetBytes) {
215
+ const rows = [];
216
+ const cut = new Set();
217
+ let spent = 0;
218
+ // Segment by segment in time, every browser's first segment before any
219
+ // browser's second, so a budget that runs out cuts every browser at about
220
+ // the same moment rather than leaving the last one unread.
221
+ const files = names.map((n) => TRACK_FILE.exec(n)).filter(Boolean)
222
+ .sort((a, b) => (a[2] === b[2] ? (a[1] < b[1] ? -1 : 1) : a[2] < b[2] ? -1 : 1));
223
+ for (const [name, conn] of files) {
224
+ let size = 0;
225
+ try { size = lstatSync(join(dir, name)).size; } catch { continue; }
226
+ if (cut.size || spent + size > budgetBytes) {
227
+ // Past the budget nothing more is read, so every browser with a
228
+ // segment left is counted in part.
229
+ cut.add(conn);
230
+ continue;
231
+ }
232
+ spent += size;
233
+ const seg = readJson(join(dir, name));
234
+ for (const f of Array.isArray(seg?.frames) ? seg.frames : []) {
235
+ if (!f || typeof f !== 'object') continue;
236
+ const row = { row: 'frame', conn, at_ms: finite(f.at), dt: finite(f.dt),
237
+ tick_to: Array.isArray(f.ticks) && f.ticks.length === 2 ? finite(f.ticks[1]) : null };
238
+ for (const p of PHASES) row[`${p}_ms`] = finite(f[p]);
239
+ rows.push(row);
240
+ }
241
+ }
242
+ return { rows, cut };
243
+ }
244
+
245
+ // Who was in the room: named in a segment's header, or joined during it.
246
+ function playersOf(dir, names) {
247
+ const conns = new Set();
248
+ let header = null;
249
+ for (const name of names.filter((n) => RECORDING_FILE.test(n)).sort()) {
250
+ const seg = readJson(join(dir, name));
251
+ if (!seg) continue;
252
+ header ??= seg.header ?? null;
253
+ const participants = seg.header?.participants;
254
+ if (participants && typeof participants === 'object') for (const key of Object.keys(participants)) conns.add(key);
255
+ for (const e of Array.isArray(seg.events) ? seg.events : []) {
256
+ if (e?.k === 'jn' && typeof e.connId === 'string') conns.add(e.connId);
257
+ }
258
+ }
259
+ return { players: conns.size, header };
260
+ }
261
+
262
+ /**
263
+ * Every recorded session in a game folder, newest first.
264
+ * @param {string} dir - the game folder
265
+ * @param {{ trackBudgetBytes?: number }} [opts] - how much of each session's
266
+ * performance track to read
267
+ */
268
+ export function listSessions(dir, { trackBudgetBytes = TRACK_BUDGET_BYTES } = {}) {
269
+ const recordings = listRecordings(dir);
270
+ if (!recordings.length) return [];
271
+ let current = null;
272
+ try { current = gameBuild(dir, { git: false }).id; } catch { /* not a folder of game files */ }
273
+ return recordings.map((r) => {
274
+ const sdir = streamDir(dir, r.stream);
275
+ let names = [];
276
+ try { names = readdirSync(sdir); } catch { /* removed while listing */ }
277
+ const { players, header } = playersOf(sdir, names);
278
+ const clock = { startTick: tickOf(r.startTick) ?? 0, tickRate: Number.isFinite(r.tickRate) ? r.tickRate : null };
279
+ const measured = devicesOf(sdir, names);
280
+ const track = measured.length ? trackFrames(sdir, names, trackBudgetBytes) : { rows: [], cut: new Set() };
281
+ const went = new Map(lateFrames(track.rows, clock).map((x) => [x.conn, x]));
282
+ const devices = measured.map((d) => {
283
+ const { conn: _, ...how } = went.get(d.conn) ?? { frames: 0, late: 0, budgetMs: null, worstStutter: null };
284
+ // True when the track went past what the list reads: the counts are of its start.
285
+ return { ...d, ...how, partial: track.cut.has(d.conn) };
286
+ });
287
+ const build = r.build?.id ?? null;
288
+ return {
289
+ stream: r.stream,
290
+ startedAt: isoOf(r.startedAt),
291
+ seconds: Number.isFinite(r.durationMs) ? Math.round(r.durationMs / 100) / 10 : null,
292
+ ticks: tickOf(r.startTick) != null && tickOf(r.endTick) != null ? [r.startTick, r.endTick] : null,
293
+ players,
294
+ measured: devices.length > 0,
295
+ devices,
296
+ watchable: r.watchable,
297
+ // A room served by `joystack dev` carries a placeholder where a published
298
+ // one carries its engine version; the dev server's stamp names it.
299
+ engine: knownHeaderToken(header?.engineVersion) ?? r.build?.engine ?? null,
300
+ code: knownHeaderToken(header?.codeFingerprint),
301
+ build,
302
+ commit: r.build?.git?.commit ?? null,
303
+ uncommitted: r.build?.git?.dirty ?? null,
304
+ buildIsCurrent: build && current ? build === current : null,
305
+ kept: r.kept,
306
+ };
307
+ });
308
+ }