@homie-rocks/studio 0.46.3 → 0.46.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,76 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.46.6] - 2026-10-11
21
+
22
+ **Plugin 0.46.6** · Big rooms: what one room carries, measured.
23
+
24
+ What one room object carries on Cloudflare, measured in six trials and stated: with a light game at 20 ticks a second, 300 players with margin, 600 at the edge, 800 to 1,000 only by luck.
25
+
26
+ ### Changed
27
+
28
+ - A big match room's once-a-second checkpoint no longer holds its frames. Cloudflare holds whatever an object sends after a storage write until the write is confirmed, so one or two worlds a second reached every Gate a tick late, at every size. The checkpoint is now written with `allowUnconfirmed`; a save an admission asks for is confirmed as before and replaces it. A lasting world's writes are unchanged. At 300 players the gap every Gate saw once a second is gone, and a Gate's round trip to the room fell from 40 ms to 18.
29
+ - A big room costs less of its one thread than on 0.46.3 (a fifth less; a quarter less when inputs arrive late): it no longer builds the whole snapshot as text for nobody, does not encode an ASCII message to measure it, and reads the round trip it tells the rules, for a player behind a Gate, once a second from its ten-second window, not once an input.
30
+ - A player is sent fewer frames only when behind and not catching up. A connection that stalls for a second and then delivers what it held catches up by itself; 0.46.2 slowed every player of a healthy room of 600 when their one home link hiccuped.
31
+ - A Gate nobody is on gives its channel to the room back after ten seconds; the next player to arrive opens another. An open channel kept a big room and its Gates in memory after everybody had left.
32
+ - Each Gate reports the share of worlds over 100, 200 and 400 ms old and the players more than a second behind, beside the worst of each second.
33
+
34
+ ### Added
35
+
36
+ - Measuring a game's capacity on your own Cloudflare account (`lib/crowd-probe/README.md`): the in-Cloudflare probe offers compression as a browser does, reports every thirty seconds so a request cut short has still said what it saw, can play slow players among healthy ones (`--slow-every`), and times a fixed piece of work against your computer with the stopwatch outside Cloudflare (`--calibrate`). `lib/crowd-probe/run-trial.sh` runs a whole trial, each in-Cloudflare step three times, and writes one summary. The home driver can stop every Kth player's socket reading (`--slow-every`). Slow players' own figures are kept apart from everybody else's.
37
+
38
+ ### Upgrade notes
39
+
40
+ - 1,000 players in one room is not promised; past what one room carries a world is split across rooms. Rooms of 64 seats or fewer and games a player's browser hosts are not on any of this path and behave as on 0.46.3.
41
+ - **What one room carries** is in the README ("Room size and delivery") and, with the measurements and the ways past one room, in `docs/rooms-capacity-findings.md`. Homie still sets no limit on `players.max`: choose it with your own game's numbers.
42
+ - Nothing an object measures about itself by its own clock is to be trusted on Cloudflare: a Worker's clock stands still while its code runs and reads a timer's due time when the timer fires. Each Gate's 20 ms lateness timer (added early in this release) read 0 in every step of every trial, fallen ones too. Read a Gate's round trip to the room and the share of worlds over 200 ms old.
43
+ - `HOMIE_GATE_PLAYERS` on a studio's Worker sets the players a Gate holds, for a trial that is finding the right number. A studio does not set it.
44
+ - The local crowd reproduction (eight workerd processes, two Chrome players and the load driver on one computer) is no longer a check. Run it by hand with `npm run test:crowd`; `docs/crowd-transport-0462.md` says what it can and cannot show.
45
+ ## [0.46.5] - 2026-10-11
46
+
47
+ **Plugin 0.46.5**
48
+
49
+ A page left open across a deploy is brought up to date with its game, and a check never opens the live site
50
+ unless it is asked to.
51
+
52
+ ### Added
53
+
54
+ - `room.bots: { keep, names: [...] }`: a rules game names its own bots, by seat. The roster, the results and
55
+ `world.label` all use it. Opt-in: without `names` the toolkit's own list is used, as before.
56
+
57
+ - A predictor can judge its own shot as the server will: `ctx.judgedTick(aimTick)` (the tick the server will
58
+ pose the others at for this seat; the snapshot's control row now carries the seat's own rewind allowance in a
59
+ game that declares `room.historySeconds`), `ctx.ray/rays(..., { atTick })` and `room.poseAt(id, tick)` (the pose
60
+ at a tick from the snapshots the browser holds), and `ctx.math` (the rules' own `math`).
61
+
62
+ ### Fixed
63
+
64
+ Each of these changes what happened before; none needs a change to a game.
65
+
66
+ - A play page open during a deploy kept hosting the newer game inside the older page: only the frame was loaded
67
+ again. When the newer game needed something the old page did not have (a player ticket, for a game that had
68
+ just started keeping results with `room.records`), the player saw an empty history and finished rounds were not
69
+ recorded until they reloaded the page themselves. The page now says what it was made for, the Worker answers
70
+ the frame with a stand-in when a page made now would differ, and the page does one full reload to the same room
71
+ and seat. A page from before this handshake is sent to a fresh page, by the one way it has, only when the game
72
+ cannot keep its results inside it. A deploy that changes only a game's code or art still reloads the frame alone.
73
+ - A finished round that had no player to be written for was dropped without a word. The log now says
74
+ `record-unsaved`, and the player is told on their screen that the round was not saved and to reload.
75
+ - The player's own body was drawn sinking for the whole tick it landed in, and standing only at the tick's end
76
+ (up to 33 ms late at 20 ticks a second). It is drawn down to the ground by the moment it met it.
77
+ - `homie-studio check` with no `--url` opened the studio's **live** site, and with no game the first game by name:
78
+ `npm run check` could seat two check browsers in a live public room of a game nobody was working on. With no
79
+ `--url` a check now runs against this studio's own build: its running dev server, or one it starts on a free
80
+ port and stops afterwards. With no game it covers the games this checkout changed (against the default branch,
81
+ committed or not), else every game. The live site is checked only when asked: `--live`, or its address in
82
+ `--url`. `port check` and `perf` follow the same rule (the running dev server, `--url`, or `--live`).
83
+
84
+ ### Upgrade notes
85
+
86
+ - Nothing to do. A script that relied on `homie-studio check` reaching the live site says `--live` now.
87
+ - The first deploy of this version reloads open play pages whole, once (their pages are older than it).
88
+ - Version 0.46.5 is provisional. No deploy or merge is part of this PR.
89
+
20
90
  ## [0.46.3] - 2026-10-11
21
91
 
22
92
  **Plugin 0.46.3**
package/README.md CHANGED
@@ -222,7 +222,18 @@ For example, a 300-player room can choose spatial delivery explicitly:
222
222
  The studio chooses the radius, precision and distant update rate for its game. The
223
223
  controlled body stays precise. Whole-room state remains the default. The build runs a
224
224
  local capacity trial; its numbers are not Cloudflare capacity guarantees. Test the actual
225
- mechanic at the chosen size. Rules builds derive their revision from rules and view;
225
+ mechanic at the chosen size.
226
+
227
+ **What one room carries.** A room's rules run in one object on one thread, whatever its
228
+ `players.max`. Measured on Cloudflare in October 2026 with a light game at 20 ticks a
229
+ second (every player in one dense arena, ten inputs a second each): 300 players with
230
+ margin (clean in every run), 600 at the edge (four runs of six), and 800 to 1,000 only by
231
+ luck (five runs of twenty-five). A game with heavier rules carries fewer. Homie sets no
232
+ limit on `players.max`; it is the studio's to choose with its own game's numbers:
233
+ `lib/crowd-probe/README.md` is how to measure them on your own Cloudflare account. Beyond
234
+ what one room carries, a world is split across rooms (several rooms of a game today; one
235
+ world of many areas is on the roadmap). `docs/rooms-capacity-findings.md` in the Homie
236
+ repository has the measurements. Rules builds derive their revision from rules and view;
226
237
  do not manually bump `netplay.version`. Optional `netplay.params` still selects play-page
227
238
  query parameters passed to the view, beside `debug` and `q`.
228
239
 
@@ -785,6 +785,8 @@ function print(result) {
785
785
  `Written: ${result.file}`);
786
786
  break;
787
787
  case 'check':
788
+ if (result.results) { for (const r of result.results) lines.push(`${r.game}: PASS at ${r.site}: room ${r.room}, round ${r.round?.n} (${r.round?.humans} humans, ${r.round?.bots} bots), connection ${r.uninterrupted === true ? 'uninterrupted' : r.uninterrupted === false ? 'INTERRUPTED' : 'unknown'}`); break; }
789
+ if (result.site) lines.push(`Checked ${result.local ? 'this studio\'s own build' : 'the site'} at ${result.site}.`);
788
790
  lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
789
791
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`),
790
792
  ...(result.frames ?? []).map((f) => ` ${f.browser} drew ${f.fps ?? '?'} fps${f.renderer ? ` on ${f.renderer}` : ''}`),
@@ -933,8 +935,9 @@ async function main() {
933
935
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
934
936
  if (cmd === 'port' && sub === 'check') {
935
937
  const game = positional[2] ?? listGames(root)[0]?.id;
936
- const url = flags.get('url') ?? siteUrl(root);
937
- if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
938
+ // Never the live site unless asked (--live, or its address): this studio's running dev server, else say so.
939
+ const url = flags.get('url') ?? (flags.has('live') ? siteUrl(root) : (await import('../lib/dev.mjs')).runningDev(root)?.url);
940
+ if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address), start npm run dev, or say --live for the live site' };
938
941
  return tracked(root, 'checks', (report) => portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'port check');
939
942
  }
940
943
  if (cmd === 'app' && sub === 'role') {
@@ -955,8 +958,8 @@ async function main() {
955
958
  if (cmd === 'lab') return labCommand(root, sub);
956
959
  if (cmd === 'perf') {
957
960
  const game = sub ?? listGames(root)[0]?.id;
958
- const url = flags.get('url') ?? siteUrl(root);
959
- if (!url) return { ok: false, command: 'perf', why: 'give --url (the local dev address or the live site)' };
961
+ const url = flags.get('url') ?? (flags.has('live') ? siteUrl(root) : (await import('../lib/dev.mjs')).runningDev(root)?.url);
962
+ if (!url) return { ok: false, command: 'perf', why: 'give --url (the local dev address), start npm run dev, or say --live for the live site' };
960
963
  const devices = String(flags.get('device') ?? 'computer,phone').split(',').map((d) => d.trim()).filter(Boolean);
961
964
  const num = (k, d, lo, hi) => Math.max(lo, Math.min(hi, Number(flags.get(k) ?? d) || d));
962
965
  return perfRun({ root, url, game, devices, runs: num('runs', 1, 1, 50), seconds: num('seconds', 15, 3, 300), warm: num('warm', 3, 0, 60), profile: flags.has('profile'), out: flags.get('out') ? resolve(String(flags.get('out'))) : null, maxLoad: num('max-load', 0.8, 0.05, 50), cpu: num('cpu', 4, 1, 20), pair: flags.get('pair') ? String(flags.get('pair')) : null, log });
@@ -973,14 +976,30 @@ async function main() {
973
976
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
974
977
  if (cmd === 'dev') return dev(root, { lan: flags.has('lan'), port: flags.get('port') ?? 8787, remoteAi: flags.has('remote-ai'), localAi: !flags.has('no-local-ai'), timestamps: flags.has('timestamps'), log });
975
978
  if (cmd === 'check') {
976
- const game = positional[1] ?? listGames(root)[0]?.id;
977
- const url = flags.get('url') ?? siteUrl(root);
978
- if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
979
+ // lib/check-site.mjs: with no --url a check runs against this studio's own build, never the live site (--live asks
980
+ // for that); with no game it covers the games this checkout changed.
981
+ const { checkSite, gamesToCheck } = await import('../lib/check-site.mjs');
982
+ const { games, why } = gamesToCheck(root, positional[1]);
983
+ if (!games.length) return { ok: false, command: 'check', why: 'this studio has no game to check' };
979
984
  // A big binary committed under games/ fails the check before any browser starts: it belongs in R2, never in git.
980
985
  const { bigCommittedFiles } = await import('../lib/asset-check.mjs');
981
- const big = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${game}/`));
982
- if (big.length) return { ok: false, command: 'check', why: `committed files over 5 MB under games/${game}: ${big.map((f) => `${f.path} (${(f.bytes / 1024 / 1024).toFixed(1)} MB)`).join(', ')}. Big media goes to the studio's R2 (homie-studio storage add, then media move); raw models stay in art/<slug>/raw/ (git-ignored); a shipped model is made phone-sized with homie-studio assets optimise. Take it out of git (git rm --cached), then check again.` };
983
- return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
986
+ for (const game of games) {
987
+ const big = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${game}/`));
988
+ if (big.length) return { ok: false, command: 'check', why: `committed files over 5 MB under games/${game}: ${big.map((f) => `${f.path} (${(f.bytes / 1024 / 1024).toFixed(1)} MB)`).join(', ')}. Big media goes to the studio's R2 (homie-studio storage add, then media move); raw models stay in art/<slug>/raw/ (git-ignored); a shipped model is made phone-sized with homie-studio assets optimise. Take it out of git (git rm --cached <file>) and check again.` };
989
+ }
990
+ const site = await checkSite(root, { url: flags.get('url') ?? null, live: flags.has('live'), log });
991
+ if (!site.ok) return { ok: false, command: 'check', why: site.why };
992
+ try {
993
+ if (!positional[1]) log(`check: ${games.join(', ')} (${why}) at ${site.url}`);
994
+ const results = [];
995
+ for (const game of games) {
996
+ const shots = flags.get('shots') ? resolve(flags.get('shots'), ...(games.length > 1 ? [game] : [])) : null;
997
+ const r = await tracked(root, 'checks', (report) => check({ url: site.url, game, shots, log, report }), 'check');
998
+ results.push({ ...r, game, site: site.url, local: site.local });
999
+ if (!r.ok) break;
1000
+ }
1001
+ return results.length === 1 ? results[0] : { ok: results.every((r) => r.ok), command: 'check', site: site.url, local: site.local, why: results.find((r) => !r.ok)?.why, results };
1002
+ } finally { await site.stop(); }
984
1003
  }
