@homie-rocks/studio 0.46.0 → 0.46.3
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 +105 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +115 -5
- package/guides/game/SKILL.md +5 -0
- package/lib/build-identity.mjs +43 -0
- package/lib/cloudflare.mjs +13 -11
- package/lib/crowd-cloud.mjs +88 -0
- package/lib/crowd-diagnostics.mjs +73 -0
- package/lib/crowd-load.mjs +603 -0
- package/lib/crowd-probe/README.md +61 -0
- package/lib/crowd-probe/client.mjs +96 -0
- package/lib/crowd-probe/worker.mjs +72 -0
- package/lib/crowd-probe/wrangler.jsonc +12 -0
- package/lib/crowd-summary.mjs +140 -0
- package/lib/crowd-worker.mjs +496 -0
- package/lib/rules-check-worker.mjs +1 -1
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +5 -5
- package/lib/template-history.json +84 -0
- package/netplay/netplay.ts +19 -6
- package/package.json +4 -2
- package/rules/core.ts +29 -4
- package/rules/host.ts +119 -21
- package/rules/interest.mjs +320 -51
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +29 -8
- package/rules/timing.mjs +20 -0
- package/rules/types.ts +5 -0
- package/rules/view.ts +229 -25
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +2 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +92 -10
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/players.mjs +13 -15
- package/worker/room.mjs +113 -19
- package/worker/saves-shell.mjs +22 -2
- package/worker/saves.mjs +282 -19
- package/worker/version.mjs +1 -1
- package/worker/world-wire.mjs +110 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Public-room load probe. Two real Chrome players plus N simulated; no private admission route. */
|
|
3
|
+
import { Worker } from 'node:worker_threads';
|
|
4
|
+
import { mkdir, writeFile, mkdtemp, rm, readFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { performance, monitorEventLoopDelay } from 'node:perf_hooks';
|
|
9
|
+
import puppeteer from 'puppeteer-core';
|
|
10
|
+
import { Histogram } from './crowd-worker.mjs';
|
|
11
|
+
const opts = {};
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
if (!args[i].startsWith('--') || !args[i + 1] || args[i + 1].startsWith('--'))
|
|
15
|
+
throw Error('Use --key value');
|
|
16
|
+
opts[args[i].slice(2)] = args[++i];
|
|
17
|
+
}
|
|
18
|
+
for (const k of Object.keys(opts))
|
|
19
|
+
if (
|
|
20
|
+
![
|
|
21
|
+
'url',
|
|
22
|
+
'n',
|
|
23
|
+
'total',
|
|
24
|
+
'seconds',
|
|
25
|
+
'room',
|
|
26
|
+
'out',
|
|
27
|
+
'ramp-ms',
|
|
28
|
+
'chrome',
|
|
29
|
+
'rss-mb',
|
|
30
|
+
'workers',
|
|
31
|
+
'churn',
|
|
32
|
+
'churn-at',
|
|
33
|
+
'compress',
|
|
34
|
+
'strict-chrome'
|
|
35
|
+
].includes(k)
|
|
36
|
+
)
|
|
37
|
+
throw Error('Unknown option --' + k);
|
|
38
|
+
const origin = new URL(opts.url ?? 'http://127.0.0.1:8787').origin,
|
|
39
|
+
game = 'crowd-circuit',
|
|
40
|
+
total =
|
|
41
|
+
opts.total === undefined ? Number(opts.n ?? 50) + 2 : Number(opts.total),
|
|
42
|
+
n = total - 2,
|
|
43
|
+
seconds = Number(opts.seconds ?? 150),
|
|
44
|
+
rampMs = Number(opts['ramp-ms'] ?? 50),
|
|
45
|
+
workerCount = Number(opts.workers ?? 4),
|
|
46
|
+
rssLimit = Number(opts['rss-mb'] ?? 1024);
|
|
47
|
+
if (opts.n !== undefined && opts.total !== undefined)
|
|
48
|
+
throw Error('Use --n (simulated) or --total (including Chrome), not both');
|
|
49
|
+
if (
|
|
50
|
+
!Number.isInteger(total) ||
|
|
51
|
+
total < 3 ||
|
|
52
|
+
total > 1000 ||
|
|
53
|
+
!Number.isInteger(workerCount) ||
|
|
54
|
+
workerCount < 1 ||
|
|
55
|
+
workerCount > 16 ||
|
|
56
|
+
!Number.isFinite(seconds) ||
|
|
57
|
+
seconds < 1 ||
|
|
58
|
+
seconds > 3600 ||
|
|
59
|
+
!Number.isFinite(rampMs) ||
|
|
60
|
+
rampMs < 10 ||
|
|
61
|
+
!Number.isFinite(rssLimit) ||
|
|
62
|
+
rssLimit < 128
|
|
63
|
+
)
|
|
64
|
+
throw Error(
|
|
65
|
+
'n 1..998 simulated or total 3..1000 including Chrome, workers 1..16, seconds 1..3600, ramp-ms >=10, rss-mb >=128'
|
|
66
|
+
);
|
|
67
|
+
if (
|
|
68
|
+
opts.churn !== undefined &&
|
|
69
|
+
(!Number.isInteger(Number(opts.churn)) ||
|
|
70
|
+
Number(opts.churn) < 0 ||
|
|
71
|
+
Number(opts.churn) > n)
|
|
72
|
+
)
|
|
73
|
+
throw Error('churn must be an integer from 0 to n');
|
|
74
|
+
if (
|
|
75
|
+
opts['churn-at'] !== undefined &&
|
|
76
|
+
(!Number.isFinite(Number(opts['churn-at'])) || Number(opts['churn-at']) < 0)
|
|
77
|
+
)
|
|
78
|
+
throw Error('churn-at must be a nonnegative number of seconds');
|
|
79
|
+
const simulated = n,
|
|
80
|
+
room = opts.room ?? `load-${n}-${Date.now().toString(36)}`,
|
|
81
|
+
out = resolve(opts.out ?? `reports/crowd-circuit/${room}`);
|
|
82
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(room)) throw Error('Invalid room');
|
|
83
|
+
await mkdir(out, { recursive: true });
|
|
84
|
+
const sources = {};
|
|
85
|
+
for (const name of ['crowd-load.mjs', 'crowd-worker.mjs']) {
|
|
86
|
+
const data = await readFile(new URL(name, import.meta.url));
|
|
87
|
+
await writeFile(`${out}/${name}`, data);
|
|
88
|
+
sources[name] = createHash('sha256').update(data).digest('hex');
|
|
89
|
+
}
|
|
90
|
+
const started = new Date().toISOString();
|
|
91
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
92
|
+
const browsers = [],
|
|
93
|
+
profiles = [],
|
|
94
|
+
windows = [],
|
|
95
|
+
samples = [],
|
|
96
|
+
errors = new Map(),
|
|
97
|
+
workers = [];
|
|
98
|
+
let stopReason = null,
|
|
99
|
+
chromeTrouble = 0,
|
|
100
|
+
measureStart = 0,
|
|
101
|
+
measureEnd = 0,
|
|
102
|
+
maxRss = 0,
|
|
103
|
+
manifest = null,
|
|
104
|
+
finals = [];
|
|
105
|
+
const memoryGuard = setInterval(() => {
|
|
106
|
+
maxRss = Math.max(maxRss, process.memoryUsage.rss() / 1048576);
|
|
107
|
+
if (maxRss > rssLimit) stop('driver RSS exceeded ' + rssLimit + ' MiB');
|
|
108
|
+
}, 200);
|
|
109
|
+
const loop = monitorEventLoopDelay({ resolution: 10 });
|
|
110
|
+
loop.enable();
|
|
111
|
+
function problem(type, text) {
|
|
112
|
+
const key = `${type}: ${String(text).slice(0, 500)}`;
|
|
113
|
+
errors.set(key, (errors.get(key) ?? 0) + 1);
|
|
114
|
+
if (errors.get(key) === 1)
|
|
115
|
+
console.error(JSON.stringify({ phase: 'error', text: key }));
|
|
116
|
+
}
|
|
117
|
+
function stop(reason) {
|
|
118
|
+
stopReason ??= reason;
|
|
119
|
+
}
|
|
120
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
121
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
122
|
+
function makeWorker(index) {
|
|
123
|
+
const thread = new Worker(new URL('./crowd-worker.mjs', import.meta.url), {
|
|
124
|
+
workerData: {
|
|
125
|
+
origin,
|
|
126
|
+
game,
|
|
127
|
+
room,
|
|
128
|
+
index,
|
|
129
|
+
workers: workerCount,
|
|
130
|
+
compress: opts.compress !== 'off'
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
let sequence = 0;
|
|
134
|
+
let failure = null;
|
|
135
|
+
const pending = new Map();
|
|
136
|
+
function fail(e) {
|
|
137
|
+
failure = e;
|
|
138
|
+
for (const p of pending.values()) {
|
|
139
|
+
clearTimeout(p.timer);
|
|
140
|
+
p.reject(e);
|
|
141
|
+
}
|
|
142
|
+
pending.clear();
|
|
143
|
+
}
|
|
144
|
+
thread.on('message', (m) => {
|
|
145
|
+
if (m.event) {
|
|
146
|
+
problem('worker' + index, m.text);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const p = pending.get(m.id);
|
|
150
|
+
if (p) {
|
|
151
|
+
clearTimeout(p.timer);
|
|
152
|
+
pending.delete(m.id);
|
|
153
|
+
m.error ? p.reject(Error(m.error)) : p.resolve(m.value);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
thread.on('error', (e) => {
|
|
157
|
+
fail(e);
|
|
158
|
+
stop('driver worker failed');
|
|
159
|
+
problem('worker', e.message);
|
|
160
|
+
});
|
|
161
|
+
thread.on('exit', (code) => fail(Error(`worker ${index} exited (${code})`)));
|
|
162
|
+
return {
|
|
163
|
+
thread,
|
|
164
|
+
call(op, ...args) {
|
|
165
|
+
if (failure) return Promise.reject(failure);
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const id = ++sequence,
|
|
168
|
+
timer = setTimeout(() => {
|
|
169
|
+
pending.delete(id);
|
|
170
|
+
reject(Error(`worker ${index} ${op} timeout`));
|
|
171
|
+
}, 180000);
|
|
172
|
+
pending.set(id, { resolve, reject, timer });
|
|
173
|
+
thread.postMessage({ id, op, args });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function chromeWindow(i) {
|
|
179
|
+
const profile = await mkdtemp(`${tmpdir()}/crowd-load-`);
|
|
180
|
+
profiles.push(profile);
|
|
181
|
+
const browser = await puppeteer.launch({
|
|
182
|
+
executablePath:
|
|
183
|
+
opts.chrome ??
|
|
184
|
+
process.env.CHROME_PATH ??
|
|
185
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
186
|
+
headless: false,
|
|
187
|
+
handleSIGINT: false,
|
|
188
|
+
handleSIGTERM: false,
|
|
189
|
+
handleSIGHUP: false,
|
|
190
|
+
userDataDir: profile,
|
|
191
|
+
defaultViewport: { width: 1000, height: 760 },
|
|
192
|
+
args: [
|
|
193
|
+
'--no-first-run',
|
|
194
|
+
'--no-default-browser-check',
|
|
195
|
+
'--disable-background-timer-throttling',
|
|
196
|
+
'--disable-renderer-backgrounding',
|
|
197
|
+
`--window-position=${i * 550},${i * 80}`,
|
|
198
|
+
'--window-size=1020,840'
|
|
199
|
+
]
|
|
200
|
+
});
|
|
201
|
+
browsers.push(browser);
|
|
202
|
+
const page = await browser.newPage();
|
|
203
|
+
await page.setUserAgent(`${await browser.userAgent()} HomieCheck crowd-load`);
|
|
204
|
+
await page.evaluateOnNewDocument(() => {
|
|
205
|
+
const Native = window.WebSocket;
|
|
206
|
+
window.__crowdSockets = [];
|
|
207
|
+
window.WebSocket = class extends Native {
|
|
208
|
+
constructor(...args) {
|
|
209
|
+
super(...args);
|
|
210
|
+
const path = new URL(String(args[0]), location.href).pathname;
|
|
211
|
+
const record = (event) => {
|
|
212
|
+
window.__crowdSockets.push({ at: Date.now(), path, ...event });
|
|
213
|
+
if (window.__crowdSockets.length > 100) window.__crowdSockets.shift();
|
|
214
|
+
};
|
|
215
|
+
this.addEventListener('open', () => record({ type: 'open' }));
|
|
216
|
+
this.addEventListener('close', (e) =>
|
|
217
|
+
record({
|
|
218
|
+
type: 'close',
|
|
219
|
+
code: e.code,
|
|
220
|
+
reason: e.reason,
|
|
221
|
+
clean: e.wasClean
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
this.addEventListener('error', () => record({ type: 'error' }));
|
|
225
|
+
}
|
|
226
|
+
close(code, reason) {
|
|
227
|
+
const path = new URL(this.url).pathname;
|
|
228
|
+
window.__crowdSockets.push({
|
|
229
|
+
at: Date.now(),
|
|
230
|
+
type: 'client-close',
|
|
231
|
+
path,
|
|
232
|
+
code,
|
|
233
|
+
reason
|
|
234
|
+
});
|
|
235
|
+
super.close(code, reason);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
page.on('pageerror', (e) => problem(`chrome${i}`, e.message));
|
|
240
|
+
page.on('console', (m) => {
|
|
241
|
+
if (m.type() === 'error') problem(`chrome${i}`, m.text());
|
|
242
|
+
});
|
|
243
|
+
await page.goto(`${origin}/${game}/play?room=${room}&name=Chrome-${i + 1}`, {
|
|
244
|
+
waitUntil: 'domcontentloaded',
|
|
245
|
+
timeout: 60000
|
|
246
|
+
});
|
|
247
|
+
await page
|
|
248
|
+
.waitForFunction(
|
|
249
|
+
() =>
|
|
250
|
+
window.__shell?.seat !== null &&
|
|
251
|
+
window.__shell?.stats?.role === 'replica' &&
|
|
252
|
+
window.__shell?.arrival?.phase === 'done',
|
|
253
|
+
{ timeout: 60000 }
|
|
254
|
+
)
|
|
255
|
+
.catch(async () => {
|
|
256
|
+
await page.waitForFunction(
|
|
257
|
+
() =>
|
|
258
|
+
window.__shell?.stats?.role === 'replica' &&
|
|
259
|
+
typeof window.__shell?.seat === 'number',
|
|
260
|
+
{ timeout: 15000 }
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
const frame = page.frames().find((f) => f.url().includes('/__game/'));
|
|
264
|
+
if (frame) await frame.click('canvas');
|
|
265
|
+
windows.push({ page, i, frame });
|
|
266
|
+
return page;
|
|
267
|
+
}
|
|
268
|
+
async function browserFacts(w) {
|
|
269
|
+
return w.page.evaluate(() => {
|
|
270
|
+
const s = window.__shell;
|
|
271
|
+
return {
|
|
272
|
+
room: s?.room,
|
|
273
|
+
seat: s?.seat,
|
|
274
|
+
stats: s?.stats,
|
|
275
|
+
arrival: s?.arrival,
|
|
276
|
+
facts: {
|
|
277
|
+
toolkit: s?.facts?.toolkit,
|
|
278
|
+
transport: s?.facts?.transport,
|
|
279
|
+
gates: s?.facts?.gates,
|
|
280
|
+
counts: s?.facts?.counts,
|
|
281
|
+
hosted: s?.facts?.hosted,
|
|
282
|
+
ticks: s?.facts?.ticks,
|
|
283
|
+
stats: s?.facts?.stats
|
|
284
|
+
},
|
|
285
|
+
results: s?.results?.map((r) => ({
|
|
286
|
+
n: r.n,
|
|
287
|
+
at: r.at,
|
|
288
|
+
own: r.results?.find((p) => p.seat === s.seat)
|
|
289
|
+
}))
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
async function sample(phase) {
|
|
294
|
+
const states = await Promise.all(workers.map((w) => w.call('summary'))),
|
|
295
|
+
facts = await Promise.all(windows.map(browserFacts));
|
|
296
|
+
for (const s of states) if (s.stopReason) stop(s.stopReason);
|
|
297
|
+
if (new Set(states.map((s) => s.epoch).filter((e) => e !== null)).size > 1)
|
|
298
|
+
stop('workers observed different room epochs');
|
|
299
|
+
const rss = process.memoryUsage().rss / 1048576;
|
|
300
|
+
maxRss = Math.max(maxRss, rss);
|
|
301
|
+
if (rss > rssLimit) stop('driver RSS exceeded ' + rssLimit + ' MiB');
|
|
302
|
+
for (const [i, f] of facts.entries())
|
|
303
|
+
if (
|
|
304
|
+
f.room !== room ||
|
|
305
|
+
f.stats?.role !== 'replica' ||
|
|
306
|
+
!f.stats?.connected ||
|
|
307
|
+
f.stats?.offline ||
|
|
308
|
+
f.stats?.reconnects > 0 ||
|
|
309
|
+
typeof f.seat !== 'number'
|
|
310
|
+
) {
|
|
311
|
+
problem(
|
|
312
|
+
'browser membership',
|
|
313
|
+
JSON.stringify({ i, room: f.room, seat: f.seat, stats: f.stats })
|
|
314
|
+
);
|
|
315
|
+
chromeTrouble++;
|
|
316
|
+
// A real browser on a busy computer losing its link is a finding, and
|
|
317
|
+
// no reason to throw away what a thousand other players measured:
|
|
318
|
+
// the step runs on unless it was asked to stop (--strict-chrome 1).
|
|
319
|
+
if (opts['strict-chrome'] === '1')
|
|
320
|
+
stop('Chrome membership failed or its link reconnected');
|
|
321
|
+
}
|
|
322
|
+
const s = {
|
|
323
|
+
phase,
|
|
324
|
+
seconds: measureStart ? (performance.now() - measureStart) / 1000 : null,
|
|
325
|
+
connected: states.reduce((a, s) => a + s.connected, 0),
|
|
326
|
+
firstState: states.reduce((a, s) => a + s.joined, 0),
|
|
327
|
+
freshState: states.reduce((a, s) => a + s.fresh, 0),
|
|
328
|
+
rssMiB: rss,
|
|
329
|
+
coordinatorLoopP99Ms: loop.percentile(99) / 1e6,
|
|
330
|
+
workers: states,
|
|
331
|
+
browsers: facts
|
|
332
|
+
};
|
|
333
|
+
samples.push(s);
|
|
334
|
+
console.log(
|
|
335
|
+
JSON.stringify({
|
|
336
|
+
...s,
|
|
337
|
+
browsers: facts.map((f) => ({
|
|
338
|
+
room: f.room,
|
|
339
|
+
seat: f.seat,
|
|
340
|
+
connected: f.stats?.connected,
|
|
341
|
+
reconnects: f.stats?.reconnects
|
|
342
|
+
})),
|
|
343
|
+
workers: states.map((s) => ({
|
|
344
|
+
worker: s.worker,
|
|
345
|
+
loopP99Ms: s.loopP99Ms,
|
|
346
|
+
peakTimerLagMs: s.peakTimerLagMs
|
|
347
|
+
}))
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
return s;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
try {
|
|
354
|
+
const res = await fetch(
|
|
355
|
+
`${origin}/.well-known/homie-studio.json?probe=${Date.now()}`,
|
|
356
|
+
{ signal: AbortSignal.timeout(15000) }
|
|
357
|
+
);
|
|
358
|
+
if (res.ok) manifest = await res.json();
|
|
359
|
+
} catch (e) {
|
|
360
|
+
problem('manifest', e.message);
|
|
361
|
+
}
|
|
362
|
+
await writeFile(`${out}/deployment.json`, JSON.stringify(manifest, null, 2));
|
|
363
|
+
console.log(
|
|
364
|
+
JSON.stringify({
|
|
365
|
+
phase: 'start',
|
|
366
|
+
origin,
|
|
367
|
+
room,
|
|
368
|
+
n,
|
|
369
|
+
total,
|
|
370
|
+
simulated,
|
|
371
|
+
chromePlayers: 2,
|
|
372
|
+
seconds,
|
|
373
|
+
workerCount,
|
|
374
|
+
toolkit: manifest?.toolkit ?? null,
|
|
375
|
+
manifestStudioVersion: manifest?.studio ?? null,
|
|
376
|
+
sources
|
|
377
|
+
})
|
|
378
|
+
);
|
|
379
|
+
for (let i = 0; i < 2; i++) await chromeWindow(i);
|
|
380
|
+
for (let i = 0; i < workerCount; i++) workers.push(makeWorker(i));
|
|
381
|
+
let ramped = false;
|
|
382
|
+
const ramp = Promise.all(
|
|
383
|
+
workers.map((w) => w.call('ramp', simulated, rampMs))
|
|
384
|
+
).finally(() => (ramped = true));
|
|
385
|
+
ramp.catch((e) => {
|
|
386
|
+
problem('ramp', e.message);
|
|
387
|
+
stop('ramp failed');
|
|
388
|
+
});
|
|
389
|
+
while (!ramped && !stopReason) {
|
|
390
|
+
await sample('ramp');
|
|
391
|
+
await sleep(2000);
|
|
392
|
+
}
|
|
393
|
+
if (stopReason) await Promise.all(workers.map((w) => w.call('cancelRamp')));
|
|
394
|
+
await ramp;
|
|
395
|
+
const deadline = performance.now() + 30000;
|
|
396
|
+
while (!stopReason) {
|
|
397
|
+
const s = await sample('joining');
|
|
398
|
+
if (s.firstState === simulated || performance.now() > deadline) break;
|
|
399
|
+
await sleep(1000);
|
|
400
|
+
}
|
|
401
|
+
if (!stopReason) {
|
|
402
|
+
await Promise.all(workers.map((w) => w.call('start')));
|
|
403
|
+
measureStart = performance.now();
|
|
404
|
+
let nextShot = measureStart + 5000,
|
|
405
|
+
churned = false,
|
|
406
|
+
keyRight = true;
|
|
407
|
+
while (!stopReason && performance.now() - measureStart < seconds * 1000) {
|
|
408
|
+
await sample('steady');
|
|
409
|
+
for (const w of windows) {
|
|
410
|
+
await w.page.keyboard.up(keyRight ? 'ArrowLeft' : 'ArrowRight');
|
|
411
|
+
await w.page.keyboard.down(keyRight ? 'ArrowRight' : 'ArrowLeft');
|
|
412
|
+
}
|
|
413
|
+
keyRight = !keyRight;
|
|
414
|
+
if (performance.now() >= nextShot) {
|
|
415
|
+
for (const w of windows)
|
|
416
|
+
await w.page.screenshot({ path: `${out}/chrome-${w.i + 1}.png` });
|
|
417
|
+
nextShot = Infinity;
|
|
418
|
+
}
|
|
419
|
+
if (
|
|
420
|
+
opts.churn &&
|
|
421
|
+
!churned &&
|
|
422
|
+
performance.now() - measureStart > Number(opts['churn-at'] ?? 60) * 1000
|
|
423
|
+
) {
|
|
424
|
+
churned = true;
|
|
425
|
+
await Promise.all(
|
|
426
|
+
workers.map((w) => w.call('churn', Number(opts.churn)))
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
await sleep(
|
|
430
|
+
Math.min(
|
|
431
|
+
5000,
|
|
432
|
+
Math.max(0, seconds * 1000 - (performance.now() - measureStart))
|
|
433
|
+
)
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch (e) {
|
|
438
|
+
problem('fatal', e.stack);
|
|
439
|
+
stop('fatal error');
|
|
440
|
+
} finally {
|
|
441
|
+
measureEnd = performance.now();
|
|
442
|
+
clearInterval(memoryGuard);
|
|
443
|
+
try {
|
|
444
|
+
await sample('final');
|
|
445
|
+
} catch (e) {
|
|
446
|
+
problem('final', e.message);
|
|
447
|
+
}
|
|
448
|
+
const results = await Promise.allSettled(
|
|
449
|
+
workers.map((w) => w.call('finish'))
|
|
450
|
+
);
|
|
451
|
+
finals = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
|
|
452
|
+
if (finals.length !== workers.length) stop('incomplete worker results');
|
|
453
|
+
for (const w of windows) {
|
|
454
|
+
try {
|
|
455
|
+
await w.page.screenshot({ path: `${out}/chrome-${w.i + 1}-end.png` });
|
|
456
|
+
const socketEvents = await Promise.all(
|
|
457
|
+
w.page.frames().map(async (f) => ({
|
|
458
|
+
url: new URL(f.url()).pathname,
|
|
459
|
+
events: await f.evaluate(() => window.__crowdSockets ?? [])
|
|
460
|
+
}))
|
|
461
|
+
);
|
|
462
|
+
await writeFile(
|
|
463
|
+
`${out}/chrome-${w.i + 1}-sockets.json`,
|
|
464
|
+
JSON.stringify(socketEvents, null, 2)
|
|
465
|
+
);
|
|
466
|
+
} catch (e) {
|
|
467
|
+
problem('browser capture', e.message);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
await Promise.allSettled(workers.map((w) => w.thread.terminate()));
|
|
471
|
+
await Promise.allSettled(browsers.map((b) => b.close()));
|
|
472
|
+
await Promise.allSettled(
|
|
473
|
+
profiles.map((p) => rm(p, { recursive: true, force: true }))
|
|
474
|
+
);
|
|
475
|
+
loop.disable();
|
|
476
|
+
const histograms = {};
|
|
477
|
+
for (const key of ['first', 'welcome', 'ack', 'gaps', 'loops']) {
|
|
478
|
+
const h = new Histogram();
|
|
479
|
+
for (const f of finals) h.merge(f.histograms[key]);
|
|
480
|
+
histograms[key] = h.json();
|
|
481
|
+
}
|
|
482
|
+
const players = finals.flatMap((f) => f.players),
|
|
483
|
+
percentiles = (key) => {
|
|
484
|
+
const a = players
|
|
485
|
+
.filter((p) => !p.retired && Number.isFinite(p[key]))
|
|
486
|
+
.map((p) => p[key])
|
|
487
|
+
.sort((a, b) => a - b);
|
|
488
|
+
return {
|
|
489
|
+
count: a.length,
|
|
490
|
+
p50: a[Math.floor(a.length / 2)] ?? null,
|
|
491
|
+
min: a[0] ?? null,
|
|
492
|
+
median: a[Math.floor(a.length / 2)] ?? null,
|
|
493
|
+
max: a.at(-1) ?? null
|
|
494
|
+
};
|
|
495
|
+
},
|
|
496
|
+
sum = (key) => finals.reduce((a, f) => a + (f[key] ?? 0), 0);
|
|
497
|
+
const report = {
|
|
498
|
+
started,
|
|
499
|
+
measurementStarted: measureStart
|
|
500
|
+
? new Date(performance.timeOrigin + measureStart).toISOString()
|
|
501
|
+
: null,
|
|
502
|
+
ended: new Date().toISOString(),
|
|
503
|
+
origin,
|
|
504
|
+
room,
|
|
505
|
+
n,
|
|
506
|
+
total,
|
|
507
|
+
simulated,
|
|
508
|
+
chromePlayers: 2,
|
|
509
|
+
toolkit: manifest?.toolkit ?? null,
|
|
510
|
+
manifestStudioVersion: manifest?.studio ?? null,
|
|
511
|
+
sources,
|
|
512
|
+
requestedSeconds: seconds,
|
|
513
|
+
measuredSeconds: measureStart ? (measureEnd - measureStart) / 1000 : 0,
|
|
514
|
+
stopReason,
|
|
515
|
+
joined: sum('joined'),
|
|
516
|
+
connectedAtEnd: sum('connected'),
|
|
517
|
+
disconnects: sum('disconnects'),
|
|
518
|
+
firstStateMs: histograms.first,
|
|
519
|
+
welcomeMs: histograms.welcome,
|
|
520
|
+
inputAckMs: histograms.ack,
|
|
521
|
+
receiveGapMs: histograms.gaps,
|
|
522
|
+
inputLoopMs: histograms.loops,
|
|
523
|
+
ackSkipped: sum('ackSkipped'),
|
|
524
|
+
ackExpired: sum('ackExpired'),
|
|
525
|
+
tickHz: percentiles('tickHz'),
|
|
526
|
+
deliveredHz: percentiles('deliveredHz'),
|
|
527
|
+
tickHzMedian: percentiles('tickHz').median,
|
|
528
|
+
deliveredHzMedian: percentiles('deliveredHz').median,
|
|
529
|
+
lifetime: {
|
|
530
|
+
upBytes: sum('totalUp'),
|
|
531
|
+
downBytes: sum('totalDown'),
|
|
532
|
+
peakSampledConnected: Math.max(0, ...samples.map((s) => s.connected))
|
|
533
|
+
},
|
|
534
|
+
payload: {
|
|
535
|
+
upBytes: sum('upBytes'),
|
|
536
|
+
downBytes: sum('downBytes'),
|
|
537
|
+
inputFrames: sum('inputFrames'),
|
|
538
|
+
upFrames: sum('upFrames'),
|
|
539
|
+
downFrames: sum('downFrames'),
|
|
540
|
+
// What the uplink carried: compare with the link's measured capacity.
|
|
541
|
+
wireDownBytes: sum('wireDownBytes'),
|
|
542
|
+
compressedSockets: sum('compressed')
|
|
543
|
+
},
|
|
544
|
+
// Samples in which a Chrome player was not a connected replica of this room.
|
|
545
|
+
chromeTrouble,
|
|
546
|
+
// Whether the server agreed to permessage-deflate on these sockets, as a
|
|
547
|
+
// browser's would be, and what it saved: decoded bytes over wire bytes.
|
|
548
|
+
compression: {
|
|
549
|
+
offered: opts.compress !== 'off',
|
|
550
|
+
sockets: sum('compressed'),
|
|
551
|
+
of: sum('attempted'),
|
|
552
|
+
ratio:
|
|
553
|
+
sum('wireDownBytes') > 0
|
|
554
|
+
? Math.round((sum('downBytes') / sum('wireDownBytes')) * 100) / 100
|
|
555
|
+
: null
|
|
556
|
+
},
|
|
557
|
+
// The whole ticks ahead each player stamps its input (it starts at 3 and
|
|
558
|
+
// follows what the room reports, as the toolkit's client does), and the
|
|
559
|
+
// share of the room's reports that said an input missed its tick.
|
|
560
|
+
inputLead: (() => {
|
|
561
|
+
const a = finals.flatMap((f) => f.leadTicks ?? []).sort((x, y) => x - y);
|
|
562
|
+
const reports = sum('inputReports');
|
|
563
|
+
return {
|
|
564
|
+
ticksMedian: a[Math.floor(a.length / 2)] ?? null,
|
|
565
|
+
ticksMax: a.at(-1) ?? null,
|
|
566
|
+
lateShare: reports ? Math.round((sum('inputReportsLate') / reports) * 1000) / 1000 : null
|
|
567
|
+
};
|
|
568
|
+
})(),
|
|
569
|
+
driver: {
|
|
570
|
+
workers: workerCount,
|
|
571
|
+
maxRssMiB: maxRss,
|
|
572
|
+
coordinatorLoopP99Ms: loop.percentile(99) / 1e6,
|
|
573
|
+
workerLoopP99Ms: finals.map((f) => f.loopP99Ms),
|
|
574
|
+
peakTimerLagMs: finals.map((f) => f.peakTimerLagMs)
|
|
575
|
+
},
|
|
576
|
+
errors: [...errors].map(([text, count]) => ({ text, count })),
|
|
577
|
+
workerErrors: finals.map((f) => f.errors),
|
|
578
|
+
players,
|
|
579
|
+
samples,
|
|
580
|
+
notes: [
|
|
581
|
+
'N simulated players plus two Chrome players; --total includes Chrome and prevents exceeding capacity.',
|
|
582
|
+
'Every received snapshot is fully decoded in a worker thread. No frame sampling or per-frame re-serialization.',
|
|
583
|
+
'Input ack includes three-tick lead. Tick progression and delivered frame cadence are separate metrics.',
|
|
584
|
+
'Clients stop input after two seconds without own state; freshness exposes reduced offered load.',
|
|
585
|
+
'Cloudflare synchronous stage duration may be unavailable; never interpreted as zero CPU.'
|
|
586
|
+
]
|
|
587
|
+
};
|
|
588
|
+
await writeFile(`${out}/report.json`, JSON.stringify(report, null, 2) + '\n');
|
|
589
|
+
console.log(
|
|
590
|
+
JSON.stringify({ ...report, players: undefined, samples: undefined })
|
|
591
|
+
);
|
|
592
|
+
process.exitCode =
|
|
593
|
+
stopReason ||
|
|
594
|
+
report.joined !== simulated ||
|
|
595
|
+
report.connectedAtEnd !== simulated ||
|
|
596
|
+
report.disconnects ||
|
|
597
|
+
errors.size
|
|
598
|
+
? 1
|
|
599
|
+
: 0;
|
|
600
|
+
}
|
|
601
|
+
// Owned workers, sockets, Chrome and files are settled; imported browser libraries may retain timers.
|
|
602
|
+
await new Promise((resolve) => process.stdout.write('', resolve));
|
|
603
|
+
process.exit(process.exitCode ?? 0);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Measuring a big room
|
|
2
|
+
|
|
3
|
+
Two tools, both for a trial on your own Cloudflare account. Neither is part of a studio: nothing in a studio's
|
|
4
|
+
Worker imports them.
|
|
5
|
+
|
|
6
|
+
- **From your computer** (`lib/crowd-load.mjs`): simulated players and two real Chrome players over your own
|
|
7
|
+
connection. What a player at home sees, up to what one connection and one computer can carry.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
CHROME_PATH=/path/to/chrome node node_modules/@homie-rocks/studio/lib/crowd-load.mjs \
|
|
11
|
+
--url https://<studio> --n 300 --seconds 150 --room home-300 --out reports/home-300
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- **From inside Cloudflare** (this folder): what the room and its Gates can do, with no home connection in it.
|
|
15
|
+
|
|
16
|
+
`lib/crowd-diagnostics.mjs` records the room's own figures beside either (a row a second), and
|
|
17
|
+
`lib/crowd-summary.mjs <folder>` puts a run's steps in one table. Both players steer as the crowd-circuit
|
|
18
|
+
fixture does; for another game, change the input they send.
|
|
19
|
+
|
|
20
|
+
# The in-Cloudflare crowd probe
|
|
21
|
+
|
|
22
|
+
A Worker that plays simulated players against a studio from inside Cloudflare's network. It separates what a
|
|
23
|
+
studio's room and Gates can do from what one home connection and one busy computer can carry. It is a trial's
|
|
24
|
+
tool: no studio's Worker imports it, and it is deleted when the trial is over.
|
|
25
|
+
|
|
26
|
+
It proves the servers at a given number of players. It does not prove the last hop to a real player: its sockets
|
|
27
|
+
never leave Cloudflare, and they are not compressed.
|
|
28
|
+
|
|
29
|
+
## Deploy (in the account the studio is in, on a paid Workers plan)
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
cd node_modules/@homie-rocks/studio/lib/crowd-probe
|
|
33
|
+
KEY=$(openssl rand -hex 24)
|
|
34
|
+
npx wrangler deploy --var PROBE_KEY:$KEY
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`wrangler` prints the probe's address (`https://homie-crowd-probe.<account>.workers.dev`). The probe answers
|
|
38
|
+
nothing without the key.
|
|
39
|
+
|
|
40
|
+
## Run one step
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
node node_modules/@homie-rocks/studio/lib/crowd-cloud.mjs \
|
|
44
|
+
--probe https://homie-crowd-probe.<account>.workers.dev --key $KEY \
|
|
45
|
+
--url https://<studio> --n 300 --seconds 150 --room cloud-300 --out reports/cloud-300
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
It opens one request to the probe for every five players, each held open for the step, adds up what they
|
|
49
|
+
measured, and writes `report.json` in the shape `crowd-load.mjs` writes, so `crowd-summary.mjs` reads both.
|
|
50
|
+
|
|
51
|
+
## Remove
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npx wrangler delete --name homie-crowd-probe
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## What it needs of Cloudflare
|
|
58
|
+
|
|
59
|
+
- A paid plan: a request plays five players for minutes (the free plan's 10 ms of CPU a request is not enough).
|
|
60
|
+
- Six connections open at once per request, which is why a request plays five players.
|
|
61
|
+
- `global_fetch_strictly_public`, so its sockets reach the studio by its public address as a player's do.
|