@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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fixed piece of work, the same everywhere it runs: what a room's thread spends its time on, in miniature (parsing
|
|
3
|
+
* players' small messages, building and reading plain objects, arithmetic on floats, writing a block of text).
|
|
4
|
+
*
|
|
5
|
+
* A Worker cannot time a piece of its own work (its clock stands still while its code runs, and reads a timer's due
|
|
6
|
+
* time when the timer fires), so nothing measured on a laptop says by itself what a room costs on Cloudflare. This
|
|
7
|
+
* runs on both: the probe does it inside Cloudflare while `crowd-cloud.mjs --calibrate` holds the stopwatch on the
|
|
8
|
+
* request, and the same call is timed on the computer that asked. The ratio turns `test/crowd-room-cost.mjs`'s
|
|
9
|
+
* milliseconds into Cloudflare's.
|
|
10
|
+
*
|
|
11
|
+
* It returns a number that depends on every step, so no engine may skip the work, and the same number everywhere.
|
|
12
|
+
*/
|
|
13
|
+
export function referenceWork(rounds = 40) {
|
|
14
|
+
let check = 0, seed = 20261011;
|
|
15
|
+
const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
|
|
16
|
+
for (let round = 0; round < rounds; round += 1) {
|
|
17
|
+
// A tick's inputs: five hundred small messages parsed, held to numbers, queued by seat.
|
|
18
|
+
const queues = new Map();
|
|
19
|
+
for (let i = 0; i < 500; i += 1) {
|
|
20
|
+
const text = JSON.stringify({ t: 'in', e: 3914181188, k: 3000 + round, s: [[0, i % 2 ? 127 : -127, Math.round(random() * 70 - 35)]], r: i & 7, g: 2997 + round });
|
|
21
|
+
const m = JSON.parse(text), seat = i * 2;
|
|
22
|
+
const entry = { at: m.k >>> 0, values: { ax: Math.max(-127, Math.min(127, m.s[0][1] | 0)), ay: Math.max(-127, Math.min(127, m.s[0][2] | 0)) } };
|
|
23
|
+
let q = queues.get(seat); if (!q) { q = []; queues.set(seat, q); }
|
|
24
|
+
q.push(entry); check = (check + entry.values.ax + entry.values.ay + text.length) | 0;
|
|
25
|
+
}
|
|
26
|
+
// A tick of a thousand bodies: frozen vectors made and read, a little float arithmetic each.
|
|
27
|
+
const bodies = [];
|
|
28
|
+
for (let i = 0; i < 1000; i += 1) {
|
|
29
|
+
const q = queues.get(i), held = q ? q[q.length - 1].values : { ax: 0, ay: 0 };
|
|
30
|
+
const vel = Object.freeze({ x: Math.fround(held.ax / 127 * 6), y: Math.fround(held.ay / 127 * 6), z: 0 });
|
|
31
|
+
const pos = Object.freeze({ x: Math.fround(random() * 16 - 8 + vel.x * 0.05), y: Math.fround(random() * 120 - 60 + vel.y * 0.05), z: 0 });
|
|
32
|
+
const speed = Math.hypot(vel.x, vel.y);
|
|
33
|
+
bodies.push([`r${i}`, 0, round & 0xffff, [pos.x, pos.y], [vel.x, vel.y], [speed > 0 ? vel.x / speed : 1, speed > 0 ? vel.y / speed : 0], 0, [round, pos.x > 0], [], i, 0]);
|
|
34
|
+
check = (check + Math.round(pos.x * 100) + Math.round(pos.y * 100)) | 0;
|
|
35
|
+
}
|
|
36
|
+
// The world as it leaves: a block of numbers, and the room's checkpoint as text.
|
|
37
|
+
const block = new Float32Array(bodies.length * 6);
|
|
38
|
+
for (const [i, b] of bodies.entries()) block.set([b[3][0], b[3][1], b[4][0], b[4][1], b[5][0], b[5][1]], i * 6);
|
|
39
|
+
const bytes = new Uint8Array(block.buffer); let sum = 0;
|
|
40
|
+
for (let i = 0; i < bytes.length; i += 97) sum += bytes[i];
|
|
41
|
+
const text = JSON.stringify(bodies);
|
|
42
|
+
check = (check + sum + text.length + JSON.parse(text).length) | 0;
|
|
43
|
+
}
|
|
44
|
+
return check;
|
|
45
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# A crowd trial of one game of a deployed studio, on your own Cloudflare account: each step from your computer and
|
|
3
|
+
# from inside Cloudflare, the in-Cloudflare steps three times each (one run does not tell a limit from luck), a
|
|
4
|
+
# step with slow players among healthy ones, the room's own figures a second beside every step, how fast Cloudflare
|
|
5
|
+
# ran a fixed piece of work against this computer, and one summary.md at the end.
|
|
6
|
+
#
|
|
7
|
+
# It deploys nothing and changes nothing in the studio. It writes under reports/<game>/trial/<stamp>/ in the
|
|
8
|
+
# studio's folder, creates temporary office keys for the diagnostics and revokes them.
|
|
9
|
+
#
|
|
10
|
+
# cd <your studio> # deployed, with this toolkit installed
|
|
11
|
+
# (cd node_modules/@homie-rocks/studio/lib/crowd-probe && KEY=$(openssl rand -hex 24) && npx wrangler deploy --var PROBE_KEY:$KEY)
|
|
12
|
+
# URL=https://<studio> WORKER=<its worker's name> PROBE_URL=https://homie-crowd-probe.<account>.workers.dev PROBE_KEY=$KEY \
|
|
13
|
+
# bash node_modules/@homie-rocks/studio/lib/crowd-probe/run-trial.sh
|
|
14
|
+
# npx wrangler delete --name homie-crowd-probe # when the trial is over
|
|
15
|
+
#
|
|
16
|
+
# Without PROBE_URL only the steps from this computer run. The players steer as the crowd-circuit fixture does:
|
|
17
|
+
# for another game, change the input lib/crowd-probe/client.mjs and lib/crowd-worker.mjs send.
|
|
18
|
+
#
|
|
19
|
+
# GAME=crowd-circuit the game's id
|
|
20
|
+
# PLAN=$'300 home\n600 cloud 3' a different plan: "players mode [runs]" a line (modes below)
|
|
21
|
+
# LABEL=evening a word added to the report folder's name
|
|
22
|
+
# SECONDS_PER_STEP=150 steady measurement per step
|
|
23
|
+
# EXPECTED_RUNTIME_SHA=<sha256> stop unless the installed and the deployed toolkit are this build
|
|
24
|
+
# CHROME_PATH=/path/to/chrome for the two real Chrome players in a step from this computer
|
|
25
|
+
#
|
|
26
|
+
# Read docs/crowd-transport-0462.md first for what each column can and cannot say. In short: nothing an object
|
|
27
|
+
# measures about itself by its own clock is to be trusted on Cloudflare; a Gate's round trip to the room, the share
|
|
28
|
+
# of worlds over 200 ms old and the players' own loop are the figures that show a room out of time.
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
[ -d node_modules/@homie-rocks/studio ] || { echo 'Run this in a studio folder with @homie-rocks/studio installed.'; exit 1; }
|
|
31
|
+
: "${URL:?URL=https://<your studio>}"
|
|
32
|
+
: "${WORKER:?WORKER=<the name of the studio Worker, as in wrangler.jsonc>}"
|
|
33
|
+
GAME="${GAME:-crowd-circuit}"
|
|
34
|
+
export WRANGLER_SEND_METRICS=false
|
|
35
|
+
# The probe inflates every frame, as a browser does; give zlib's pool room.
|
|
36
|
+
export UV_THREADPOOL_SIZE=16
|
|
37
|
+
TOOLKIT="$PWD/node_modules/@homie-rocks/studio"
|
|
38
|
+
SECONDS_PER_STEP="${SECONDS_PER_STEP:-150}"
|
|
39
|
+
PROBE_URL="${PROBE_URL:-}"
|
|
40
|
+
PROBE_KEY="${PROBE_KEY:-}"
|
|
41
|
+
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
|
42
|
+
OUT="$PWD/reports/$GAME/trial/$STAMP${LABEL:+-$LABEL}"
|
|
43
|
+
mkdir -p "$OUT"
|
|
44
|
+
|
|
45
|
+
# 1. The deployed Worker is this toolkit, byte for byte.
|
|
46
|
+
node --input-type=module - "$URL" "$TOOLKIT" "$OUT" <<'JS'
|
|
47
|
+
import { pathToFileURL } from 'node:url';
|
|
48
|
+
import { writeFile } from 'node:fs/promises';
|
|
49
|
+
const [url, toolkit, out] = process.argv.slice(2);
|
|
50
|
+
const { STUDIO_BUILD } = await import(pathToFileURL(`${toolkit}/worker/build-id.mjs`));
|
|
51
|
+
const expected = process.env.EXPECTED_RUNTIME_SHA;
|
|
52
|
+
if (expected && STUDIO_BUILD.sha256 !== expected) throw Error(`Installed toolkit's runtime is ${STUDIO_BUILD.sha256}, not this build's ${expected}: install that build first`);
|
|
53
|
+
const response = await fetch(`${url}/.well-known/homie-studio.json?verify=${Date.now()}`);
|
|
54
|
+
if (!response.ok) throw Error(`manifest HTTP ${response.status}`);
|
|
55
|
+
const manifest = await response.json();
|
|
56
|
+
await writeFile(`${out}/deployment.json`, JSON.stringify(manifest, null, 2));
|
|
57
|
+
if (manifest.toolkit?.sha256 !== STUDIO_BUILD.sha256 || manifest.toolkit?.version !== STUDIO_BUILD.version)
|
|
58
|
+
throw Error(`Wrong deployed toolkit: expected ${JSON.stringify(STUDIO_BUILD)}, got ${JSON.stringify(manifest.toolkit)}`);
|
|
59
|
+
console.log('Verified deployed runtime', STUDIO_BUILD);
|
|
60
|
+
JS
|
|
61
|
+
|
|
62
|
+
# What this connection carries: twelve parallel downloads from Cloudflare for eight seconds, to nowhere.
|
|
63
|
+
probe_link() {
|
|
64
|
+
local file="$1" tmp start end bytes pids=""
|
|
65
|
+
tmp=$(mktemp -d)
|
|
66
|
+
start=$(node -e 'console.log(Date.now())')
|
|
67
|
+
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
|
68
|
+
curl -s -o /dev/null -H 'Referer: https://speed.cloudflare.com/' -A 'Mozilla/5.0' --max-time 8 \
|
|
69
|
+
-w '%{size_download}\n' 'https://speed.cloudflare.com/__down?bytes=100000000' > "$tmp/$i" 2>/dev/null &
|
|
70
|
+
pids="$pids $!"
|
|
71
|
+
done
|
|
72
|
+
wait $pids
|
|
73
|
+
end=$(node -e 'console.log(Date.now())')
|
|
74
|
+
bytes=$(cat "$tmp"/* 2>/dev/null | awk '{s+=$1} END {print s+0}')
|
|
75
|
+
rm -rf "$tmp"
|
|
76
|
+
node -e '
|
|
77
|
+
const [file, bytes, start, end] = process.argv.slice(1);
|
|
78
|
+
const seconds = (end - start) / 1000, down = bytes > 0 && seconds > 0 ? bytes / seconds / 1e6 : null;
|
|
79
|
+
require("fs").writeFileSync(file, JSON.stringify({ at: new Date().toISOString(), streams: 12, seconds, bytes: Number(bytes), downMBps: down }, null, 2));
|
|
80
|
+
console.log("Link before this step:", down === null ? "not measured" : down.toFixed(2) + " MB/s (" + (down * 8).toFixed(0) + " Mbit/s)");
|
|
81
|
+
' "$file" "$bytes" "$start" "$end"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
TAIL_PID=
|
|
85
|
+
DIAG_PID=
|
|
86
|
+
cleanup() {
|
|
87
|
+
if [ -n "$DIAG_PID" ]; then kill -TERM "$DIAG_PID" 2>/dev/null || true; wait "$DIAG_PID" 2>/dev/null || true; fi
|
|
88
|
+
if [ -n "$TAIL_PID" ]; then kill -TERM "$TAIL_PID" 2>/dev/null || true; wait "$TAIL_PID" 2>/dev/null || true; fi
|
|
89
|
+
# Whatever finished is summarised, even when a step failed.
|
|
90
|
+
node "$TOOLKIT/lib/crowd-summary.mjs" "$OUT" || true
|
|
91
|
+
printf 'All reports: %s\nSummary: %s\n' "$OUT" "$OUT/summary.md"
|
|
92
|
+
}
|
|
93
|
+
trap cleanup EXIT
|
|
94
|
+
trap 'exit 130' INT TERM
|
|
95
|
+
|
|
96
|
+
# The tail is sampled by Cloudflare under load and is not the record; diagnostics.jsonl is.
|
|
97
|
+
# Only failures are kept, so the sample is not spent on healthy requests.
|
|
98
|
+
./node_modules/.bin/wrangler tail "$WORKER" --format json --status error > "$OUT/worker-tail.jsonl" 2> "$OUT/worker-tail.stderr" &
|
|
99
|
+
TAIL_PID=$!
|
|
100
|
+
sleep 3
|
|
101
|
+
kill -0 "$TAIL_PID" 2>/dev/null || { printf 'Worker tail did not start (the run continues without it): %s\n' "$OUT/worker-tail.stderr"; TAIL_PID=; }
|
|
102
|
+
# One step: the diagnostics collector beside a driver. $1 the step's folder, $2 the room, the rest the driver's command.
|
|
103
|
+
run_step() {
|
|
104
|
+
local STEP="$1" ROOM="$2"; shift 2
|
|
105
|
+
mkdir -p "$STEP"
|
|
106
|
+
# The collector keeps a failed poll as a row and carries on; it is stopped when the step ends.
|
|
107
|
+
node "$TOOLKIT/lib/crowd-diagnostics.mjs" --root "$PWD" --url "$URL" \
|
|
108
|
+
--game "$GAME" --room "$ROOM" --seconds $((SECONDS_PER_STEP + 600)) --out "$STEP/diagnostics.jsonl" \
|
|
109
|
+
> "$STEP/diagnostics.log" 2>&1 &
|
|
110
|
+
DIAG_PID=$!
|
|
111
|
+
local READY=0 WAIT
|
|
112
|
+
for WAIT in $(seq 1 60); do
|
|
113
|
+
if [ -s "$STEP/diagnostics.jsonl" ]; then READY=1; break; fi
|
|
114
|
+
kill -0 "$DIAG_PID" 2>/dev/null || break
|
|
115
|
+
sleep 1
|
|
116
|
+
done
|
|
117
|
+
if [ "$READY" -ne 1 ]; then printf 'Diagnostics did not start for %s; the step runs without them:\n' "$ROOM"; cat "$STEP/diagnostics.log" || true; fi
|
|
118
|
+
local STATUS=0
|
|
119
|
+
"$@" > "$STEP.log" 2>&1 || STATUS=$?
|
|
120
|
+
printf '%s\n' "$STATUS" > "$STEP/driver-exit-code.txt"
|
|
121
|
+
kill -TERM "$DIAG_PID" 2>/dev/null || true
|
|
122
|
+
local DIAG_STATUS=0
|
|
123
|
+
wait "$DIAG_PID" 2>/dev/null || DIAG_STATUS=$?
|
|
124
|
+
printf '%s\n' "$DIAG_STATUS" > "$STEP/diagnostics-exit-code.txt"
|
|
125
|
+
DIAG_PID=
|
|
126
|
+
local ROWS FAILED
|
|
127
|
+
ROWS=$(wc -l < "$STEP/diagnostics.jsonl" 2>/dev/null | tr -d ' ' || echo 0)
|
|
128
|
+
FAILED=$(grep -c '"ok":false' "$STEP/diagnostics.jsonl" 2>/dev/null || true)
|
|
129
|
+
printf '%s finished (driver exit %s; diagnostics %s rows, %s failed polls): %s\n' "$ROOM" "$STATUS" "$ROWS" "${FAILED:-0}" "$STEP/report.json"
|
|
130
|
+
if [ -n "$TAIL_PID" ] && ! kill -0 "$TAIL_PID" 2>/dev/null; then
|
|
131
|
+
printf 'Worker tail stopped; inspect %s\n' "$OUT/worker-tail.stderr"; TAIL_PID=
|
|
132
|
+
fi
|
|
133
|
+
# A Gate gives its channel to the room back ten seconds after its last player leaves: the next step starts clean.
|
|
134
|
+
sleep 15
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if [ -n "$PROBE_URL" ]; then
|
|
138
|
+
HELLO=$(curl -fsS -H "x-probe-key: $PROBE_KEY" "$PROBE_URL/hello" || true)
|
|
139
|
+
case "$HELLO" in
|
|
140
|
+
*'"probe":2'*) ;;
|
|
141
|
+
'') printf 'The probe at %s did not answer with that key; running from this computer only.\n' "$PROBE_URL"; PROBE_URL= ;;
|
|
142
|
+
*) printf 'The probe at %s is an older one (%s): deploy it again from the installed toolkit (lib/crowd-probe): it has the slow players and the calibration.\n' "$PROBE_URL" "$HELLO"; exit 1 ;;
|
|
143
|
+
esac
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
# How fast Cloudflare runs a fixed piece of work against this computer: $1 the file. Eight probe requests.
|
|
147
|
+
calibrate() {
|
|
148
|
+
[ -n "$PROBE_URL" ] || return 0
|
|
149
|
+
node "$TOOLKIT/lib/crowd-cloud.mjs" --probe "$PROBE_URL" --key "$PROBE_KEY" --calibrate "$1" || true
|
|
150
|
+
}
|
|
151
|
+
uptime > "$OUT/load-average-start.txt" || true
|
|
152
|
+
calibrate "$OUT/calibration-start.json"
|
|
153
|
+
|
|
154
|
+
# The plan: one step a line, "players mode [repeat]". Modes:
|
|
155
|
+
# home this computer, compression on (what one home connection carries)
|
|
156
|
+
# home-slow the same with every tenth player's socket not read for four seconds in five (a real slow socket)
|
|
157
|
+
# home-plain this computer, compression off
|
|
158
|
+
# cloud inside Cloudflare, compression off
|
|
159
|
+
# cloud-deflate inside Cloudflare, compression on
|
|
160
|
+
# cloud-slow, cloud-deflate-slow the same with every tenth player slow (it takes six frames a second)
|
|
161
|
+
# The cloud steps run three times each: one run of each was not enough to tell a limit from luck.
|
|
162
|
+
# From home: sizes one connection carries with room to spare. A thousand players on one connection measure the connection.
|
|
163
|
+
PLAN="${PLAN:-300 home
|
|
164
|
+
300 home-slow
|
|
165
|
+
600 home
|
|
166
|
+
800 cloud 3
|
|
167
|
+
800 cloud-deflate 3
|
|
168
|
+
900 cloud 3
|
|
169
|
+
900 cloud-deflate 3
|
|
170
|
+
1000 cloud 3
|
|
171
|
+
1000 cloud-deflate 3
|
|
172
|
+
1000 cloud-slow
|
|
173
|
+
1000 cloud-deflate-slow}"
|
|
174
|
+
printf '%s\n' "$PLAN" | while read -r N MODE REPEAT; do
|
|
175
|
+
[ -n "$N" ] || continue
|
|
176
|
+
for RUN in $(seq 1 "${REPEAT:-1}"); do
|
|
177
|
+
SUFFIX=""; [ "$RUN" -gt 1 ] && SUFFIX="-r$RUN"
|
|
178
|
+
case "$MODE" in
|
|
179
|
+
home|home-plain|home-slow)
|
|
180
|
+
STEP="$OUT/n$N"; COMPRESS=on; EXTRA=()
|
|
181
|
+
if [ "$MODE" = home-plain ]; then STEP="$OUT/n$N-home-plain"; COMPRESS=off; fi
|
|
182
|
+
if [ "$MODE" = home-slow ]; then STEP="$OUT/n$N-home-slow"; EXTRA=(--slow-every 10); fi
|
|
183
|
+
STEP="$STEP$SUFFIX"
|
|
184
|
+
mkdir -p "$STEP"
|
|
185
|
+
uptime > "$STEP/load-average.txt" || true
|
|
186
|
+
probe_link "$STEP/link.json" || true
|
|
187
|
+
# A full room is 998 simulated and two Chrome players.
|
|
188
|
+
SIZE=(--n "$N")
|
|
189
|
+
if [ "$N" -ge 999 ]; then SIZE=(--total 1000); fi
|
|
190
|
+
ROOM="h$(printf '%s' "$MODE" | cut -c6-7)$N-$RUN-$STAMP"
|
|
191
|
+
run_step "$STEP" "$ROOM" node "$TOOLKIT/lib/crowd-load.mjs" --url "$URL" "${SIZE[@]}" \
|
|
192
|
+
--workers 4 --seconds "$SECONDS_PER_STEP" --ramp-ms 50 --rss-mb 3072 --compress "$COMPRESS" ${EXTRA[@]+"${EXTRA[@]}"} --room "$ROOM" --out "$STEP" < /dev/null
|
|
193
|
+
;;
|
|
194
|
+
cloud|cloud-deflate|cloud-slow|cloud-deflate-slow)
|
|
195
|
+
if [ -z "$PROBE_URL" ]; then continue; fi
|
|
196
|
+
STEP="$OUT/n$N-$MODE$SUFFIX"; COMPRESS=off; EXTRA=(); TAG=c
|
|
197
|
+
case "$MODE" in cloud-deflate*) COMPRESS=on; TAG=cd ;; esac
|
|
198
|
+
case "$MODE" in *-slow) EXTRA=(--slow-every 10); TAG="${TAG}s" ;; esac
|
|
199
|
+
ROOM="$TAG$N-$RUN-$STAMP"
|
|
200
|
+
run_step "$STEP" "$ROOM" node "$TOOLKIT/lib/crowd-cloud.mjs" --probe "$PROBE_URL" --key "$PROBE_KEY" \
|
|
201
|
+
--url "$URL" --n "$N" --seconds "$SECONDS_PER_STEP" --ramp-ms 50 --compress "$COMPRESS" --game "$GAME" ${EXTRA[@]+"${EXTRA[@]}"} --room "$ROOM" --out "$STEP" < /dev/null
|
|
202
|
+
;;
|
|
203
|
+
esac
|
|
204
|
+
done
|
|
205
|
+
done
|
|
206
|
+
calibrate "$OUT/calibration-end.json"
|
|
207
|
+
uptime > "$OUT/load-average-end.txt" || true
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* It proves the room and its Gates at that many players. It says nothing of the last hop to a real player.
|
|
12
12
|
*/
|
|
13
13
|
import { Tally, crowdClient } from './client.mjs';
|
|
14
|
+
import { referenceWork } from './reference-work.mjs';
|
|
14
15
|
|
|
15
16
|
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
16
17
|
|
|
@@ -18,20 +19,41 @@ export default {
|
|
|
18
19
|
async fetch(request, env, ctx) {
|
|
19
20
|
const url = new URL(request.url);
|
|
20
21
|
if (!env.PROBE_KEY || request.headers.get('x-probe-key') !== env.PROBE_KEY) return new Response('not found', { status: 404 });
|
|
21
|
-
if (url.pathname === '/hello') return Response.json({ ok: true, probe:
|
|
22
|
+
if (url.pathname === '/hello') return Response.json({ ok: true, probe: 2 });
|
|
23
|
+
if (url.pathname === '/calibrate') {
|
|
24
|
+
// The reference work, `rounds` times, in one event. This Worker cannot time it: its clock stands still while
|
|
25
|
+
// its code runs and reads a timer's due time when the timer fires, so whoever asked holds the stopwatch (the
|
|
26
|
+
// time this request took, less the time one with no rounds took). `own` is what this Worker's clock said the
|
|
27
|
+
// rounds took, across an awaited timer, for the record of what such a figure is worth.
|
|
28
|
+
const rounds = Math.min(20, Math.max(0, Math.floor(Number(url.searchParams.get('rounds') ?? 0)) || 0));
|
|
29
|
+
await sleep(2);
|
|
30
|
+
const at = Date.now(); let check = 0;
|
|
31
|
+
for (let i = 0; i < rounds; i += 1) check = referenceWork();
|
|
32
|
+
await sleep(1);
|
|
33
|
+
return Response.json({ ok: true, colo: request.cf?.colo ?? null, rounds, check, own: Date.now() - at });
|
|
34
|
+
}
|
|
22
35
|
const origin = url.searchParams.get('origin'), game = url.searchParams.get('game') ?? 'crowd-circuit', room = url.searchParams.get('room');
|
|
23
36
|
const from = Number(url.searchParams.get('from') ?? 0), count = Math.min(5, Math.max(1, Number(url.searchParams.get('count') ?? 5)));
|
|
24
37
|
const joinAt = Number(url.searchParams.get('joinAt')), rampMs = Number(url.searchParams.get('rampMs') ?? 50);
|
|
25
38
|
const measureAt = Number(url.searchParams.get('measureAt')), endAt = Number(url.searchParams.get('endAt'));
|
|
39
|
+
// `compress=1`: offer permessage-deflate as a browser does, so the studio's Gates compress for these players too.
|
|
40
|
+
const compress = url.searchParams.get('compress') === '1';
|
|
41
|
+
// `slowEvery=K`: every Kth player is a slow one (so every Gate holds some, beside healthy players). Each takes what it is sent at six frames a second, in
|
|
42
|
+
// order, as a connection that carries no more does: it falls behind, says so (the tick it holds), and the
|
|
43
|
+
// question is what that costs the players beside it. (A Worker cannot slow a socket's reads; a real slow
|
|
44
|
+
// connection is played from a computer: `crowd-load.mjs --slow-every`.)
|
|
45
|
+
const slowEvery = Math.max(0, Math.floor(Number(url.searchParams.get('slowEvery') ?? 0)) || 0);
|
|
26
46
|
if (!/^https:\/\/[a-z0-9.-]+$/.test(origin ?? '') || !/^[A-Za-z0-9_-]{1,40}$/.test(room ?? '') || ![joinAt, measureAt, endAt].every(Number.isFinite) || endAt - joinAt > 900_000) return new Response('bad request', { status: 400 });
|
|
27
47
|
const { readable, writable } = new TransformStream(), writer = writable.getWriter(), encoder = new TextEncoder();
|
|
28
48
|
const line = (o) => writer.write(encoder.encode(JSON.stringify(o) + '\n')).catch(() => {});
|
|
29
49
|
const tallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() }, players = [];
|
|
50
|
+
// A slow player's own figures, apart: its acknowledgements are late by what it is.
|
|
51
|
+
const slowTallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() };
|
|
30
52
|
const run = async () => {
|
|
31
53
|
const join = async (i) => {
|
|
32
54
|
const key = crypto.randomUUID().replaceAll('-', '');
|
|
33
|
-
const player = { socket: null, client: null };
|
|
34
|
-
player.client = crowdClient({ index: i, game, tallies, send: (text) => { try { player.socket?.send(text); } catch { /* closing */ } } });
|
|
55
|
+
const player = { socket: null, client: null, compressed: false, slow: slowEvery > 0 && i % slowEvery === 0, held: [], nextAt: 0 };
|
|
56
|
+
player.client = crowdClient({ index: i, game, tallies: player.slow ? slowTallies : tallies, send: (text) => { try { player.socket?.send(text); } catch { /* closing */ } } });
|
|
35
57
|
players.push(player);
|
|
36
58
|
try {
|
|
37
59
|
await sleep(Math.max(0, joinAt + i * rampMs - Date.now()));
|
|
@@ -40,31 +62,36 @@ export default {
|
|
|
40
62
|
const match = (await page.text()).match(/window\.HOMIE_NET=(\{[^<]+\})<\/script>/);
|
|
41
63
|
if (!page.ok || !match) throw new Error(`bootstrap HTTP ${page.status}`);
|
|
42
64
|
const cfg = JSON.parse(match[1]);
|
|
43
|
-
const answer = await fetch(cfg.url.replace(/^ws/, 'http'), { headers: { Upgrade: 'websocket', Origin: 'null' } });
|
|
65
|
+
const answer = await fetch(cfg.url.replace(/^ws/, 'http'), { headers: { Upgrade: 'websocket', Origin: 'null', ...(compress ? { 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits' } : {}) } });
|
|
66
|
+
player.compressed = /permessage-deflate/.test(answer.headers?.get?.('sec-websocket-extensions') ?? '');
|
|
44
67
|
const socket = answer.webSocket;
|
|
45
68
|
if (!socket) throw new Error(`upgrade HTTP ${answer.status}`);
|
|
46
69
|
socket.accept(); player.socket = socket;
|
|
47
|
-
socket.addEventListener('message', (event) => { if (typeof event.data
|
|
70
|
+
socket.addEventListener('message', (event) => { if (typeof event.data !== 'string') return; if (player.slow) player.held.push(event.data); else player.client.message(event.data, Date.now()); });
|
|
48
71
|
socket.addEventListener('close', (event) => player.client.close(event.code, event.reason));
|
|
49
72
|
socket.addEventListener('error', () => player.client.close(1006, 'error'));
|
|
50
73
|
player.client.open(Date.now());
|
|
51
74
|
} catch (error) { player.client.c.errors.push(String(error?.message ?? error).slice(0, 120)); player.client.close(0, 'never connected'); }
|
|
52
75
|
};
|
|
53
76
|
const joins = Promise.all(Array.from({ length: count }, (_, n) => join(from + n)));
|
|
54
|
-
let measuring = false, lastLine = 0, lastTick = Date.now(), worstLag = 0;
|
|
77
|
+
let measuring = false, lastLine = 0, lastWhole = 0, lastTick = Date.now(), worstLag = 0;
|
|
78
|
+
// Everything measured so far: the last line of a step, and every thirty seconds on the way, so a request that
|
|
79
|
+
// is cut off before its end has still said most of what it saw.
|
|
80
|
+
const whole = (t, now) => ({ t, from, count, colo: request.cf?.colo ?? null, measuredMs: Math.max(0, Math.min(now, endAt) - measureAt), worstTimerLagMs: worstLag, players: players.map((p) => ({ ...p.client.facts(), compressed: p.compressed, slow: p.slow, ...(p.slow ? { waiting: p.held.length } : {}) })), tallies: { ack: tallies.ack.bins, gaps: tallies.gaps.bins, loops: tallies.loops.bins }, slowAck: slowTallies.ack.bins });
|
|
55
81
|
while (Date.now() < endAt) {
|
|
56
82
|
const now = Date.now();
|
|
57
83
|
// This Worker's own lateness: how long past ten milliseconds its timer came back.
|
|
58
84
|
worstLag = Math.max(worstLag, now - lastTick - 10); lastTick = now;
|
|
59
85
|
if (!measuring && now >= measureAt) { measuring = true; for (const p of players) p.client.measure(); }
|
|
60
|
-
for (const p of players) p.client.tick(now);
|
|
86
|
+
for (const p of players) { if (p.slow && p.held.length && now >= p.nextAt) { p.nextAt = now + 160; p.client.message(p.held.shift(), now); } p.client.tick(now); }
|
|
87
|
+
if (measuring && now - lastWhole >= 30000) { lastWhole = now; await line(whole('partial', now)); }
|
|
61
88
|
if (now - lastLine >= 5000) { lastLine = now; await line({ t: 'sample', at: now, from, joined: players.filter((p) => p.client.c.first !== null).length, open: players.filter((p) => p.socket && !p.client.c.closed).length, measuring }); }
|
|
62
89
|
await sleep(10);
|
|
63
90
|
}
|
|
64
|
-
await joins;
|
|
65
|
-
const
|
|
91
|
+
await Promise.race([joins, sleep(5000)]);
|
|
92
|
+
const last = whole('final', endAt);
|
|
66
93
|
for (const p of players) { try { p.socket?.close(1000, 'probe step complete'); } catch { /* gone */ } }
|
|
67
|
-
await line(
|
|
94
|
+
await line(last);
|
|
68
95
|
};
|
|
69
96
|
ctx.waitUntil(run().catch((error) => line({ t: 'failed', from, error: String(error?.stack ?? error).slice(0, 400) })).finally(() => writer.close().catch(() => {})));
|
|
70
97
|
return new Response(readable, { headers: { 'content-type': 'application/x-ndjson', 'cache-control': 'no-store' } });
|
package/lib/crowd-summary.mjs
CHANGED
|
@@ -32,10 +32,17 @@ export function summarize(report, rows = [], link = null) {
|
|
|
32
32
|
from: report.generator === 'cloudflare' ? 'cloudflare' : 'home',
|
|
33
33
|
n: report.n, total: report.total, room: report.room, toolkit: report.toolkit ?? null, stopReason: report.stopReason ?? null,
|
|
34
34
|
joined: report.joined, connectedAtEnd: report.connectedAtEnd, disconnects: report.disconnects,
|
|
35
|
+
// In-Cloudflare steps: probe requests whose report never came (their players were in the room all the same).
|
|
36
|
+
lostReports: report.lostReports ?? null,
|
|
37
|
+
// What the step varied: compression offered or not.
|
|
38
|
+
variant: (report.variant ?? (report.compression?.offered === false ? 'plain' : 'deflate')) + (report.slowPlayers ? ` +${report.slowPlayers} slow` : ''),
|
|
39
|
+
// With deliberately slow players in the step: what everybody else got.
|
|
40
|
+
healthy: report.slowPlayers ? report.healthy : null,
|
|
35
41
|
client: {
|
|
36
42
|
inputAckMs: { p50: report.inputAckMs?.p50 ?? null, p95: report.inputAckMs?.p95 ?? null, p99: report.inputAckMs?.p99 ?? null },
|
|
37
43
|
// A player's tick progression and the frames they received, a second: the median player, and the worst.
|
|
38
|
-
|
|
44
|
+
// (From the in-Cloudflare probe this is by a Worker's clock, which runs slow when the Worker is late: not shown.)
|
|
45
|
+
tickHz: report.tickHzClock === 'probe' ? { median: null, min: null } : { median: round(report.tickHz?.median), min: round(report.tickHz?.min) },
|
|
39
46
|
deliveredHz: { median: round(report.deliveredHz?.median), min: round(report.deliveredHz?.min) },
|
|
40
47
|
receiveGapMs: { p95: report.receiveGapMs?.p95 ?? null, p99: report.receiveGapMs?.p99 ?? null },
|
|
41
48
|
ackExpired: report.ackExpired ?? null,
|
|
@@ -78,9 +85,28 @@ export function summarize(report, rows = [], link = null) {
|
|
|
78
85
|
roomTickHzAtGates: { median: round(pick(gates.map((g) => g.roomTickHz), 0.5)), p05: round(pick(gates.map((g) => g.roomTickHz), 0.05)) }, worldGapMsMax: pick(gates.map((g) => g.worldGapMs), 1),
|
|
79
86
|
// The room's clock less a Gate's (median Gate), and how long the room says it held a Gate's frame.
|
|
80
87
|
roomClockOffsetMs: pick(gates.map((g) => g.roomClock?.offsetMs), 0.5), channelDelayMs: pick(gates.map((g) => g.roomClock?.delayMs), 0.5),
|
|
88
|
+
// A Gate's own lateness: the worst its 20 ms timer came late (the worst Gate-second), and the share of Gate-seconds with any timer over 20 ms late.
|
|
89
|
+
gateLoopLateMs: { median: pick(gates.map((g) => g.loopLateMs), 0.5), p95: pick(gates.map((g) => g.loopLateMs), 0.95) }, gateBusyShare: gates.length ? round(gates.filter((g) => (g.loopLateTimers ?? 0) > 0).length / gates.length, 3) : null,
|
|
81
90
|
gateRttMs: { median: round(pick(gates.map((g) => g.rttMs), 0.5), 0), p95: round(pick(gates.map((g) => g.rttMs), 0.95), 0) },
|
|
82
91
|
inputOffsetMs: pick(gates.map((g) => g.inputOffsetMs), 0.5),
|
|
83
92
|
clientLagTicks: { p95: pick(gates.map((g) => g.clientLagTicks?.p95), 0.95), max: pick(gates.map((g) => g.clientLagTicks?.max), 1) },
|
|
93
|
+
// Players more than a second behind at one moment, the worst second (counted, whether or not they were paced).
|
|
94
|
+
behindPlayers: pick(during.map((r) => (r.gates ?? []).reduce((a, g) => a + (g.behind ?? 0), 0)), 1),
|
|
95
|
+
// Of every world every Gate wrote: the share over 100, 200 and 400 ms old, and worlds a Gate-second that came
|
|
96
|
+
// more than 75, 150 and 300 ms after the one before.
|
|
97
|
+
worldsOld: (() => { const n = gates.reduce((a, g) => a + (g.worldsOld?.n ?? 0), 0), of = (key) => (n ? round(gates.reduce((a, g) => a + (g.worldsOld?.[key] ?? 0), 0) / n, 4) : null); return n ? { worlds: n, over100: of('over100'), over200: of('over200'), over400: of('over400') } : null; })(),
|
|
98
|
+
worldGaps: (() => { const with_ = gates.filter((g) => g.worldGaps), of = (key) => (with_.length ? round(with_.reduce((a, g) => a + g.worldGaps[key], 0) / with_.length, 3) : null); return with_.length ? { over75: of('over75'), over150: of('over150'), over300: of('over300') } : null; })(),
|
|
99
|
+
// What crossed the room's channels a second: rows (a row is a player's message, mostly) and frames in, frames
|
|
100
|
+
// and megabytes out, and worlds replaced before they were sent because eight were unacknowledged.
|
|
101
|
+
roomTraffic: (() => {
|
|
102
|
+
const first = during[0], last = during.at(-1), seconds = first && last ? (last.at - first.at) / 1000 : 0;
|
|
103
|
+
if (!(seconds > 0)) return null;
|
|
104
|
+
const total = (row, f) => (row.transport ?? []).reduce((a, t) => a + (f(t) ?? 0), 0), rate = (f) => round((total(last, f) - total(first, f)) / seconds, 1);
|
|
105
|
+
const heard = (last.transport ?? []).some((t) => t.in);
|
|
106
|
+
return { inRows: heard ? rate((t) => t.in?.rows) : null, inFrames: heard ? rate((t) => t.in?.frames) : null, inKB: heard ? rate((t) => (t.in?.bytes ?? 0) / 1000) : null, outFrames: rate((t) => t.frames), outMB: rate((t) => t.bytes / 1e6), replaced: rate((t) => t.coalesced) };
|
|
107
|
+
})(),
|
|
108
|
+
// Checkpoints the room wrote without holding its frames for them.
|
|
109
|
+
unconfirmedSaves: during.at(-1)?.save?.unconfirmed ?? null,
|
|
84
110
|
},
|
|
85
111
|
};
|
|
86
112
|
}
|
|
@@ -105,11 +131,11 @@ export function table(steps) {
|
|
|
105
131
|
const head = ['N', 'from', 'joined/end/drops', 'ack p50/p95/p99 ms', 'loop p50/p95 ms', 'lead (late)', 'client tick Hz', 'client delivered Hz', 'wire MB/s (link)', 'KB/s a player', 'compressed (ratio)', 'driver lag ms', 'server late', 'Gates', 'room Hz at Gates med/p05', 'Gate delivered Hz med/p05', 'Gate rtt med/p95 ms', 'world age p50/p95 ms', 'paced', 'diag ok/failed'];
|
|
106
132
|
const lines = [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`];
|
|
107
133
|
for (const s of steps) lines.push(`| ${[
|
|
108
|
-
s.n, s.from
|
|
134
|
+
s.n, `${s.from} ${s.variant}${s.lostReports ? ` (${s.lostReports} reports lost)` : ''}`, `${cell(s.joined)}/${cell(s.connectedAtEnd)}/${cell(s.disconnects)}`,
|
|
109
135
|
`${cell(s.client.inputAckMs.p50)}/${cell(s.client.inputAckMs.p95)}/${cell(s.client.inputAckMs.p99)}`,
|
|
110
136
|
`${cell(s.client.inputLoopMs?.p50)}/${cell(s.client.inputLoopMs?.p95)}`,
|
|
111
137
|
`${cell(s.client.inputLead?.ticksMedian)} (${cell(s.client.inputLead?.lateShare)})`,
|
|
112
|
-
cell(s.client.tickHz.median), cell(s.client.deliveredHz.median),
|
|
138
|
+
cell(s.client.tickHz.median), `${cell(s.client.deliveredHz.median)}${s.healthy ? ` (healthy: ${cell(round(s.healthy.deliveredHz?.median))}, worst ${cell(round(s.healthy.deliveredHz?.min))})` : ''}`,
|
|
113
139
|
`${cell(s.client.wireMBps)} (${cell(s.link.downMBps)}${s.link.limited ? ', LINK-LIMITED' : ''})`,
|
|
114
140
|
cell(s.client.wireKBsPerPlayer),
|
|
115
141
|
s.client.compression ? `${s.client.compression.sockets}/${s.client.compression.of} (${cell(s.client.compression.ratio)})` : cell(s.client.compressedSockets),
|
|
@@ -123,18 +149,59 @@ export function table(steps) {
|
|
|
123
149
|
return lines.join('\n');
|
|
124
150
|
}
|
|
125
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The second table: what the room carried, a line a step. Read with the first.
|
|
154
|
+
* - "rows in": players' messages reaching the room a second. "worlds out": frames to Gates a second, megabytes,
|
|
155
|
+
* and worlds replaced before sending because eight were unacknowledged on a channel (a room that is behind).
|
|
156
|
+
* - "worlds old": of every world every Gate wrote, the share over 100 / 200 / 400 ms old.
|
|
157
|
+
* - "behind": players more than a second behind at one moment, the worst second. "paced": how many were being
|
|
158
|
+
* sent fewer frames for it, the worst second.
|
|
159
|
+
* There is no column for the room's processor time: Cloudflare's count of it could not be read from a tail (the
|
|
160
|
+
* requests that are a Gate's channel reported under 1% for a room that was out of time), and nothing an object
|
|
161
|
+
* measures about itself can stand in, because its clock stands still while it runs.
|
|
162
|
+
*/
|
|
163
|
+
export function threadTable(steps) {
|
|
164
|
+
const cell = (v) => (v === null || v === undefined ? '—' : String(v));
|
|
165
|
+
const pct = (v) => (Number.isFinite(v) ? `${Math.round(v * 1000) / 10}%` : '—');
|
|
166
|
+
const head = ['N', 'from', 'rows in/s', 'worlds out/s (MB/s, replaced/s)', 'unconfirmed checkpoints', 'worlds old >100/>200/>400', 'gaps >75/>150/>300 a Gate-s', 'behind', 'paced'];
|
|
167
|
+
const lines = [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`];
|
|
168
|
+
for (const s of steps) {
|
|
169
|
+
const t = s.server.roomTraffic, old = s.server.worldsOld, gaps = s.server.worldGaps;
|
|
170
|
+
lines.push(`| ${[
|
|
171
|
+
s.n, `${s.from} ${s.variant}`,
|
|
172
|
+
cell(t?.inRows), t ? `${cell(t.outFrames)} (${cell(t.outMB)}, ${cell(t.replaced)})` : '—',
|
|
173
|
+
cell(s.server.unconfirmedSaves),
|
|
174
|
+
old ? `${pct(old.over100)}/${pct(old.over200)}/${pct(old.over400)}` : '—', gaps ? `${cell(gaps.over75)}/${cell(gaps.over150)}/${cell(gaps.over300)}` : '—',
|
|
175
|
+
cell(s.server.behindPlayers), cell(s.server.pacedPlayers),
|
|
176
|
+
].join(' | ')} |`);
|
|
177
|
+
}
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
126
181
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
127
182
|
const dir = resolve(process.argv[2] ?? '.');
|
|
128
183
|
const read = (file) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null);
|
|
129
184
|
const steps = [];
|
|
130
|
-
for (const name of readdirSync(dir).filter((n) => /^n\d+(-
|
|
185
|
+
for (const name of readdirSync(dir).filter((n) => /^n\d+(-[a-z0-9-]+)?$/.test(n)).sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10) || a.localeCompare(b))) {
|
|
131
186
|
const report = read(join(dir, name, 'report.json'));
|
|
132
187
|
if (!report) continue;
|
|
133
188
|
const file = join(dir, name, 'diagnostics.jsonl');
|
|
134
189
|
const rows = existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } }) : [];
|
|
135
|
-
steps.push(summarize(report, rows, read(join(dir, name, 'link.json'))));
|
|
190
|
+
steps.push({ ...summarize(report, rows, read(join(dir, name, 'link.json'))), folder: name });
|
|
136
191
|
}
|
|
192
|
+
// The same step played more than once (n1000-cloud, n1000-cloud-r2, ...): the spread of what matters.
|
|
193
|
+
const groups = new Map();
|
|
194
|
+
for (const step of steps) { const key = `${step.n} ${step.from} ${step.variant}`; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(step); }
|
|
195
|
+
const spread = [...groups].filter(([, runs]) => runs.length > 1).map(([key, runs]) => {
|
|
196
|
+
const of = (f) => { const a = runs.map(f).filter(Number.isFinite).sort((x, y) => x - y); return a.length ? `${a[0]} to ${a.at(-1)}` : '—'; };
|
|
197
|
+
return `| ${key} | ${runs.length} | ${of((r) => r.client.deliveredHz.median)} | ${of((r) => r.client.inputAckMs.p95)} | ${of((r) => r.server.gateRttMs.p95)} | ${of((r) => r.server.worldAgeMs.p95)} | ${of((r) => r.server.worldsOld?.over200)} | ${of((r) => r.server.lateInputs)} | ${of((r) => r.server.gateDeliveredHz.median)} |`;
|
|
198
|
+
});
|
|
199
|
+
const spreadTable = spread.length ? `\n\nRepeated steps, lowest to highest:\n\n| step | runs | client delivered Hz | ack p95 ms | Gate rtt p95 ms | world age p95 ms | share of worlds over 200 ms old | server late | Gate delivered Hz |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${spread.join('\n')}` : '';
|
|
137
200
|
writeFileSync(join(dir, 'summary.json'), JSON.stringify(steps, null, 2));
|
|
138
|
-
|
|
139
|
-
|
|
201
|
+
// How fast Cloudflare ran the reference work against this computer (crowd-cloud.mjs --calibrate), when the run measured it.
|
|
202
|
+
const calibrations = readdirSync(dir).filter((n) => /^calibration.*\.json$/.test(n)).map((n) => read(join(dir, n))).filter((c) => c?.ratio);
|
|
203
|
+
const calibration = calibrations.length ? `\n\nThe reference work (lib/crowd-probe/reference-work.mjs), a round: this computer ${calibrations.map((c) => Math.min(...c.localMs)).join(', ')} ms (its quickest); inside Cloudflare, by this computer's stopwatch, ${calibrations.map((c) => `${c.cloudMsARound.min} to ${c.cloudMsARound.max} (median ${c.cloudMsARound.median})`).join('; ')} ms. Cloudflare over this computer: ${calibrations.map((c) => `${c.ratio.min} to ${c.ratio.max} (median ${c.ratio.median})`).join('; ')}. The Worker's own clock said the same rounds took ${calibrations.map((c) => (c.workerOwnClockMs ?? []).join(', ')).join('; ')} ms in all.` : '';
|
|
204
|
+
const text = `${table(steps)}\n\nWhat the room carried:\n\n${threadTable(steps)}${spreadTable}${calibration}`;
|
|
205
|
+
writeFileSync(join(dir, 'summary.md'), `${text}\n`);
|
|
206
|
+
console.log(text);
|
|
140
207
|
}
|
package/lib/crowd-worker.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export function createLoad({
|
|
|
46
46
|
workers = 1,
|
|
47
47
|
lead = 3,
|
|
48
48
|
compress = true,
|
|
49
|
+
slowEvery = 0,
|
|
49
50
|
onProblem = () => {}
|
|
50
51
|
}) {
|
|
51
52
|
const clients = [],
|
|
@@ -53,6 +54,8 @@ export function createLoad({
|
|
|
53
54
|
first = new Histogram(),
|
|
54
55
|
welcome = new Histogram(),
|
|
55
56
|
ack = new Histogram(),
|
|
57
|
+
// A deliberately slow player's own figures, apart: its acknowledgements are seconds late by what it is.
|
|
58
|
+
slowAck = new Histogram(),
|
|
56
59
|
gaps = new Histogram(),
|
|
57
60
|
// From the tick a player held when it stamped an input to the tick it
|
|
58
61
|
// reached the rules, ms: what the system took, whatever the lead.
|
|
@@ -144,7 +147,7 @@ export function createLoad({
|
|
|
144
147
|
c.lastTick = snap.k;
|
|
145
148
|
c.lastAt = now;
|
|
146
149
|
c.frames++;
|
|
147
|
-
gaps.add(gap);
|
|
150
|
+
if (!c.slow) gaps.add(gap);
|
|
148
151
|
}
|
|
149
152
|
const control = snap.c?.find((row) => row[0] === c.seat);
|
|
150
153
|
// The room says how early this player's inputs arrive, in sixteenths of a
|
|
@@ -155,7 +158,7 @@ export function createLoad({
|
|
|
155
158
|
// early, one fewer when it was over a tick and a half.
|
|
156
159
|
if (control && control[3] !== -128 && control[3] !== undefined) {
|
|
157
160
|
const early = control[3] / 16;
|
|
158
|
-
if (measuring) { if (early <= 0) lateAcks++; onTime++; loops.add((c.lead - early) * 50); }
|
|
161
|
+
if (measuring && !c.slow) { if (early <= 0) lateAcks++; onTime++; loops.add((c.lead - early) * 50); }
|
|
159
162
|
(c.early ??= []).push(early); if (c.early.length > 40) c.early.shift();
|
|
160
163
|
if (c.early.length >= 20 && now - (c.leadAt ?? 0) > 2000) {
|
|
161
164
|
const low = c.early.slice().sort((a, b) => a - b)[Math.floor(c.early.length / 20)];
|
|
@@ -169,7 +172,7 @@ export function createLoad({
|
|
|
169
172
|
if (tick > control[2]) break;
|
|
170
173
|
c.pending.delete(tick);
|
|
171
174
|
if (measuring) {
|
|
172
|
-
if (tick === control[2]) ack.add(now - at);
|
|
175
|
+
if (tick === control[2]) (c.slow ? slowAck : ack).add(now - at);
|
|
173
176
|
else skipped++;
|
|
174
177
|
}
|
|
175
178
|
}
|
|
@@ -230,6 +233,9 @@ export function createLoad({
|
|
|
230
233
|
});
|
|
231
234
|
c.ws.on('open', () => {
|
|
232
235
|
c.helloAt = performance.now();
|
|
236
|
+
// A deliberately slow player: this socket stops reading for four seconds in every five, as a connection
|
|
237
|
+
// that cannot keep up does. What it costs the players beside it is the question.
|
|
238
|
+
if (slowEvery > 0 && i % slowEvery === 0) { c.slow = true; c.slowTimer = setInterval(() => { c.ws._socket?.pause(); setTimeout(() => c.ws._socket?.resume(), 4000); }, 5000); }
|
|
233
239
|
c.compressed = /permessage-deflate/.test(c.ws.extensions ?? '');
|
|
234
240
|
send(c, {
|
|
235
241
|
t: 'hello',
|
|
@@ -434,6 +440,7 @@ export function createLoad({
|
|
|
434
440
|
measuring = false;
|
|
435
441
|
stopping = true;
|
|
436
442
|
clearInterval(timer);
|
|
443
|
+
for (const c of clients) clearInterval(c.slowTimer);
|
|
437
444
|
loop.disable();
|
|
438
445
|
for (const c of clients) c.ws?.close(1000, 'load step complete');
|
|
439
446
|
await new Promise((r) => setTimeout(r, 250));
|
|
@@ -441,7 +448,7 @@ export function createLoad({
|
|
|
441
448
|
return {
|
|
442
449
|
...end,
|
|
443
450
|
errors: [...errors].map(([text, count]) => ({ text, count })),
|
|
444
|
-
histograms: { first, welcome, ack, gaps, loops },
|
|
451
|
+
histograms: { first, welcome, ack, slowAck, gaps, loops },
|
|
445
452
|
inputReports: onTime,
|
|
446
453
|
inputReportsLate: lateAcks,
|
|
447
454
|
leadTicks: clients.filter((c) => !c.retired).map((c) => c.lead),
|
|
@@ -456,6 +463,7 @@ export function createLoad({
|
|
|
456
463
|
priorSeat: c.priorSeat,
|
|
457
464
|
sameSeat: c.priorSeat === null ? null : c.seat === c.priorSeat,
|
|
458
465
|
retired: Boolean(c.retired),
|
|
466
|
+
slow: Boolean(c.slow),
|
|
459
467
|
firstStateMs: c.first === null ? null : c.first - c.began,
|
|
460
468
|
helloAfterMs: c.helloAt ? c.helloAt - c.began : null,
|
|
461
469
|
closeCode: c.closeCode,
|
package/lib/rules-check.mjs
CHANGED
|
@@ -28,10 +28,10 @@ export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE
|
|
|
28
28
|
|
|
29
29
|
/** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
|
|
30
30
|
export function stateHash(c) {
|
|
31
|
-
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save, historySeconds, rewindSeconds } = c;
|
|
31
|
+
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save, historySeconds, rewindSeconds, botNames } = c;
|
|
32
32
|
// Keep existing 2D save compatibility hashes when the 3D map vocabulary grows.
|
|
33
33
|
const map = dims === 2 ? { name: geometry.name, bounds: geometry.bounds, boxes: geometry.boxes, circles: geometry.circles, spots: geometry.spots } : geometry;
|
|
34
|
-
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, ...(historySeconds?{historySeconds}:{}), ...(rewindSeconds!==undefined?{rewindSeconds}:{}), tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
34
|
+
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, ...(historySeconds?{historySeconds}:{}), ...(rewindSeconds!==undefined?{rewindSeconds}:{}), ...(botNames?{botNames}:{}), tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
package/lib/rules-types.mjs
CHANGED
|
@@ -78,7 +78,7 @@ type W = World<${parameters}>;
|
|
|
78
78
|
type RW = World<${parameters}, true>;
|
|
79
79
|
type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
|
|
80
80
|
entities: ${entities}; move?: Moves;
|
|
81
|
-
room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number }; historySeconds?: number; rewindSeconds?: number;
|
|
81
|
+
room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number; names?: readonly string[] }; historySeconds?: number; rewindSeconds?: number;
|
|
82
82
|
start?: (world: RW) => void;
|
|
83
83
|
join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: (${joins}) | null }) => ${joins};
|
|
84
84
|
on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
|