985
1004
  if (cmd === 'shoot') {
986
1005
  const game = positional[1] ?? listGames(root)[0]?.id;
@@ -1078,6 +1078,23 @@ leaves: take the marker down quietly. `ctx.ray/rays/rayAll` query what the
1078
1078
  player sees (`ctx.viewTick`); `ctx.preview` says the run is between ticks. A
1079
1079
  predictor runs often: keep it cheap, and keep per-entity state in `me`.
1080
1080
 
1081
+ To guess a shot the server will judge with rewind, ask for what the server
1082
+ will use (0.46.5). `ctx.judgedTick(aimTick)` is the tick it will pose the others
1083
+ at: the `aimTick` the input carries, held to how far back this seat may look
1084
+ right now (the server says so in every snapshot) and to this tick. Pass it on:
1085
+ `ctx.ray(from, dir, max, { atTick: ctx.judgedTick(input.aimTick), ...filters })`
1086
+ poses the other bodies as the server will, from the snapshots this browser
1087
+ holds (between two of them, or carried on from the newest along its velocity).
1088
+ Inside the allowance that is the drawn target; past the game's cap it is a newer
1089
+ pose, and the predictor then shows no hit where the server will see none.
1090
+ `room.poseAt(id, tick)` gives `{pos, vel, known}` for the game's own tests
1091
+ (`known: false` is a guess past the newest snapshot). `ctx.math` is the rules'
1092
+ own `math`, and the context is accepted by guarded rules code as a world is for
1093
+ what the two share (`tick`, `dt`, `math`, `ray`, `rays`, `rayAll`, and a scene's
1094
+ `ray`, `rayAll`, `sweep`, `support`), so an aiming helper written for the rules
1095
+ can be called with `ctx` instead of being restated in the view. It has no
1096
+ `near`, `inBox`, `send` or `emit`: a helper that needs those is the server's.
1097
+
1081
1098
  A tick's prediction runs more than once (between ticks, again when the input
1082
1099
  changes, then the tick itself) and shows each thing once. Things are told apart
