@homie-rocks/studio 0.46.0 → 0.46.2
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 +44 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +11 -2
- package/lib/build-identity.mjs +43 -0
- package/lib/cloudflare.mjs +13 -11
- package/lib/crowd-cloud.mjs +88 -0
- package/lib/crowd-diagnostics.mjs +73 -0
- package/lib/crowd-load.mjs +603 -0
- package/lib/crowd-probe/README.md +61 -0
- package/lib/crowd-probe/client.mjs +96 -0
- package/lib/crowd-probe/worker.mjs +72 -0
- package/lib/crowd-probe/wrangler.jsonc +12 -0
- package/lib/crowd-summary.mjs +140 -0
- package/lib/crowd-worker.mjs +496 -0
- package/lib/scaffold.mjs +5 -5
- package/lib/template-history.json +42 -0
- package/netplay/netplay.ts +18 -5
- package/package.json +4 -2
- package/rules/host.ts +34 -8
- package/rules/interest.mjs +320 -51
- package/rules/rules.ts +13 -3
- package/rules/timing.mjs +20 -0
- package/worker/build-id.mjs +2 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +90 -10
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/room.mjs +85 -16
- package/worker/version.mjs +1 -1
- package/worker/world-wire.mjs +110 -0
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,50 @@ 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.2] - 2026-10-11
|
|
21
|
+
|
|
22
|
+
**Plugin 0.46.2** · Big rooms: the room runs the rules, its Gates do the rest.
|
|
23
|
+
|
|
24
|
+
A room of hundreds has a new shape, a deploy the owner asked for is approved by the asking, and Homie paces no input of a server-hosted game.
|
|
25
|
+
|
|
26
|
+
Measured on Cloudflare with a game where every player sees about 180 others: 300 players at 20 frames a second from a home connection; 600 at 20 frames a second measured from inside Cloudflare, marginal from one home connection carrying all 600; 1,000 are admitted and the room keeps its ticks, but delivery is not there yet. On the same game 0.45.0 delivered 13 frames a second at 300 and lost every player at 1,000.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `room.rates` in game.json: a studio's own pace on what one player may send, in messages a second by kind (`{ "in": 60 }`). Leave it out and there is none.
|
|
31
|
+
- `lib/crowd-probe/`: a Worker of its own that plays simulated players against a studio from inside Cloudflare, five a request, and `lib/crowd-cloud.mjs`, which adds the requests up into the report the home driver writes. A trial's tool: no studio's Worker imports it.
|
|
32
|
+
- Each Gate measures the room's clock against its own, the room's ticks a second by its own clock, and the longest it went without a world. The room's own count of its ticks cannot see the room run late.
|
|
33
|
+
- A big room's diagnostics come from an object that is neither the room nor a Gate with players. The room and each Gate post a second's facts to it (ticks a second, how long a player's input took to reach the rules, frames written to each player, how old the world was when a Gate sent it, how far behind players say they are), and `/_studio/api/room` answers from that copy with its age. Measuring a room costs it nothing, and a room too busy to answer still has its last word read.
|
|
34
|
+
- A player's client says which tick it holds (`g` on its inputs and pings). A Gate that finds a player more than a second behind sends that player fewer, fresher frames, down to one a second, and restores the rate when they have caught up. Each frame is a change from the last one sent and carries the player's own body. A client that says nothing is sent every tick, as before.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- In a room behind Gates the room object runs the rules and hands each Gate one world a tick. The Gate chooses what each of its players can see, encodes it and writes it. The room no longer selects, encodes or queues anything per player on its tick.
|
|
39
|
+
- A Gate holds fifty players and Gates fill in order: the room tells the door which Gate has room, so fifty players in a 1,000-seat game are one Gate and a full room is twenty. A studio sets its room's size and nothing else. A room of 64 or fewer is still its Table alone.
|
|
40
|
+
- The world crosses from the room to a Gate as one block of numbers, the same block for every Gate, plus whatever else about a body changed since that Gate last heard: about 33 KB a tick for 1,000 players where the JSON was 150 KB, and almost nothing for the Gate to parse.
|
|
41
|
+
- A body that keeps its course is not sent again. A client built with 0.46.2 moves it along its last velocity, and a Gate sends it only when that has drifted past the game's `view.precisionM` (near bodies) or half the distance it covers between `view.farHz` refreshes (far ones). On the crowd-circuit game at 1,000 players a frame falls from 71 rows to 20, a player from 14.8 KB a second on the wire to 6.3, and a far body is drawn 3 cm from the truth on average where it was 35 cm. A game page built before 0.46.2 is sent every moving body, as before.
|
|
42
|
+
- A Gate works out once for each world what does not depend on the player. Encoding a player's frame takes under a third of the time.
|
|
43
|
+
- Each Gate has its own channel to the room. The relay between them (the Concentrator) is gone: it re-read and re-sent every world for eight Gates and was the first object to fall behind. A studio's `CONCENTRATOR` binding stays declared and unused, so an upgrade needs no migration.
|
|
44
|
+
- A Gate sends the room what its players sent at once when the channel is quiet and never more than one frame in 50 ms, however many players it holds. Receipts ride that frame.
|
|
45
|
+
- Far-view refreshes are spread across players, so a room's largest updates do not all leave on the same tick.
|
|
46
|
+
- A deploy requested in the owner's prompt is approved for that task, including retries. Unrequested deployments remain held; explicit refusals revoke the task approval.
|
|
47
|
+
- The crowd probe offers WebSocket compression, as every browser does, coasts, names the tick it holds, follows the room's word on how early its inputs arrive (as the toolkit's client does), and reports whether the server agreed to compress, what that saved, and the kilobytes a second a player cost.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- Homie no longer paces what a player sends a game whose rules run on the server. 0.45.3 dropped honest input at 60 a second when a slow network delivered a batch late. What players say to each other keeps its pace, and so does every room a player's own browser hosts.
|
|
52
|
+
- A game with fields only their owner may see keeps each player's view in the room when the room is behind Gates. A Gate was handed the whole state.
|
|
53
|
+
- Late callbacks that catch up at the requested tick rate no longer end a healthy room as an overrun; sustained slow ticks still stop.
|
|
54
|
+
- Relay failures log their layer, close cause and queue counters. A link's receipt latency measures the link, not the cadence a receipt waited for. Frozen Cloudflare clocks are not reported as zero execution time.
|
|
55
|
+
- Deployment summaries no longer assume Workers Free. A big room's estimate counts its Gates and its diagnostics object and says which request traffic it leaves out.
|
|
56
|
+
|
|
57
|
+
### Upgrade notes
|
|
58
|
+
|
|
59
|
+
- Rebuild and deploy a studio to get the new room shape; a game needs no change. A game page built before 0.46.2 still plays in a big room: it is sent every tick and every moving body, because it says neither which tick it holds nor that it coasts.
|
|
60
|
+
- A room of 64 seats or fewer, and every room a player's browser hosts, is as it was: none of this is on its path. The one change that reaches a small server-hosted room is that Homie no longer paces what a player sends it.
|
|
61
|
+
- To measure your own game on your own Cloudflare account: `lib/crowd-load.mjs` plays a crowd from your computer and `lib/crowd-probe/` from inside Cloudflare. `lib/crowd-probe/README.md` has the commands and how to delete the probe afterwards.
|
|
62
|
+
- What four trials on Cloudflare measured, what is proven and what is not, is in `docs/crowd-transport-0462.md`.
|
|
63
|
+
|
|
20
64
|
## [0.46.0] - 2026-10-10
|
|
21
65
|
|
|
22
66
|
**Plugin 0.46.0**
|
package/README.md
CHANGED
|
@@ -206,8 +206,9 @@ game its studio chooses to share, each with its own licence ("Game parts", below
|
|
|
206
206
|
|
|
207
207
|
`players.max` in game.json is the room's chosen positive integer capacity; omitted means 8.
|
|
208
208
|
There is no 32-player ceiling or default per-address admission limit. Public games use
|
|
209
|
-
rules plus view with `room.host: "server"`. Gates carry the browser connections
|
|
210
|
-
|
|
209
|
+
rules plus view with `room.host: "server"`. Gates carry the browser connections, each on
|
|
210
|
+
its own channel to the room: the room runs the rules and hands every Gate one world a tick;
|
|
211
|
+
the Gate selects, encodes and paces each player's view. Server rules survive browser departures.
|
|
211
212
|
New browser/offline games use the same rules contract. Existing netplay games and their
|
|
212
213
|
port host helpers remain supported for upgrades, with their original 32-seat limit.
|
|
213
214
|
For larger rooms, follow `guides/game/REWRITE.md`.
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# Rooms milestone 2: releasable slices
|
|
2
|
+
|
|
3
|
+
The opening sections preserve the released slice-1 record. The 0.45.0 slice-2
|
|
4
|
+
implementation, local measurements and remaining work follow below.
|
|
5
|
+
|
|
6
|
+
0.44.0 is slice 1, **spatial state delivery**. It is useful independently in an
|
|
7
|
+
existing server-hosted room: the studio chooses `room.view.radiusM` in game.json,
|
|
8
|
+
and a player's view receives nearby entities and its own body. Unchanged state
|
|
9
|
+
is compressed as deltas. No change to rules or movement is needed. `room.each`,
|
|
10
|
+
`enter` and `leave` describe the visible set, so a view must dispose of departed
|
|
11
|
+
meshes and recreate returning ones. Leaving view does not mean despawning.
|
|
12
|
+
|
|
13
|
+
This first PR does not claim that the public join/build path admits hundreds.
|
|
14
|
+
That path retains milestone 1's 32-seat restriction until slice 2 supplies Gates
|
|
15
|
+
and removes it. The simulation and relay now handle a 300-seat test directly,
|
|
16
|
+
without a simulation seat clamp or truncated rosters/control rows. This is the
|
|
17
|
+
foundation for the next slice, not a substitute for its admission and transport
|
|
18
|
+
proof. No deployment or merge was made.
|
|
19
|
+
|
|
20
|
+
## What ships first
|
|
21
|
+
|
|
22
|
+
- `room.view.radiusM`: a studio-selected radius in metres, including z in 3D.
|
|
23
|
+
Omitted or null means the whole room, with the existing small-room path and
|
|
24
|
+
no extra per-client encoding. Zero means only colocated entities. No default
|
|
25
|
+
radius, downstream budget, spend limit, or crowd admission policy is imposed.
|
|
26
|
+
- The real relay filters both ongoing snapshots and join/rejoin welcome state.
|
|
27
|
+
A player with no body yet sees no entities. Watchers retain the whole-room
|
|
28
|
+
overview in this slice; following a camera does not restrict their feed.
|
|
29
|
+
- Each connection retains one keyframe. Deltas carry changed entity components,
|
|
30
|
+
new entities and explicit removals, against that keyframe. They carry the
|
|
31
|
+
current round and only the receiving player's input acknowledgment/control row.
|
|
32
|
+
- A keyframe at least once per simulation second, and a fresh one at every
|
|
33
|
+
connection or epoch change. Backpressure skips snapshots without advancing the
|
|
34
|
+
encoder. A missing intermediate delta cannot corrupt later updates. A lost
|
|
35
|
+
keyframe causes deltas to be ignored until the next keyframe, at most one
|
|
36
|
+
simulation second later. WebSocket itself is ordered and reliable.
|
|
37
|
+
- Saving and host recovery still use complete authoritative state. Filtering
|
|
38
|
+
never changes simulation, collision, queries, scoreboards or shared state.
|
|
39
|
+
- Seeded, virtual-time correctness in `npm test`; the long 300-client run is in
|
|
40
|
+
`test:rules:extended`. The pre-existing 12-scenario real-time traffic matrix and
|
|
41
|
+
27 handover repetitions move there too, with all assertions retained. The gate
|
|
42
|
+
keeps two actual-browser cases (tab hiding and socket closing); virtual time
|
|
43
|
+
covers the rate/seed/handover matrix. No wall-clock throughput threshold is a
|
|
44
|
+
release gate.
|
|
45
|
+
|
|
46
|
+
Interest is a bandwidth feature, **not hidden information**. Shared state,
|
|
47
|
+
effects, roster and watcher overview are still public. Private fields and tells
|
|
48
|
+
arrive together in slice 3. Browser hosting continues to send complete state;
|
|
49
|
+
requesting a radius there produces a build diagnostic. The existing starters
|
|
50
|
+
need no contract rewrite for slice 1 and keep their whole-room views.
|
|
51
|
+
|
|
52
|
+
## Design decisions and departures
|
|
53
|
+
|
|
54
|
+
The roadmap's sections 5, 6, 7, 10 and 23 are the starting point. Milestone 1
|
|
55
|
+
already separates the host, core, transport-free relay and view. We reuse those
|
|
56
|
+
roles instead of first splitting Table into new abstractions or declaring empty
|
|
57
|
+
Durable Object classes. Gate and concentrator classes arrive together in slice 2,
|
|
58
|
+
when they can actually carry connections and their deploy can be tested.
|
|
59
|
+
|
|
60
|
+
Interest/deltas precede Gates: doing this work in the existing relay gives a
|
|
61
|
+
shippable feature and a measurable role to move. The codec is independent of
|
|
62
|
+
Cloudflare and does not introduce a second runtime or contract profile. A small
|
|
63
|
+
game continues to see the match contract without `uses` or `profile` boilerplate.
|
|
64
|
+
|
|
65
|
+
Deltas name periodic keyframes rather than retaining an acknowledged history
|
|
66
|
+
per player. One baseline bounds memory, intermediates may be skipped safely, and
|
|
67
|
+
reconnect always starts fresh. This trades retransmitted changes and up to one
|
|
68
|
+
simulation second of recovery after a lost keyframe for a much smaller protocol.
|
|
69
|
+
It is not the roadmap's final bandwidth scheduler; near/far tiers, priorities,
|
|
70
|
+
short IDs, watcher follow feeds and shared far encodings remain slice 3 work.
|
|
71
|
+
|
|
72
|
+
The roadmap's default 96-metre visibility radius, default crowd cap from S6,
|
|
73
|
+
per-client bandwidth budget and guest-address cap are not defaults we will
|
|
74
|
+
adopt. They contradict the owner's no-imposed-limits rule. Studios may choose
|
|
75
|
+
limits explicitly. Cloudflare limits and measured capacity are reported as
|
|
76
|
+
information, never turned into a Homie admission ceiling.
|
|
77
|
+
|
|
78
|
+
## Local proof
|
|
79
|
+
|
|
80
|
+
The workload uses the checked and guarded rules loader, real host runtime and
|
|
81
|
+
NetRoom relay, with 300 simulated WebSocket clients, one actual player body per
|
|
82
|
+
seat, 20 Hz seeded inputs, a 20-by-15 grid at four-metre spacing and a chosen
|
|
83
|
+
12-metre radius. Each player moves at up to 2 m/s and increments a field each
|
|
84
|
+
tick. All 300 input acknowledgments, own bodies and roster entries are checked;
|
|
85
|
+
there are no rules errors or cut ticks. The fixture explicitly chooses a
|
|
86
|
+
5,000,000-unit tick budget; this is not a new default or a general game capacity
|
|
87
|
+
claim. Warmup is 20 ticks. Clients keep one baseline and latest state, not a log
|
|
88
|
+
of every received frame.
|
|
89
|
+
|
|
90
|
+
Measurement includes input dispatch, rules, encoding, serialization and all 300
|
|
91
|
+
clients parsing and decoding in the same Node process. Memory is peak sampled
|
|
92
|
+
whole-process heap/RSS, including the harness and rules loader, not isolated
|
|
93
|
+
server memory. Bytes count all downstream frames during the measurement window,
|
|
94
|
+
including snapshot JSON and pings; WebSocket/TLS framing is not included. These
|
|
95
|
+
are **local numbers, not Cloudflare measurements**, not Internet latency, and
|
|
96
|
+
not a rendering or physical-phone capacity result.
|
|
97
|
+
|
|
98
|
+
Measurement command:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
node --input-type=module -e 'import {interestWorkload} from "./packages/studio/test/rules-interest-workload.mjs"; console.log(JSON.stringify(await interestWorkload({ticks:1200,measure:true}),null,2))'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Observed on macOS arm64, Node 22.23.3, 1,200 measured ticks (60 seconds of
|
|
105
|
+
virtual play), plus 20 warmup ticks. Other local verification was running; CPU
|
|
106
|
+
timings are observations, not a throughput guarantee.
|
|
107
|
+
|
|
108
|
+
| Measurement | Local result |
|
|
109
|
+
| --- | ---: |
|
|
110
|
+
| Clients / tick rate | 300 / 20 Hz |
|
|
111
|
+
| Peak visible bodies for one client | 33 |
|
|
112
|
+
| Tick including relay and all client decoding, p50 / p95 / p99 | 14.88 / 21.04 / 25.41 ms |
|
|
113
|
+
| Largest measured tick | 31.98 ms |
|
|
114
|
+
| Downstream per player, mean / highest | 23,171 / 29,117 bytes/s |
|
|
115
|
+
| Peak sampled whole-process heap / RSS | 133,426,952 / 238,764,032 bytes |
|
|
116
|
+
|
|
117
|
+
This does **not** meet the roadmap's eventual 16 KB/s target. Tiers, priority and
|
|
118
|
+
shared encodings in slice 3 have work to do; no target was weakened or represented
|
|
119
|
+
as passed. All 300 clients are simulated, not 300 browser tabs or Cloudflare
|
|
120
|
+
connections.
|
|
121
|
+
|
|
122
|
+
## Release verification
|
|
123
|
+
|
|
124
|
+
- Focused codec, relay and rules tests: 20 passed, no skips.
|
|
125
|
+
- Real view client on virtual time: 189 passed, including the new spatial
|
|
126
|
+
delivery/lost-keyframe/reconnect case. Existing Chrome rendering smoke tests
|
|
127
|
+
run in the root gate; this slice adds no rendering-specific browser case.
|
|
128
|
+
- Plugin: 123 passed, one optional live-URL fixture skipped.
|
|
129
|
+
- Plugin validation, packed Desktop server and release preflight passed;
|
|
130
|
+
preflight lists studio 0.44.0 as the sole new npm version.
|
|
131
|
+
- Template and history generation, changelog sync/check completed.
|
|
132
|
+
- Root `npm test` runs with `CHROME_PATH` pointing to local Chrome. Final root
|
|
133
|
+
results and all six CI checks are tracked on [PR #83](https://github.com/homie-rocks/homie/pull/83).
|
|
134
|
+
- A same-role reconnect now delivers its welcome snapshot to the rules view,
|
|
135
|
+
even though no role-change event fires. The regression blocks later periodic
|
|
136
|
+
keyframes and verifies that welcome-based deltas advance the view immediately.
|
|
137
|
+
|
|
138
|
+
## Remaining slices, in order
|
|
139
|
+
|
|
140
|
+
Each is a separate releasable commit series/PR after its predecessor is released.
|
|
141
|
+
Slices 1 and 2 are implemented. Slice 1 shipped in 0.44.0; this PR prepares
|
|
142
|
+
slice 2 as 0.45.0 on the released 0.44.1 base.
|
|
143
|
+
|
|
144
|
+
1. **0.44.0 — spatial state delivery**, above. Proof: codec oracle, real views
|
|
145
|
+
on virtual time, loss/reconnect, and 300-client host/relay workload.
|
|
146
|
+
2. **0.45.0 — Gates and bigger public rooms (implemented below).** Gate/concentrator classes together,
|
|
147
|
+
reuse the delivery role, batch input and preserve commands, authenticated binding-only admission,
|
|
148
|
+
resume/revocation, and automatic layout from `players.max`. Remove public seat
|
|
149
|
+
caps, make guest policy studio-selected, report tested size in build/deploy
|
|
150
|
+
plans. Prove 300 clients through the actual join path, Gate restart/resend,
|
|
151
|
+
20 Gates through concentrators, and small rooms without extra objects. Preserve
|
|
152
|
+
existing netplay builds, checks, deploys and host/port helpers at their original
|
|
153
|
+
32-seat limit. New games, starters and authoring guides use rules plus view,
|
|
154
|
+
including browser/offline mode.
|
|
155
|
+
3. **Private and scheduled delivery.** Field `see`, reliable one-player `tell`,
|
|
156
|
+
optional studio-chosen downstream budget and priorities (near/far visual
|
|
157
|
+
scheduling shipped in slice 2),
|
|
158
|
+
watcher follow/region and overview feeds, crowd rendering guidance/helpers.
|
|
159
|
+
Update starters where these capabilities apply. Prove no owner/server field
|
|
160
|
+
reaches another player's bytes, tells survive reconnect once, boundary
|
|
161
|
+
enter/leave and fairness under a chosen budget; measure a dense crowd and
|
|
162
|
+
rendering in real browsers. A view leaving interest is not evidence of privacy.
|
|
163
|
+
4. **Crowd policies and vehicles.** Studio-selected `crowd.cap` with queue/hold,
|
|
164
|
+
soft event work scheduling without dropping work, `uses`, solid bodies,
|
|
165
|
+
attach/control/detach and driver prediction. Prove cap absent means no imposed
|
|
166
|
+
cap; explicit queues/holds release correctly; four-person vehicle prediction,
|
|
167
|
+
seat contention, disconnect and restore. Ship one vehicle example and update
|
|
168
|
+
the skill in the same slice.
|
|
169
|
+
5. **Large worlds' files.** Extend existing studio-owned R2 asset serving to game
|
|
170
|
+
files over 25 MiB and multipart upload; streamed map chunks, zones, compiled
|
|
171
|
+
triangle meshes and navigation. Reuse the existing upload/build machinery.
|
|
172
|
+
Prove large asset integrity, interrupted/resumed multipart upload and map
|
|
173
|
+
streaming with the same collision data on server and client.
|
|
174
|
+
6. **Milestone proofs and authoring tools.** Single-machine swarm CLI, match kit,
|
|
175
|
+
cross-browser-engine movement hashing, S6 at 20/60 Hz and multiple regions,
|
|
176
|
+
S11 transport assessment, dense crowd tuning and remaining authoring trials.
|
|
177
|
+
Local correctness stays seeded/virtual; long runs stay extended. The roadmap's
|
|
178
|
+
300-bot, 30-minute Cloudflare exit test, Gate recovery latency, physical phone
|
|
179
|
+
rendering and actual billing need a separately authorized deployment/device
|
|
180
|
+
session. No local receipt here is represented as those results.
|
|
181
|
+
|
|
182
|
+
## Slice 2 (0.45.0): public rooms in the hundreds
|
|
183
|
+
|
|
184
|
+
Implemented:
|
|
185
|
+
|
|
186
|
+
- `players.max` is the single build size, read by public admission, compilation
|
|
187
|
+
and deploy planning without the 32-seat clamp. Shared carrier addresses have
|
|
188
|
+
no automatic player ceiling.
|
|
189
|
+
- Gate and Concentrator classes carry ordered, batched logical connections over
|
|
190
|
+
binding-only links. Public authentication remains in the Worker. Small rooms
|
|
191
|
+
embed the delivery role in the Table; larger layouts use one logical Gate per
|
|
192
|
+
64 seats and concentrators above eight Gates. These are routing partitions,
|
|
193
|
+
not admission limits. The template generates both bindings and migrations.
|
|
194
|
+
- Link loss closes downstream sockets for normal token-based seat resumption.
|
|
195
|
+
Gates do not persist simulation or become a second authority. Gate passes are
|
|
196
|
+
internal binding requests, not independently client-supplied authorizations.
|
|
197
|
+
- Ordered snapshot deltas, shared spatial indexing and cached wire rows reduce
|
|
198
|
+
repeated work. Optional `precisionM`, `nearM`, and `farHz` schedule remote
|
|
199
|
+
visual state; controlled bodies remain exact and independently recoverable in
|
|
200
|
+
every frame. A lost remote delta cannot stall the local body or collider
|
|
201
|
+
revisions. Snapshot exits remain immediate.
|
|
202
|
+
- Revision 12 carries roster patches after a full welcome roster. A reconnect
|
|
203
|
+
storm previously repeated full rosters to every player. The patch baseline is
|
|
204
|
+
the last transmitted roster, separately from policy relabelling.
|
|
205
|
+
|
|
206
|
+
A 300-player manifest (the game still needs appropriate arena,
|
|
207
|
+
spawning, rule cost and rendering):
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"players": { "min": 1, "max": 300 },
|
|
212
|
+
"room": {
|
|
213
|
+
"host": "server",
|
|
214
|
+
"view": { "radiusM": 12, "precisionM": 0.01, "nearM": 4, "farHz": 5 }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The visual settings are examples, not imposed defaults or information privacy.
|
|
220
|
+
The workload explicitly chooses a 5,000,000-unit tick budget as in slice 1.
|
|
221
|
+
|
|
222
|
+
### Local measurements
|
|
223
|
+
|
|
224
|
+
All results below are **local**, not Cloudflare, billing, Internet latency or
|
|
225
|
+
physical-phone results. Bytes exclude WebSocket/TLS framing. Heap/RSS include
|
|
226
|
+
clients and the test harness in the same Node process. Other work on this
|
|
227
|
+
machine affects CPU timing; bytes are deterministic for the virtual workload.
|
|
228
|
+
|
|
229
|
+
| Workload | Players | Mean bytes/player/s | Tick p50 / p95 ms | Peak heap / RSS bytes |
|
|
230
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
231
|
+
| Unmodified 0.44.0, 1,200 virtual ticks | 300 | 23,171 | 12.23 / 16.72 | 134,431,008 / 238,534,656 |
|
|
232
|
+
| Final scheduled/delta delivery, 1,200 virtual ticks | 300 | 9,937 | 10.57 / 15.59 | 129,070,360 / 232,275,968 |
|
|
233
|
+
| Final scheduled/delta delivery, 120 virtual ticks | 1,000 | 10,258 | 33.88 / 52.12 | 190,297,048 / 328,974,336 |
|
|
234
|
+
| Gate links, 4 Chrome pages, reconnect churn and host restore, before roster patches | 300 | 88,444 | 15.79 / 73.47 | 142,211,400 / 375,734,272 |
|
|
235
|
+
| Same mixed transport workload, with roster patches | 300 | 13,623 | 13.52 / 25.33 | 154,397,384 / 274,808,832 |
|
|
236
|
+
|
|
237
|
+
Steady-state downstream falls about **57%** at 300. The two preliminary mixed
|
|
238
|
+
runs include recovery traffic and measured a host wake (potentially several
|
|
239
|
+
ticks); they are not directly comparable to the steady-state pair or the final
|
|
240
|
+
single-tick measurements below. They did not declare rounds. The final harness
|
|
241
|
+
declares a ten-minute round and verifies that failures preserve it.
|
|
242
|
+
|
|
243
|
+
The mixed harness uses the real Gate/Concentrator classes, rules loader,
|
|
244
|
+
host and NetRoom, external local WebSockets and four Chrome pages rendering
|
|
245
|
+
received bodies, with in-memory links standing in for Durable Object bindings.
|
|
246
|
+
Actual public Worker routing and workerd eviction are checked separately below.
|
|
247
|
+
|
|
248
|
+
Commands:
|
|
249
|
+
|
|
250
|
+
```sh
|
|
251
|
+
node packages/studio/test/rules-crowd-local.mjs 300 60
|
|
252
|
+
node packages/studio/test/rules-crowd-local.mjs 1000 60
|
|
253
|
+
node --test packages/studio/test/rules-gates.test.mjs packages/studio/test/rules-interest.test.mjs
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The sustained, real-time 300/1,000 mixed runs live in `test:rules:extended`,
|
|
257
|
+
which runs files sequentially so load experiments do not compete with each other.
|
|
258
|
+
Virtual-time tests cover batching/order, 1,000 logical admissions, link cleanup,
|
|
259
|
+
spatial boundaries, quantisation, far scheduling and keyframe recovery.
|
|
260
|
+
|
|
261
|
+
### Admission, compatibility and recovery proofs
|
|
262
|
+
|
|
263
|
+
Existing browser-hosted netplay games retain the build and runtime path from
|
|
264
|
+
0.44.1, including host helpers, HUD and port import. Their manifest precedence
|
|
265
|
+
and 32-seat limit remain; larger requests print the limit and rewrite guide.
|
|
266
|
+
The generated Worker supplies Gate/Concentrator exports for pre-Gate studio
|
|
267
|
+
entries without rewriting custom Worker source. The release gate uses unchanged Ember Vale files from released 0.32.1, packs and
|
|
268
|
+
installs the current package, builds it and runs the real two-browser check
|
|
269
|
+
through a completed round. Starters/docs teach rules plus view only.
|
|
270
|
+
Migrating the rules fixtures exposed missing TypeScript resolution for shared parts;
|
|
271
|
+
the guarded checker now resolves those same declared parts as the bundler.
|
|
272
|
+
|
|
273
|
+
The public Worker test verifies its actual authorization and capacity selection
|
|
274
|
+
for 300 and 1,000 against Gate bindings, including a private-door refusal and
|
|
275
|
+
ignoring caller-supplied capacity/hosting. Real local workerd tests additionally
|
|
276
|
+
prove 20 Gate binding links through concentrators and a Gate abort, and the full
|
|
277
|
+
public Worker → Gate → Table/SQLite route: 40 seats in a declared 300-player room,
|
|
278
|
+
then Gate death and Table death, with every token resuming its original seat.
|
|
279
|
+
The extended Workerd case also passed all **1,000 actual public admissions**
|
|
280
|
+
across 16 Gates/two concentrators, then a Gate abort (59 returning clients) and
|
|
281
|
+
a Table abort (all 1,000 returning clients), retaining every seat. That fixture
|
|
282
|
+
runs at 1 Hz and is a storage/admission proof, not a 20 Hz capacity benchmark.
|
|
283
|
+
These are functional proofs; the measured full crowds below use in-memory
|
|
284
|
+
internal binding links as described above, not a Cloudflare deployment.
|
|
285
|
+
|
|
286
|
+
Both declared-round mixed receipts now pass:
|
|
287
|
+
|
|
288
|
+
| Local mixed run | Bytes/player/s, all measured traffic | Tick p50 / p95 / max ms | Peak Node heap / RSS bytes | Churn |
|
|
289
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
290
|
+
| 300 players, 4 Chrome pages, 62.93 s | 10,463 | 10.21 / 31.54 / 142.32 | 206,609,760 / 313,114,624 | 15 |
|
|
291
|
+
| 1,000 players, 4 Chrome pages, 101.02 s | 9,203 | 32.97 / 59.29 / 188.74 | 314,542,056 / 573,784,064 | 50 |
|
|
292
|
+
|
|
293
|
+
Each run recovered all players after both failures within the same live round;
|
|
294
|
+
simulated clients retained their seats, and all four real pages delivered live
|
|
295
|
+
snapshot receipts after recovery. The final runs also verify each simulated and
|
|
296
|
+
real client's own body after recovery. The 300 run's initial steady 20-second window
|
|
297
|
+
was 9,274 bytes/player/s; the 1,000 run measured 7,931 over its first 20.19 seconds.
|
|
298
|
+
Recovery-window averages include disconnected intervals,
|
|
299
|
+
so the seeded virtual comparison above is the fair before/after bandwidth pair.
|
|
300
|
+
Mixed tick timing is now one host tick through snapshot enqueue; earlier mixed
|
|
301
|
+
numbers measured a host wake, potentially several ticks. Browser process memory
|
|
302
|
+
is excluded from Node heap/RSS. The 300 run averaged 19.05 simulation ticks/s
|
|
303
|
+
including failures; the 1,000 run averaged 16.27. CPU timings vary with other
|
|
304
|
+
machine activity; 1,000 p95 exceeds a
|
|
305
|
+
50-ms tick period, so this is not a claim of guaranteed 20 Hz on Cloudflare.
|
|
306
|
+
|
|
307
|
+
The earlier failed 1,000 run is superseded. Fixes isolated a single failed
|
|
308
|
+
client send from its entire Gate, compacted identical fanout rows on internal
|
|
309
|
+
links, and made the restart harness reconstruct both saved relay seats and the
|
|
310
|
+
host (as a Table restart does) instead of rejoining every old connection just
|
|
311
|
+
before disconnecting it. Final root and six-check CI results are tracked in
|
|
312
|
+
[PR #85](https://github.com/homie-rocks/homie/pull/85). No deployment or merge
|
|
313
|
+
was performed.
|
|
314
|
+
|
|
315
|
+
Competing full-suite and load runs triggered the real Workerd overrun guard
|
|
316
|
+
and a mixed-run recovery timeout. They are failed stress attempts, not capacity
|
|
317
|
+
receipts. The successful full Workerd rerun kept that guard enabled and ran
|
|
318
|
+
without our other load runs. No Cloudflare CPU, memory or network guarantee is
|
|
319
|
+
inferred from these local results.
|
|
320
|
+
|
|
321
|
+
The scripted recovery checks include deliberate observation waits. All clients
|
|
322
|
+
were back when checked at 20.08 s (Gate) / 20.74 s (rules) at 300, and
|
|
323
|
+
20.10 s / 60.00 s at 1,000. These are upper bounds, not minimum recovery latency.
|
|
324
|
+
The 1,000-client full restart is visibly expensive; faster crowd reconnection
|
|
325
|
+
and deployed latency measurements remain work for the milestone proof slice.
|
|
326
|
+
|
|
327
|
+
## Later step: retire browser-hosted netplay
|
|
328
|
+
|
|
329
|
+
Retirement is not part of 0.45.0. Its preconditions are that every game in
|
|
330
|
+
Homie's own three live studios has moved to server rules, and a release has
|
|
331
|
+
already shipped a one-command assisted rewrite for other studios. Until both
|
|
332
|
+
are met, existing games must continue to build, pass check, run and deploy,
|
|
333
|
+
with the legacy release gate protecting that promise. New games remain rules
|
|
334
|
+
games only.
|
|
335
|
+
|
|
336
|
+
### Upgrade proof on the real studio copy
|
|
337
|
+
|
|
338
|
+
Copied `/Users/ryan/Studios/homie-arcade` to
|
|
339
|
+
`/tmp/homie-arcade-045-0Vaqlp/studio`, packed and installed 0.45.0 there.
|
|
340
|
+
`npm run build` passed for all five existing netplay games.
|
|
341
|
+
`npm run check -- --url http://127.0.0.1:8898` passed for its default game,
|
|
342
|
+
2048 Race: desktop and phone finished round 1 in 123 seconds, with both
|
|
343
|
+
connections uninterrupted. The original studio was not changed; nothing was
|
|
344
|
+
deployed. The released 0.32.1 Ember Vale gate also passed with an older Worker
|
|
345
|
+
entry: one host and one replica finished round 1 in 92 seconds.
|
package/guides/game/RULES.md
CHANGED
|
@@ -780,8 +780,17 @@ Rules, collision, saves and shared state stay authoritative and unchanged.
|
|
|
780
780
|
Views must interpolate distant motion and dispose/recreate entities on view exit
|
|
781
781
|
and entry. Omit the settings for full precision and the room's normal tick rate.
|
|
782
782
|
|
|
783
|
-
A small room embeds delivery in its Table. Larger rooms open Gate connections
|
|
784
|
-
|
|
783
|
+
A small room embeds delivery in its Table. Larger rooms open Gate connections, one channel
|
|
784
|
+
from each Gate to the room: the room runs the rules and hands each Gate one world a tick, and
|
|
785
|
+
the Gate selects, encodes and paces each player's view. A player on a slow connection is sent
|
|
786
|
+
fewer, fresher frames, never a growing backlog; draw from the newest snapshot and interpolate.
|
|
787
|
+
A body that keeps its course is not sent every tick: the view library moves it along its last
|
|
788
|
+
velocity, and it is sent again when that has drifted past `view.precisionM` (near bodies) or half
|
|
789
|
+
the distance it covers between `view.farHz` refreshes (far ones). `precisionM` is what a crowd
|
|
790
|
+
costs a player's connection: 0.01 is a centimetre; 0.05 sends about a sixth fewer bytes.
|
|
791
|
+
Homie sets no pace on what a player sends a server-hosted game. To set your own, add
|
|
792
|
+
`"rates": { "in": 60 }` to `room` in game.json: messages a second per player, by frame kind.
|
|
793
|
+
The studio template supplies the Gate exports, bindings and
|
|
785
794
|
migration; upgrade the template before deploying an existing studio. No infrastructure naming or studio admission ceiling is required.
|
|
786
795
|
Read the local measurements and their limitations in
|
|
787
796
|
`docs/rooms-milestone-2-notes.md` before making a capacity claim.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Content identity of the runtime, independent of a studio's package pin or git commit. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
7
|
+
export function runtimeIdentity() {
|
|
8
|
+
const hash = createHash('sha256');
|
|
9
|
+
function walk(dir) {
|
|
10
|
+
for (const entry of readdirSync(join(root, dir), {
|
|
11
|
+
withFileTypes: true
|
|
12
|
+
}).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
13
|
+
const path = dir + '/' + entry.name;
|
|
14
|
+
if (entry.isDirectory()) walk(path);
|
|
15
|
+
else if (/\.(mjs|ts|json)$/.test(path) && path !== 'worker/build-id.mjs')
|
|
16
|
+
hash
|
|
17
|
+
.update(path + '\0')
|
|
18
|
+
.update(readFileSync(join(root, path)))
|
|
19
|
+
.update('\0');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (const dir of ['worker', 'rules', 'netplay']) walk(dir);
|
|
23
|
+
return {
|
|
24
|
+
version: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
25
|
+
.version,
|
|
26
|
+
sha256: hash.digest('hex')
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function stampRuntime(check = false) {
|
|
30
|
+
const text =
|
|
31
|
+
'// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.\nexport const STUDIO_BUILD = Object.freeze(' +
|
|
32
|
+
JSON.stringify(runtimeIdentity()) +
|
|
33
|
+
');\n',
|
|
34
|
+
path = join(root, 'worker/build-id.mjs');
|
|
35
|
+
if (check) {
|
|
36
|
+
if (readFileSync(path, 'utf8') !== text)
|
|
37
|
+
throw Error(
|
|
38
|
+
'Runtime identity is stale: node packages/studio/lib/build-identity.mjs --write'
|
|
39
|
+
);
|
|
40
|
+
} else writeFileSync(path, text);
|
|
41
|
+
}
|
|
42
|
+
if (process.argv[1] === fileURLToPath(import.meta.url))
|
|
43
|
+
stampRuntime(process.argv.includes('--check'));
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { roomLayout } from '../worker/gate.mjs';
|
|
1
2
|
import {workerEntry} from './worker-build.mjs';
|
|
2
3
|
import { seatCount } from '../worker/seats.mjs';
|
|
3
4
|
import { refreshWorkerConfig } from './worker-config.mjs';
|
|
@@ -175,9 +176,12 @@ export async function zonePlan(root, opts = {}) {
|
|
|
175
176
|
*/
|
|
176
177
|
export function hostedUse({ seats = 8, inputHz = 20 } = {}) {
|
|
177
178
|
const requests = Math.round((seats * (inputHz * 3600 + 1800)) / 20);
|
|
178
|
-
const
|
|
179
|
+
const layout = roomLayout(seats);
|
|
180
|
+
// A room behind Gates: the room, its Gates, and the object that keeps its diagnostics.
|
|
181
|
+
const objects = 1 + (layout.gates > 1 ? layout.gates + 1 : 0);
|
|
182
|
+
const gbSeconds = 0.128 * 3600 * objects;
|
|
179
183
|
const pct = (n, of) => (n / of < 0.001 ? 'under 0.1%' : `${Math.round((n / of) * 1000) / 10}%`);
|
|
180
|
-
return { seats, requests, gbSeconds, rows: 12, share: { requests: pct(requests, 100_000), gbSeconds: pct(gbSeconds, 13_000), rows: pct(12, 100_000) } };
|
|
184
|
+
return { seats, requests, gbSeconds, objects, layout, requestBasis: 'player input and ping only; relay frames, receipts, HTTP, saves and Lobby are additional', rows: 12, share: { requests: pct(requests, 100_000), gbSeconds: pct(gbSeconds, 13_000), rows: pct(12, 100_000) } };
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
/** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
|
|
@@ -197,18 +201,16 @@ export function deployPlan(root) {
|
|
|
197
201
|
return {
|
|
198
202
|
ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
|
|
199
203
|
cloudflare: [
|
|
200
|
-
{ kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`)
|
|
201
|
-
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`)
|
|
202
|
-
{ kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker'
|
|
203
|
-
...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `
|
|
204
|
-
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker'
|
|
204
|
+
{ kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`) },
|
|
205
|
+
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`) },
|
|
206
|
+
{ kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker' },
|
|
207
|
+
...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `One full ${h.seats}-player room activates ${h.objects} objects (${h.layout.gates > 1 ? `${h.layout.gates} Gates, the room and its diagnostics` : 'the room'}): approximately ${h.gbSeconds} GB-seconds/hour before Lobby activity. Player input/ping alone contributes ${h.requests} request units/hour; relay frames and receipts, HTTP and storage are additional. Occupancy, batching and account usage determine the bill.`, state: 'in the Worker' })),
|
|
208
|
+
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker' },
|
|
205
209
|
storage
|
|
206
210
|
? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
207
211
|
: { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
|
|
208
212
|
],
|
|
209
|
-
cost:
|
|
210
|
-
? `Free on Cloudflare's Workers Free plan. ${R2_COST} Beyond the free tier Cloudflare bills the account directly.`
|
|
211
|
-
: 'Free: everything above is on Cloudflare\'s Workers Free plan, which needs no payment method. Its daily limits (100,000 Worker requests; D1 5 million rows read and 100,000 written) reset at 00:00 UTC; past them requests fail until the reset, nothing is charged.',
|
|
213
|
+
cost: 'Account subscription not queried. Cloudflare bills usage under the account’s current plan; estimates are not a billing quote.',
|
|
212
214
|
login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
|
|
213
215
|
address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
|
|
214
216
|
directory: {
|
|
@@ -299,7 +301,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
299
301
|
const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
|
|
300
302
|
announced.push(
|
|
301
303
|
`First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
|
|
302
|
-
'
|
|
304
|
+
'Account subscription not queried; usage is billed under the account’s current Cloudflare plan.',
|
|
303
305
|
`The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
|
|
304
306
|
);
|
|
305
307
|
for (const line of announced) log(line);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One crowd step played from inside Cloudflare, through the in-Cloudflare probe (lib/crowd-probe/README.md).
|
|
4
|
+
*
|
|
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]
|
|
7
|
+
*
|
|
8
|
+
* It opens a request to the probe for every five players, holds them open for the step, and writes report.json in
|
|
9
|
+
* the shape crowd-load.mjs writes (what applies: there is no browser and no home connection in this one).
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { Tally } from './crowd-probe/client.mjs';
|
|
15
|
+
|
|
16
|
+
const pick = (sorted, q) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))] : null);
|
|
17
|
+
|
|
18
|
+
/** 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
|
+
for (const f of finals) for (const key of Object.keys(tallies)) tallies[key].merge(f.tallies?.[key]);
|
|
22
|
+
const of = (key) => players.map((p) => p[key]).filter(Number.isFinite).sort((a, b) => a - b);
|
|
23
|
+
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 }; };
|
|
24
|
+
const sum = (key) => players.reduce((a, p) => a + (p[key] ?? 0), 0), leads = of('lead');
|
|
25
|
+
const errors = new Map();
|
|
26
|
+
for (const p of players) { for (const text of p.errors ?? []) errors.set(text, (errors.get(text) ?? 0) + 1); if (p.closed && p.closed[1] !== 'probe step complete') { const text = `close: ${p.closed[0]} ${p.closed[1]}`; errors.set(text, (errors.get(text) ?? 0) + 1); } }
|
|
27
|
+
for (const text of failures) errors.set(text, (errors.get(text) ?? 0) + 1);
|
|
28
|
+
const joined = players.filter((p) => p.firstStateMs !== null).length, measured = Math.max(0, (endAt - measureAt) / 1000);
|
|
29
|
+
return {
|
|
30
|
+
generator: 'cloudflare', colos: [...new Set(finals.map((f) => f.colo).filter(Boolean))],
|
|
31
|
+
started: new Date(started).toISOString(), measurementStarted: new Date(measureAt).toISOString(), ended: new Date(endAt).toISOString(),
|
|
32
|
+
origin, room, n, total: n, simulated: n, chromePlayers: 0, toolkit, requestedSeconds: seconds, measuredSeconds: measured, stopReason: finals.length ? null : 'no probe answered',
|
|
33
|
+
joined, connectedAtEnd: players.filter((p) => p.firstStateMs !== null && (!p.closed || p.closed[1] === 'probe step complete')).length,
|
|
34
|
+
disconnects: players.filter((p) => p.closed && p.closed[1] !== 'probe step complete').length,
|
|
35
|
+
firstStateMs: (() => { const a = of('firstStateMs'); return { count: a.length, p50: pick(a, 0.5), p95: pick(a, 0.95), p99: pick(a, 0.99), max: a.at(-1) ?? null }; })(),
|
|
36
|
+
inputAckMs: tallies.ack.json(), receiveGapMs: tallies.gaps.json(),
|
|
37
|
+
// From the tick a player held when it stamped an input to the tick that input reached the rules.
|
|
38
|
+
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 },
|
|
43
|
+
inputLead: { ticksMedian: pick(leads, 0.5), ticksMax: leads.at(-1) ?? null, lateShare: sum('reports') ? Math.round(sum('late') / sum('reports') * 1000) / 1000 : null },
|
|
44
|
+
driver: { requests: finals.length, failedRequests: failures.length, workerLoopP99Ms: [], peakTimerLagMs: finals.map((f) => f.worstTimerLagMs) },
|
|
45
|
+
errors: [...errors].map(([text, count]) => ({ text, count })),
|
|
46
|
+
players,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
51
|
+
const opts = {};
|
|
52
|
+
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');
|
|
56
|
+
await mkdir(out, { recursive: true });
|
|
57
|
+
const hello = await fetch(`${probe}/hello`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(15000) });
|
|
58
|
+
if (!hello.ok) throw Error(`The probe did not answer (${hello.status}): is it deployed, and is the key its PROBE_KEY?`);
|
|
59
|
+
let toolkit = null;
|
|
60
|
+
try { toolkit = (await (await fetch(`${origin}/.well-known/homie-studio.json?probe=${Date.now()}`, { signal: AbortSignal.timeout(15000) })).json()).toolkit ?? null; } catch { /* reported as null */ }
|
|
61
|
+
// Every request is told the same moments: when its players join (one every ramp-ms), when to start measuring
|
|
62
|
+
// (fifteen seconds after the last has joined) and when to stop.
|
|
63
|
+
const started = Date.now(), joinAt = started + 5000, measureAt = joinAt + n * rampMs + 15000, endAt = measureAt + seconds * 1000;
|
|
64
|
+
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();
|
|
66
|
+
await Promise.all(Array.from({ length: Math.ceil(n / 5) }, async (_, r) => {
|
|
67
|
+
const from = r * 5, count = Math.min(5, n - from);
|
|
68
|
+
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) });
|
|
70
|
+
if (!res.ok || !res.body) throw Error(`probe HTTP ${res.status}`);
|
|
71
|
+
let text = '', final = null;
|
|
72
|
+
for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
|
|
73
|
+
text += chunk;
|
|
74
|
+
for (let at = text.indexOf('\n'); at >= 0; at = text.indexOf('\n')) {
|
|
75
|
+
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);
|
|
77
|
+
}
|
|
78
|
+
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
|
+
}
|
|
80
|
+
if (!final) throw Error('the probe ended without its report');
|
|
81
|
+
finals.push(final);
|
|
82
|
+
} catch (error) { failures.push(`probe request ${from}: ${String(error?.message ?? error).slice(0, 160)}`); }
|
|
83
|
+
}));
|
|
84
|
+
const report = cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures, toolkit });
|
|
85
|
+
await writeFile(`${out}/report.json`, JSON.stringify(report, null, 2));
|
|
86
|
+
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
|
+
if (!finals.length) process.exitCode = 1;
|
|
88
|
+
}
|