@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.
- package/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- 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
|
+
}
|