1083
1100
  by their name and `key`: give two effects of one name that mean different things
@@ -1087,6 +1104,12 @@ before using it in a `match` closure: `me` is reused. A valued pulse
1087
1104
  (`f.pulse()`) carries the tick it was predicted on, and the server acts on it in
1088
1105
  that tick, so a predicted swap is in the snapshot of the tick it was drawn for.
1089
1106
 
1107
+ ### Naming the bots (0.46.5)
1108
+
1109
+ `room.bots: { keep: 4, names: ['Vex', 'Moth', 'Kilo'] }` gives the game's bots its own names, by seat (the list
1110
+ wraps). The roster, the round's results and `world.label(id)` all say the same name, and a restore keeps it.
1111
+ Without `names` the toolkit's own list is used. A name is 1 to 24 characters on one line.
1112
+
1090
1113
  ### Discrete actions with a value
1091
1114
 
1092
1115
  Use `select: f.pulse()` for a weapon slot or other byte-valued action. Call
@@ -304,6 +304,8 @@ npm run build # fix every error it name
304
304
  npx --no-install homie-studio build --types # the same, with the TypeScript checked first: run it before a deploy
305
305
  npm run dev # in the background: http://127.0.0.1:8787/<id>/play
306
306
  npx --no-install homie-studio check <id> --url http://127.0.0.1:8787 --shots ./.checks
307
+ # With no --url the check runs against this studio's own build (its running dev server, or one it starts and
308
+ # stops); with no <id>, on the games this checkout changed. The live site only with --live or its address.
307
309
  npx --no-install homie-studio shoot <id> --preview # pictures of the built game on a stepped clock, no site needed (a 3D game where there is no GPU)
308
310
  npx --no-install homie-studio preview <id> # only that game's built files at an address (alone, offline): for a capture script
