@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 +70 -0
- package/README.md +12 -1
- package/bin/homie-studio.mjs +29 -10
- package/guides/game/RULES.md +23 -0
- package/guides/game/SKILL.md +2 -0
- package/lib/check-site.mjs +85 -0
- package/lib/check.mjs +2 -1
- package/lib/crowd-cloud.mjs +75 -17
- package/lib/crowd-diagnostics.mjs +1 -0
- package/lib/crowd-load.mjs +15 -3
- package/lib/crowd-probe/README.md +58 -0
- package/lib/crowd-probe/client.mjs +3 -0
- package/lib/crowd-probe/reference-work.mjs +45 -0
- package/lib/crowd-probe/run-trial.sh +207 -0
- package/lib/crowd-probe/worker.mjs +37 -10
- package/lib/crowd-summary.mjs +74 -7
- package/lib/crowd-worker.mjs +12 -4
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +12 -0
- package/netplay/netplay.ts +2 -0
- package/package.json +1 -1
- package/rules/host.ts +14 -3
- package/rules/rules.ts +9 -3
- package/rules/view.ts +66 -12
- package/worker/build-id.mjs +1 -1
- package/worker/gate.mjs +72 -25
- package/worker/index.mjs +44 -10
- package/worker/office.mjs +2 -2
- package/worker/pages.mjs +46 -1
- package/worker/room-store.mjs +71 -11
- package/worker/room.mjs +16 -3
- package/worker/version.mjs +1 -1
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.
|
|
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
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
-
|
|
937
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
|
982
|
-
|
|
983
|
-
|
|
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;
|
package/guides/game/RULES.md
CHANGED
|
@@ -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
|
package/guides/game/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
package/lib/crowd-cloud.mjs
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 (
|
|
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() +
|
|
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;
|
package/lib/crowd-load.mjs
CHANGED
|
@@ -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
|
};
|