309
311
  ```
@@ -0,0 +1,85 @@
1
+ /**
2
+ * WHERE A CHECK RUNS, AND ON WHICH GAMES, WHEN NOBODY SAYS (0.46.5).
3
+ *
4
+ * A check puts real browsers in a real room. With no `--url` it used to open the studio's LIVE site, and with no game
5
+ * the first game by name: `npm run check` in a studio working on one game sat two check browsers in a live public
6
+ * room of another. A check is a gate on what is in this folder, so:
7
+ *
8
+ * where `--url <site>` as given; else this studio's own dev server: the one already running (lib/dev.mjs keeps
9
+ * its record), or one started for the check on a free port and stopped when the check ends. The live site
10
+ * only when asked by name: `--live`, or its address in `--url`.
11
+ * which the game named; else every game this checkout changed (files under games/<id>/ that differ from the
12
+ * default branch, committed or not); else, when nothing changed or this is not a git checkout, every game.
13
+ */
14
+ import { spawn, spawnSync } from 'node:child_process';
15
+ import { once } from 'node:events';
16
+ import { createServer } from 'node:net';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { runningDev } from './dev.mjs';
19
+ import { listGames, siteUrl } from './studio.mjs';
20
+
21
+ const CLI = fileURLToPath(new URL('../bin/homie-studio.mjs', import.meta.url));
22
+
23
+ async function freePort() {
24
+ const s = createServer(); s.listen(0, '127.0.0.1'); await once(s, 'listening');
25
+ const port = s.address().port; await new Promise((r) => s.close(r)); return port;
26
+ }
27
+
28
+ /**
29
+ * The site a check opens: { ok, url, local, live, started, stop() }. `stop` ends only a server this call started.
30
+ * `spawnFn` and `fetchFn` are for tests.
31
+ */
32
+ export async function checkSite(root, { url = null, live = false, log = () => {}, spawnFn = spawn, fetchFn = globalThis.fetch, waitMs = 240_000 } = {}) {
33
+ const none = async () => {};
34
+ if (url) return { ok: true, url: String(url), local: /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::|\/|$)/.test(String(url)), live: false, started: false, stop: none };
35
+ if (live) {
36
+ const site = siteUrl(root);
37
+ return site ? { ok: true, url: site, local: false, live: true, started: false, stop: none } : { ok: false, why: 'this studio has no live site yet (deploy it first), so there is nothing for --live to check' };
38
+ }
39
+ const running = runningDev(root);
40
+ if (running) return { ok: true, url: running.url, local: true, live: false, started: false, stop: none };
41
+ const port = await freePort(), origin = `http://127.0.0.1:${port}`;
42
+ log(`check: no --url, so this studio's own build is checked: starting its dev server on ${origin} (stopped when the check ends; --live checks the live site)`);
43
+ const child = spawnFn(process.execPath, [CLI, 'dev', '--port', String(port), '--no-local-ai'], { cwd: root, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
44
+ let tail = '';
45
+ for (const stream of [child.stdout, child.stderr]) stream?.on('data', (chunk) => { tail = (tail + chunk).slice(-4000); });
46
+ const stop = async () => {
47
+ if (child.exitCode !== null || child.signalCode !== null) return;
48
+ const ended = once(child, 'exit');
49
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } }
50
+ const force = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, 8000);
51
+ try { await ended; } finally { clearTimeout(force); }
52
+ };
53
+ const deadline = Date.now() + waitMs;
54
+ while (Date.now() < deadline && child.exitCode === null && child.signalCode === null) {
55
+ const up = await fetchFn(`${origin}/games.json`, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
56
+ if (up) return { ok: true, url: origin, local: true, live: false, started: true, stop };
57
+ await new Promise((r) => setTimeout(r, 400));
58
+ }
59
+ await stop();
60
+ return { ok: false, why: `this studio's dev server did not start for the check${tail ? `:\n${tail.trim().split('\n').slice(-12).join('\n')}` : ''}\nStart it yourself (npm run dev) and give its address with --url, or fix the build.` };
61
+ }
62
+
63
+ /** The ids of the games this checkout changed against its default branch (committed or not), in the studio's order; [] when it cannot be told. */
64
+ export function changedGames(root, { run = spawnSync } = {}) {
65
+ const git = (...args) => { const r = run('git', args, { cwd: root, encoding: 'utf8' }); return r.status === 0 ? String(r.stdout) : null; };
66
+ if (git('rev-parse', '--is-inside-work-tree') === null) return [];
67
+ const files = new Set((git('status', '--porcelain', '--', 'games') ?? '').split('\n').map((l) => l.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()).filter(Boolean));
68
+ const head = (git('symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD') ?? '').trim();
69
+ for (const base of [head, 'origin/main', 'main', 'origin/master', 'master'].filter(Boolean)) {
70
+ const fork = (git('merge-base', 'HEAD', base) ?? '').trim();
71
+ if (!fork) continue;
72
+ for (const f of (git('diff', '--name-only', `${fork}..HEAD`, '--', 'games') ?? '').split('\n')) if (f) files.add(f);
73
+ break;
74
+ }
75
+ const touched = new Set([...files].map((f) => /^games\/([^/]+)\//.exec(f)?.[1]).filter(Boolean));
76
+ return listGames(root).map((g) => g.id).filter((id) => touched.has(id));
77
+ }
78
+
79
+ /** The games a check covers: the one named, else what changed, else all of them. */
80
+ export function gamesToCheck(root, named, options) {
81
+ if (named) return { games: [named], why: 'named' };
82
+ const changed = changedGames(root, options);
83
+ if (changed.length) return { games: changed, why: 'changed in this checkout' };
84
+ return { games: listGames(root).map((g) => g.id), why: 'nothing changed against the default branch: every game' };
85
+ }
package/lib/check.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `homie-studio check <game> --url <site>` — the promise, measured: two fresh
2
+ * `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
3
+ * when nobody says: lib/check-site.mjs; never the live site unless asked): two fresh
3
4
  * browsers (separate Chrome processes and profiles, one a computer and one a
4
5
  * phone) press Play, land in the SAME public room, play, and both see the round
5
6
  * finish. Exit 0 only when all of that happened. Uses this Mac's Chrome through
@@ -3,7 +3,8 @@
3
3
  * One crowd step played from inside Cloudflare, through the in-Cloudflare probe (lib/crowd-probe/README.md).
4
4
  *
5
5
  * node lib/crowd-cloud.mjs --probe <probe address> --key <PROBE_KEY> --url <studio> --n 300
6
- * [--seconds 150] [--room <name>] [--out <dir>] [--ramp-ms 50] [--game crowd-circuit]
6
+ * [--seconds 150] [--room <name>] [--out <dir>] [--ramp-ms 50] [--game crowd-circuit] [--compress on] [--slow-every 10]
7
+ * node lib/crowd-cloud.mjs --probe <probe address> --key <PROBE_KEY> --calibrate <file>
7
8
  *
8
9
  * It opens a request to the probe for every five players, holds them open for the step, and writes report.json in
9
10
  * the shape crowd-load.mjs writes (what applies: there is no browser and no home connection in this one).
@@ -12,12 +13,44 @@ import { mkdir, writeFile } from 'node:fs/promises';
12
13
  import { resolve } from 'node:path';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import { Tally } from './crowd-probe/client.mjs';
16
+ import { referenceWork } from './crowd-probe/reference-work.mjs';
15
17
 
16
18
  const pick = (sorted, q) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))] : null);
17
19
 
18
20
  /** Every probe request's last line, added up into one report. */
19
- export function cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures = [], toolkit = null }) {
20
- const players = finals.flatMap((f) => f.players), tallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() };
21
+ /**
22
+ * How fast Cloudflare runs the reference work against this computer. A Worker cannot time its own work, so the
23
+ * stopwatch is here: a request that does the work `rounds` times, less a request that does none, each the quickest
24
+ * of several (the least delayed by the connection), divided by the rounds. This process times the same call.
25
+ * `ratio` is Cloudflare's milliseconds over this computer's: what a millisecond of test/crowd-room-cost.mjs costs there.
26
+ */
27
+ export async function calibrate({ probe, key, requests = 6, rounds = 10, fetchFn = fetch, now = () => performance.now() }) {
28
+ referenceWork(4);
29
+ const local = [];
30
+ for (let i = 0; i < 5; i += 1) { const at = now(); referenceWork(); local.push(Math.round((now() - at) * 100) / 100); }
31
+ const check = referenceWork(), mine = Math.min(...local);
32
+ const ask = async (n) => { const at = now(); const answer = await (await fetchFn(`${probe}/calibrate?rounds=${n}&i=${Math.round(at)}`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(120000) })).json(); return { ms: Math.round((now() - at) * 100) / 100, ...answer }; };
33
+ const empty = [], worked = [];
34
+ for (let i = 0; i < requests; i += 1) {
35
+ try { empty.push(await ask(0)); } catch { /* one reading fewer */ }
36
+ try { const answer = await ask(rounds); if (answer.ok && answer.rounds === rounds) worked.push(answer); } catch { /* one reading fewer */ }
37
+ }
38
+ const base = Math.min(...empty.map((e) => e.ms)), each = worked.map((w) => Math.round((w.ms - base) / rounds * 100) / 100).sort((a, b) => a - b);
39
+ const of = (ms) => (Number.isFinite(ms) && mine > 0 ? Math.round(ms / mine * 100) / 100 : null);
40
+ return { at: new Date().toISOString(), rounds, localMs: local, emptyRequestMs: empty.map((e) => e.ms), workedRequestMs: worked.map((w) => w.ms), colos: [...new Set(worked.map((w) => w.colo).filter(Boolean))],
41
+ // What the Worker's own clock said the same rounds took: next to the stopwatch's figure, what a clock inside is worth.
42
+ workerOwnClockMs: worked.map((w) => w.own),
43
+ cloudMsARound: { min: each[0] ?? null, median: pick(each, 0.5), max: each.at(-1) ?? null },
44
+ ratio: each.length && Number.isFinite(base) ? { min: of(each[0]), median: of(pick(each, 0.5)), max: of(each.at(-1)) } : null,
45
+ same: worked.every((w) => w.check === check) };
46
+ }
47
+
48
+ export function cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures = [], toolkit = null, compress = false, lost = 0, roomHz = 20 }) {
49
+ // A probe's clock is a Worker's: it reads a timer's due time and runs slow when the Worker runs late, so a rate
50
+ // timed by it reads high (the fifth trial's rows of 25 to 32 "ticks a second" from a 20-tick room). Frames for
51
+ // each tick the room advanced need no clock: times the room's rate, that is frames a second. (Not for a slow
52
+ // player, whose ticks are the ones it has got to: its rate stays by the clock.)
53
+ const players = finals.flatMap((f) => f.players).map((p) => (Number.isFinite(p.framesPerTick) && !p.slow ? { ...p, clockDeliveredHz: p.deliveredHz, deliveredHz: p.framesPerTick * roomHz } : p)), tallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() };
21
54
  for (const f of finals) for (const key of Object.keys(tallies)) tallies[key].merge(f.tallies?.[key]);
22
55
  const of = (key) => players.map((p) => p[key]).filter(Number.isFinite).sort((a, b) => a - b);
23
56
  const range = (key) => { const a = of(key); return { count: a.length, min: a[0] ?? null, median: pick(a, 0.5), p50: pick(a, 0.5), max: a.at(-1) ?? null }; };
@@ -36,10 +69,25 @@ export function cloudReport({ origin, room, n, seconds, started, measureAt, endA
36
69
  inputAckMs: tallies.ack.json(), receiveGapMs: tallies.gaps.json(),
37
70
  // From the tick a player held when it stamped an input to the tick that input reached the rules.
38
71
  inputLoopMs: tallies.loops.json(),
39
- tickHz: range('tickHz'), deliveredHz: range('deliveredHz'),
40
- payload: { upBytes: sum('upBytes'), downBytes: sum('downBytes'), inputFrames: sum('inputs'), downFrames: sum('frames'), wireDownBytes: 0, compressedSockets: 0 },
41
- // Sockets that never leave Cloudflare are not compressed and cross no home connection.
42
- compression: { offered: false, sockets: 0, of: players.length, ratio: null },
72
+ // `tickHz` is by the probe's own clock (see above): above the room's rate, it says the probe ran late.
73
+ tickHz: range('tickHz'), tickHzClock: 'probe', deliveredHz: range('deliveredHz'), framesPerTick: range('framesPerTick'),
74
+ payload: { upBytes: sum('upBytes'), downBytes: sum('downBytes'), inputFrames: sum('inputs'), downFrames: sum('frames'), wireDownBytes: 0, compressedSockets: players.filter((p) => p.compressed).length },
75
+ // Sockets that never leave Cloudflare cross no home connection; with --compress on the studio's Gates compress
76
+ // for them as for a browser (the bytes saved cannot be seen from inside a Worker).
77
+ compression: { offered: Boolean(compress), sockets: players.filter((p) => p.compressed).length, of: players.length, ratio: null },
78
+ // Probe requests whose last word never came: their players were in the room, and are counted from each
79
+ // request's last thirty-second report where there was one.
80
+ lostReports: lost, partialReports: finals.filter((f) => f.t === 'partial').length,
81
+ // --slow-every K: every Kth player takes six frames a second. `healthy` is everybody else: what the slow
82
+ // ones cost them is the difference from the same step without any.
83
+ slowPlayers: players.filter((p) => p.slow).length,
84
+ healthy: (() => {
85
+ const a = players.filter((p) => !p.slow), list = (key) => a.map((p) => p[key]).filter(Number.isFinite).sort((x, y) => x - y);
86
+ const hz = list('deliveredHz'), gap = list('maxStateGapMs');
87
+ return { players: a.length, deliveredHz: { min: hz[0] ?? null, p05: pick(hz, 0.05), median: pick(hz, 0.5) }, maxStateGapMs: { median: pick(gap, 0.5), max: gap.at(-1) ?? null } };
88
+ })(),
89
+ slowInputAckMs: (() => { const t = new Tally(); for (const f of finals) t.merge(f.slowAck); return t.json(); })(),
90
+ slow: (() => { const a = players.filter((p) => p.slow), hz = a.map((p) => p.deliveredHz).filter(Number.isFinite).sort((x, y) => x - y); return a.length ? { players: a.length, deliveredHz: { min: hz[0] ?? null, median: pick(hz, 0.5), max: hz.at(-1) ?? null } } : null; })(),
43
91
  inputLead: { ticksMedian: pick(leads, 0.5), ticksMax: leads.at(-1) ?? null, lateShare: sum('reports') ? Math.round(sum('late') / sum('reports') * 1000) / 1000 : null },
44
92
  driver: { requests: finals.length, failedRequests: failures.length, workerLoopP99Ms: [], peakTimerLagMs: finals.map((f) => f.worstTimerLagMs) },
45
93
  errors: [...errors].map(([text, count]) => ({ text, count })),
@@ -50,9 +98,17 @@ export function cloudReport({ origin, room, n, seconds, started, measureAt, endA
50
98
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
51
99
  const opts = {};
52
100
  for (let i = 2; i < process.argv.length; i += 2) { if (!process.argv[i].startsWith('--') || process.argv[i + 1] === undefined) throw Error('Use --key value'); opts[process.argv[i].slice(2)] = process.argv[i + 1]; }
53
- const probe = String(opts.probe ?? '').replace(/\/$/, ''), key = opts.key, origin = new URL(opts.url).origin, n = Number(opts.n ?? 50), seconds = Number(opts.seconds ?? 150);
54
- const rampMs = Number(opts['ramp-ms'] ?? 50), game = opts.game ?? 'crowd-circuit', room = opts.room ?? `cloud-${n}-${Date.now().toString(36)}`, out = resolve(opts.out ?? `reports/crowd-circuit/${room}`);
55
- if (!probe || !key || !Number.isInteger(n) || n < 1 || n > 1000 || !(seconds >= 1 && seconds <= 600)) throw Error('--probe, --key, --url, --n 1..1000, --seconds 1..600');
101
+ const probe = String(opts.probe ?? '').replace(/\/$/, ''), key = opts.key, origin = opts.url ? new URL(opts.url).origin : null, n = Number(opts.n ?? 50), seconds = Number(opts.seconds ?? 150);
102
+ const compress = opts.compress === 'on', rampMs = Number(opts['ramp-ms'] ?? 50), game = opts.game ?? 'crowd-circuit', room = opts.room ?? `cloud-${n}-${Date.now().toString(36)}`, out = resolve(opts.out ?? `reports/crowd-circuit/${room}`);
103
+ if (opts.calibrate) {
104
+ // `--calibrate <file>`: only the reference work, here and in the probe; no players.
105
+ const result = await calibrate({ probe, key });
106
+ await writeFile(resolve(opts.calibrate), JSON.stringify(result, null, 2));
107
+ console.log(JSON.stringify({ phase: 'calibrated', localMs: Math.min(...result.localMs), cloudMsARound: result.cloudMsARound, ratio: result.ratio, workerOwnClockMs: result.workerOwnClockMs }));
108
+ process.exit(0);
109
+ }
110
+ const slowEvery = Math.max(0, Math.floor(Number(opts['slow-every'] ?? 0)) || 0);
111
+ if (!probe || !key || !origin || !Number.isInteger(n) || n < 1 || n > 1000 || !(seconds >= 1 && seconds <= 600)) throw Error('--probe, --key, --url, --n 1..1000, --seconds 1..600');
56
112
  await mkdir(out, { recursive: true });
57
113
  const hello = await fetch(`${probe}/hello`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(15000) });
58
114
  if (!hello.ok) throw Error(`The probe did not answer (${hello.status}): is it deployed, and is the key its PROBE_KEY?`);
@@ -62,26 +118,28 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
62
118
  // (fifteen seconds after the last has joined) and when to stop.
63
119
  const started = Date.now(), joinAt = started + 5000, measureAt = joinAt + n * rampMs + 15000, endAt = measureAt + seconds * 1000;
64
120
  console.log(JSON.stringify({ phase: 'start', generator: 'cloudflare', origin, room, n, seconds, requests: Math.ceil(n / 5), toolkit }));
65
- const finals = [], failures = []; let lastNote = 0; const progress = new Map();
121
+ const finals = [], failures = []; let lastNote = 0, lost = 0; const progress = new Map();
66
122
  await Promise.all(Array.from({ length: Math.ceil(n / 5) }, async (_, r) => {
67
- const from = r * 5, count = Math.min(5, n - from);
123
+ const from = r * 5, count = Math.min(5, n - from); let partial = null;
124
+ // Not all at once: a thousand players are two hundred requests.
125
+ await new Promise((done) => setTimeout(done, r * 10));
68
126
  try {
69
- const res = await fetch(`${probe}/play?origin=${encodeURIComponent(origin)}&game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&from=${from}&count=${count}&joinAt=${joinAt}&rampMs=${rampMs}&measureAt=${measureAt}&endAt=${endAt}`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(endAt - Date.now() + 60000) });
127
+ const res = await fetch(`${probe}/play?origin=${encodeURIComponent(origin)}&game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&from=${from}&count=${count}&joinAt=${joinAt}&rampMs=${rampMs}&measureAt=${measureAt}&endAt=${endAt}${compress ? '&compress=1' : ''}${slowEvery ? `&slowEvery=${slowEvery}` : ''}`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(endAt - Date.now() + 30000) });
70
128
  if (!res.ok || !res.body) throw Error(`probe HTTP ${res.status}`);
71
- let text = '', final = null;
129
+ let text = '', final = null; partial = null;
72
130
  for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
73
131
  text += chunk;
74
132
  for (let at = text.indexOf('\n'); at >= 0; at = text.indexOf('\n')) {
75
133
  const row = JSON.parse(text.slice(0, at)); text = text.slice(at + 1);
76
- if (row.t === 'final') final = row; else if (row.t === 'failed') throw Error(row.error); else if (row.t === 'sample') progress.set(from, row);
134
+ if (row.t === 'final') final = row; else if (row.t === 'partial') partial = row; else if (row.t === 'failed') throw Error(row.error); else if (row.t === 'sample') progress.set(from, row);
77
135
  }
78
136
  if (Date.now() - lastNote > 10000) { lastNote = Date.now(); const rows = [...progress.values()]; console.log(JSON.stringify({ phase: rows.some((x) => x.measuring) ? 'steady' : 'ramp', joined: rows.reduce((a, x) => a + x.joined, 0), open: rows.reduce((a, x) => a + x.open, 0) })); }
79
137
  }
80
138
  if (!final) throw Error('the probe ended without its report');
81
139
  finals.push(final);
82
- } catch (error) { failures.push(`probe request ${from}: ${String(error?.message ?? error).slice(0, 160)}`); }
140
+ } catch (error) { failures.push(`probe request ${from}: ${String(error?.message ?? error).slice(0, 160)}`); if (partial) finals.push(partial); else lost += 1; }
83
141
  }));
84
- const report = cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures, toolkit });
142
+ const report = cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures, toolkit, compress, lost });
85
143
  await writeFile(`${out}/report.json`, JSON.stringify(report, null, 2));
86
144
  console.log(JSON.stringify({ phase: 'done', out: `${out}/report.json`, joined: report.joined, connectedAtEnd: report.connectedAtEnd, ack: report.inputAckMs, loop: report.inputLoopMs, deliveredHz: report.deliveredHz, lead: report.inputLead, failedRequests: failures.length }));
87
145
  if (!finals.length) process.exitCode = 1;
@@ -58,6 +58,7 @@ const result = await withKey(
58
58
  transport: f?.transport,
59
59
  gates: f?.gates,
60
60
  durability: f?.durability,
61
+ save: f?.save,
61
62
  diagnostics: f?.diagnostics,
62
63
  error: r.error
63
64
  }) + '\n'
@@ -31,7 +31,8 @@ for (const k of Object.keys(opts))
31
31
  'churn',
32
32
  'churn-at',
33
33
  'compress',
34
- 'strict-chrome'
34
+ 'strict-chrome',
35
+ 'slow-every'
35
36
  ].includes(k)
36
37
  )
37
38
  throw Error('Unknown option --' + k);
@@ -127,7 +128,8 @@ function makeWorker(index) {
127
128
  room,
128
129
  index,
129
130
  workers: workerCount,
130
- compress: opts.compress !== 'off'
131
+ compress: opts.compress !== 'off',
132
+ slowEvery: Number(opts['slow-every'] ?? 0)
131
133
  }
132
134
  });
133
135
  let sequence = 0;
@@ -474,7 +476,7 @@ try {
474
476
  );
475
477
  loop.disable();
476
478
  const histograms = {};
477
- for (const key of ['first', 'welcome', 'ack', 'gaps', 'loops']) {
479
+ for (const key of ['first', 'welcome', 'ack', 'slowAck', 'gaps', 'loops']) {
478
480
  const h = new Histogram();
479
481
  for (const f of finals) h.merge(f.histograms[key]);
480
482
  histograms[key] = h.json();
@@ -518,6 +520,8 @@ try {
518
520
  firstStateMs: histograms.first,
519
521
  welcomeMs: histograms.welcome,
520
522
  inputAckMs: histograms.ack,
523
+ // With --slow-every: the slow players' own acknowledgements, kept out of everybody else's.
524
+ slowInputAckMs: histograms.slowAck,
521
525
  receiveGapMs: histograms.gaps,
522
526
  inputLoopMs: histograms.loops,
523
527
  ackSkipped: sum('ackSkipped'),
@@ -541,6 +545,14 @@ try {
541
545
  wireDownBytes: sum('wireDownBytes'),
542
546
  compressedSockets: sum('compressed')
543
547
  },
548
+ // --slow-every K: every Kth simulated player reads for one second in five (so every Gate holds some). `healthy` is everybody else: what the
549
+ // slow ones cost them is the difference from a run without any.
550
+ slowPlayers: players.filter((p) => p.slow).length,
551
+ healthy: (() => {
552
+ const a = players.filter((p) => !p.slow && !p.retired), of = (key) => a.map((p) => p[key]).filter(Number.isFinite).sort((x, y) => x - y);
553
+ const hz = of('deliveredHz'), gap = of('maxStateGapMs');
554
+ return { players: a.length, deliveredHz: { min: hz[0] ?? null, median: hz[Math.floor(hz.length / 2)] ?? null }, maxStateGapMs: { median: gap[Math.floor(gap.length / 2)] ?? null, max: gap.at(-1) ?? null } };
555
+ })(),
544
556
  // Samples in which a Chrome player was not a connected replica of this room.
545
557
  chromeTrouble,
546
558
  // Whether the server agreed to permessage-deflate on these sockets, as a
@@ -48,6 +48,64 @@ node node_modules/@homie-rocks/studio/lib/crowd-cloud.mjs \
48
48
  It opens one request to the probe for every five players, each held open for the step, adds up what they
49
49
  measured, and writes `report.json` in the shape `crowd-load.mjs` writes, so `crowd-summary.mjs` reads both.
50
50
 
51
+ ## Slow players among them
52
+
53
+ `--slow-every 10` makes every tenth player a slow one: it takes what it is sent at six frames a second, in order,
54
+ as a connection that carries no more does, and says which tick it holds. The report then has `healthy` (everybody
55
+ else) beside `slow`: what a slow player costs the players on the same Gate is the difference from the same step
56
+ without any. A Worker cannot slow a socket's reads, so this is a slow player and not a slow socket; for a socket
57
+ that stops reading, `crowd-load.mjs --slow-every 10` from a computer pauses every tenth player's socket for four
58
+ seconds in five.
59
+
60
+ ## How fast Cloudflare runs, against your computer
61
+
62
+ ```sh
63
+ node node_modules/@homie-rocks/studio/lib/crowd-cloud.mjs \
64
+ --probe https://homie-crowd-probe.<account>.workers.dev --key $KEY --calibrate reports/calibration.json
65
+ ```
66
+
67
+ A Worker's clock stands still while its code runs, so nothing timed on a computer says by itself what the same
68
+ work costs on Cloudflare, and a Worker cannot time it either. This runs one fixed piece of work
69
+ (`reference-work.mjs`) in the probe, ten rounds a request, with the stopwatch on your computer (a request that
70
+ does the work less one that does none, the quickest of several), and the same work on your computer, and writes
71
+ the ratio. `crowd-summary.mjs` prints it under a run's tables.
72
+
73
+ ## A whole trial
74
+
75
+ `run-trial.sh` in this folder runs a plan of steps against a deployed studio: each from your computer and from
76
+ inside Cloudflare, the in-Cloudflare steps three times each, one with slow players, the room's own figures beside
77
+ every step, the calibration before and after, and one `summary.md`. It deploys nothing.
78
+
79
+ ```sh
80
+ cd <your studio>
81
+ URL=https://<studio> WORKER=<its worker's name> PROBE_URL=https://homie-crowd-probe.<account>.workers.dev PROBE_KEY=$KEY \
82
+ bash node_modules/@homie-rocks/studio/lib/crowd-probe/run-trial.sh
83
+ ```
84
+
85
+ `PLAN=$'300 cloud 3\n600 cloud 3'` chooses the steps ("players mode runs"; the modes are in the script).
86
+
87
+ ## What a room carries, and how to read a trial
88
+
89
+ One room's rules run in one object on one thread. With the crowd-circuit fixture (light rules, 20 ticks a second)
90
+ that was 300 players with margin on Cloudflare, 600 at the edge, and 800 to 1,000 only by luck: the same step gave
91
+ 10 frames a second or 20, minutes apart. A game with heavier rules carries fewer.
92
+
93
+ - Run the size you want and twice that size, three times each, at a busy hour. A size is carried when every run
94
+ is clean and twice the size is not falling apart. One clean run proves nothing: at its edge a room is clean or
95
+ not by what else its machine is doing.
96
+ - Read **a Gate's round trip to the room** ("Gate rtt"), **the share of worlds over 200 ms old** and **"server
97
+ late"** (inputs that reached the rules after their tick). They are measured from outside the room.
98
+ - Do not read the room's word about itself. On Cloudflare a Worker's clock stands still while its code runs and
99
+ reads a timer's due time when the timer fires, so a room that is seconds behind counts no late ticks. For the
100
+ same reason a probe that runs late reports rates that read high; the report's delivered frames a second are
101
+ counted against the room's ticks, not a clock.
102
+ - Cloudflare's count of the room's processor time is not in a `wrangler tail` (it reads under 1% for a room that
103
+ is out of time). It is in the GraphQL analytics API (`durableObjectsPeriodicGroups`), which needs an API token
104
+ that may read account analytics.
105
+ - `node packages/studio/test/crowd-room-cost.mjs` in the Homie repository says what a tick of the room costs on a
106
+ computer, divided into rules, input and worlds. With the calibration above it compares one game with another;
107
+ it does not predict where a room falls.
108
+
51
109
  ## Remove
52
110
 
53
111
  ```sh
@@ -90,6 +90,9 @@ export function crowdClient({ index, game, send, tallies, period = 50, lead = 3
90
90
  const span = c.lastAt > c.firstAt ? c.lastAt - c.firstAt : 0;
91
91
  return { i: index, seat: c.seat, firstStateMs: c.first, welcomeMs: c.welcomed, closed: c.closed, lead: c.lead, frames: c.frames, maxStateGapMs: c.maxGap,
92
92
  tickHz: span ? (c.lastTick - c.firstTick) * 1000 / span : null, deliveredHz: span ? (c.frames - 1) * 1000 / span : null,
93
+ // Frames received for each tick the room advanced: 1 is every frame. It needs no clock, which matters where
94
+ // the caller's clock is a Worker's (it reads a timer's due time, and runs slow when the Worker runs late).
95
+ framesPerTick: c.lastTick > c.firstTick ? (c.frames - 1) / (c.lastTick - c.firstTick) : null,
93
96
  downBytes: c.down, upBytes: c.up, inputs: c.inputs, reports: c.reports, late: c.late, undecodable: c.undecodable, ownMissing: c.ownMissing, errors: c.errors.slice(0, 8), fresh: c.at ? true : false };
94
97
  },
95
98
  };