Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

arena: spectator-styled watchers + ac-arena CLI for end-to-end testing

Visuals:
- isGuestHandle() — handles starting with guest_ or swarm_ are treated as
watchers, rendered as translucent gray bodies (alpha 0.35 instead of 0.9)
and 1×1 dim minimap dots, so the visual prominence is reserved for
people who claimed a real handle.
- HUD splits the live count into "players" (named) and "watchers" (anon).

tools/ac-arena.mjs — single-file Node CLI for testing the multiplayer
loop without a browser. Uses Node 22 native WebSocket so no extra deps.

node tools/ac-arena.mjs swarm --count=4 --duration=30
node tools/ac-arena.mjs watch --duration=30

`swarm` opens N WS-only fake players that send arena:hello + arena:cmd
at 30Hz with random walk movement; useful for load-testing arena-manager
and confirming the server's WS receiveCmd path works (it does — verified
3 clients × 30Hz, both directions, no drops).

`watch` is a passive probe that prints arena roster + per-second snap
counts; useful as a no-browser sanity check that snaps are flowing.

The swarm runs over WS only because Node has no WebRTC. That's actually
useful as a diagnostic split — if WS works but the browser still has
the drop-cmd problem, the issue is the geckos UDP transport, not the
server arena logic.

+336 -13
+49 -13
system/public/aesthetic.computer/disks/arena.mjs
··· 465 465 return a + d * k; 466 466 } 467 467 468 + // Anonymous tabs (guest_xxxx or swarm_xxx from the load-test CLI) get a 469 + // "watcher" treatment — gray, translucent, tiny on the minimap — so the 470 + // visual prominence in the arena is reserved for people who claimed a handle. 471 + function isGuestHandle(h) { 472 + return typeof h === "string" && (h.startsWith("guest_") || h.startsWith("swarm_")); 473 + } 474 + 468 475 // Build a small humanoid stick-figure as a single line Form. Cheap to clone 469 476 // per remote (handful of verts) and matches the arena's visual language. 470 - function buildRemoteBody(Form, colorRGB) { 477 + function buildRemoteBody(Form, colorRGB, watcher = false) { 471 478 const [r, g, b] = colorRGB; 472 - const col = [r / 255, g / 255, b / 255, 0.9]; 473 - const colDim = [r / 255, g / 255, b / 255, 0.5]; 479 + const a = watcher ? 0.35 : 0.9; 480 + const aDim = watcher ? 0.18 : 0.5; 481 + const col = [r / 255, g / 255, b / 255, a]; 482 + const colDim = [r / 255, g / 255, b / 255, aDim]; 474 483 const head = 0.3, shoulder = -0.4, hip = -1.1, foot = -2.0; 475 484 // coords are (x, y, z, w); y is "up" in the form's local space. 476 485 const pts = [ ··· 515 524 for (const [handle, o] of Object.entries(others)) { 516 525 const sample = sampleOther(o, t); 517 526 if (!sample) continue; 518 - if (!o.body) o.body = buildRemoteBody(Form, handleColor(handle)); 527 + if (!o.body) { 528 + const watcher = isGuestHandle(handle); 529 + const color = watcher ? [170, 175, 195] : handleColor(handle); 530 + o.body = buildRemoteBody(Form, color, watcher); 531 + } 519 532 // Mirror local body positioning: Form.position uses (-x, y, -z). 520 533 o.body.position[0] = -sample.x; 521 534 o.body.position[1] = sample.y; ··· 1879 1892 px: Math.round(ox + (wx - xMin) * sx), 1880 1893 py: Math.round(oy + (wz - zMin) * sz), 1881 1894 }); 1882 - // Remote players (interpolated like the 3D bodies). 1895 + // Remote players (interpolated like the 3D bodies). Watchers (anon 1896 + // guest_/swarm_) render as a small dim 1px dot; named players get a 1897 + // bright 3×3 colored square. 1883 1898 const tNow = renderTimeNow(); 1884 1899 for (const [handle, o] of Object.entries(others)) { 1885 1900 const s = sampleOther(o, tNow); 1886 1901 if (!s) continue; 1887 1902 const { px, py } = project(s.x, s.z); 1888 - const [r, g, b] = handleColor(handle); 1889 - ink(r, g, b).box(px - 1, py - 1, 3, 3); 1903 + if (isGuestHandle(handle)) { 1904 + ink(150, 155, 175, 160).box(px, py, 1, 1); 1905 + } else { 1906 + const [r, g, b] = handleColor(handle); 1907 + ink(r, g, b).box(px - 1, py - 1, 3, 3); 1908 + } 1890 1909 } 1891 - // Self — bigger white dot with a yaw notch. 1910 + // Self — white if you have a real handle, dim if anonymous, plus a 1911 + // yellow yaw notch so the map is orientation-readable. 1892 1912 const camRef = system?.fps?.doll?.cam; 1893 1913 if (camRef) { 1894 1914 const myX = -camRef.x; 1895 1915 const myZ = -camRef.z; 1896 1916 const { px, py } = project(myX, myZ); 1897 - ink(255, 255, 255).box(px - 2, py - 2, 5, 5); 1917 + const meWatching = isGuestHandle(myHandle); 1918 + if (meWatching) { 1919 + ink(180, 185, 205).box(px - 1, py - 1, 3, 3); 1920 + } else { 1921 + ink(255, 255, 255).box(px - 2, py - 2, 5, 5); 1922 + } 1898 1923 const yawR = camRef.rotY * Math.PI / 180; 1899 1924 const tipX = px + Math.round(Math.sin(yawR) * 6); 1900 1925 const tipY = py + Math.round(Math.cos(yawR) * 6); ··· 1906 1931 let lineY = margin + mapSize + 4; 1907 1932 const advance = () => { lineY += lineH; }; 1908 1933 1909 - // Players: count + your handle. 1910 - const peers = Object.keys(others).length; 1911 - const total = peers + (netSpectator ? 0 : 1); 1934 + // Players + watchers — split named handles from anon guests so the 1935 + // count tells you who's *here* versus who's just looking around. 1936 + let namedRemotes = 0; 1937 + let watcherRemotes = 0; 1938 + for (const h of Object.keys(others)) { 1939 + if (isGuestHandle(h)) watcherRemotes++; else namedRemotes++; 1940 + } 1941 + const meIsWatcher = isGuestHandle(myHandle); 1942 + const namedTotal = namedRemotes + (netSpectator || meIsWatcher ? 0 : 1); 1943 + const watcherTotal = watcherRemotes + (netSpectator || meIsWatcher ? 1 : 0); 1912 1944 rightLabelMulti( 1913 - [[dim, "players "], [peers > 0 ? [180, 230, 180] : [200, 200, 200], `${total}`]], 1945 + [[dim, "players "], [namedTotal > 0 ? [180, 230, 180] : [180, 180, 190], `${namedTotal}`]], 1914 1946 lineY, 1915 1947 ); 1916 1948 advance(); 1949 + if (watcherTotal > 0) { 1950 + rightLabelMulti([[dim, "watchers "], [[170, 175, 200], `${watcherTotal}`]], lineY); 1951 + advance(); 1952 + } 1917 1953 1918 1954 // Connection: transport + how stale snaps are + lag. 1919 1955 {
+287
tools/ac-arena.mjs
··· 1 + #!/usr/bin/env node 2 + // ac-arena — multiplayer end-to-end test client for arena.mjs. 3 + // 4 + // node tools/ac-arena.mjs swarm [--count=4] [--duration=30] [--target=...] 5 + // node tools/ac-arena.mjs watch [--target=...] [--duration=30] 6 + // 7 + // `swarm` opens N WebSocket-only fake clients that send arena:hello + 8 + // arena:cmd at 60 Hz with mild random motion. They do not connect over 9 + // UDP/geckos (Node has no built-in WebRTC), which exercises the WS 10 + // fallback paths server-side. Use it to load-test arena-manager and 11 + // confirm the cmd direction is reaching receiveCmd. 12 + // 13 + // `watch` opens a single passive probe and prints arena:welcome roster + 14 + // per-second snap counts. Useful for diagnosing whether snaps are 15 + // flowing at all without involving the browser/PWA. 16 + 17 + // Uses Node's built-in WebSocket (>=22). No `ws` dep required. 18 + import { packCmd, BTN } from "../system/public/aesthetic.computer/lib/pmove.mjs"; 19 + 20 + const DEFAULT_TARGET = "wss://session-server.aesthetic.computer"; 21 + const CMD_HZ = 60; 22 + const CMD_BACKUP = 3; 23 + 24 + function parseArgs(argv) { 25 + const out = { _: [] }; 26 + for (const arg of argv) { 27 + if (arg.startsWith("--")) { 28 + const [k, v] = arg.slice(2).split("="); 29 + out[k] = v ?? true; 30 + } else { 31 + out._.push(arg); 32 + } 33 + } 34 + return out; 35 + } 36 + 37 + function rngHandle(prefix) { 38 + return `${prefix}_${Math.floor(Math.random() * 9_000) + 1000}`; 39 + } 40 + 41 + class ArenaWSClient { 42 + constructor({ target, handle, onSnap, onWelcome, onClose, onError, mode = "active" }) { 43 + this.target = target; 44 + this.handle = handle; 45 + this.mode = mode; // "active" | "probe" 46 + this.onSnap = onSnap || (() => {}); 47 + this.onWelcome = onWelcome || (() => {}); 48 + this.onClose = onClose || (() => {}); 49 + this.onError = onError || ((e) => console.error("ws err:", e?.message || e)); 50 + this.connectedAt = 0; 51 + this.cmdSeq = 0; 52 + this.cmdOutbox = []; // last CMD_BACKUP cmds 53 + this.lastSnapAck = 0; 54 + this.cmdInterval = null; 55 + this.flushAcc = 0; 56 + this.snapCount = 0; 57 + this.cmdSent = 0; 58 + this.wantClose = false; 59 + this.ws = null; 60 + this.helloSent = false; 61 + } 62 + 63 + connect() { 64 + this.ws = new WebSocket(this.target); 65 + this.ws.addEventListener("open", () => this._onOpen()); 66 + this.ws.addEventListener("message", (ev) => this._onMessage(ev.data)); 67 + this.ws.addEventListener("close", () => this._onClose()); 68 + this.ws.addEventListener("error", (ev) => this.onError(ev?.error || ev?.message || ev)); 69 + } 70 + 71 + _onOpen() { 72 + this.connectedAt = Date.now(); 73 + this._send("arena:hello", { handle: this.handle, ...(this.mode === "probe" ? { probe: true } : {}) }); 74 + this.helloSent = true; 75 + if (this.mode === "active") { 76 + // 60 Hz tick: produce one cmd per tick, flush every 2 ticks (30 Hz frames). 77 + this.cmdInterval = setInterval(() => this._tick(), 1000 / CMD_HZ); 78 + } 79 + } 80 + 81 + _onClose() { 82 + if (this.cmdInterval) { clearInterval(this.cmdInterval); this.cmdInterval = null; } 83 + this.onClose(); 84 + } 85 + 86 + _onMessage(raw) { 87 + let msg; 88 + try { msg = JSON.parse(raw); } catch { return; } 89 + if (msg.type === "connected" || msg.type === "joined") return; 90 + const content = typeof msg.content === "string" ? safeParse(msg.content) : msg.content; 91 + if (msg.type === "arena:welcome") { 92 + this.onWelcome(content); 93 + return; 94 + } 95 + if (msg.type === "arena:snap") { 96 + this.snapCount++; 97 + if (typeof content?.messageNum === "number" && content.messageNum > this.lastSnapAck) { 98 + this.lastSnapAck = content.messageNum; 99 + } 100 + this.onSnap(content); 101 + return; 102 + } 103 + if (msg.type === "arena:takeover") { 104 + console.warn(`[${this.handle}] kicked to spectator (taken over by another tab)`); 105 + } 106 + } 107 + 108 + _tick() { 109 + // Compose a usercmd. Random walk: change direction every ~0.5s. 110 + if (!this._dir || Math.random() < 1 / (CMD_HZ * 0.5)) { 111 + this._dir = { 112 + fwd: Math.random() < 0.4 ? (Math.random() < 0.5 ? -1 : 1) : 0, 113 + right: Math.random() < 0.4 ? (Math.random() < 0.5 ? -1 : 1) : 0, 114 + yaw: Math.random() * 360 - 180, 115 + jump: Math.random() < 0.05, 116 + }; 117 + } 118 + const ms = Date.now() - this.connectedAt; 119 + const cmd = packCmd({ 120 + ms, 121 + fwd: this._dir.fwd, 122 + right: this._dir.right, 123 + yaw: this._dir.yaw, 124 + pitch: 0, 125 + buttons: this._dir.jump ? BTN.JUMP : 0, 126 + }); 127 + this.cmdSeq++; 128 + this.cmdOutbox.push({ seq: this.cmdSeq, cmd }); 129 + while (this.cmdOutbox.length > CMD_BACKUP) this.cmdOutbox.shift(); 130 + 131 + this.flushAcc++; 132 + if (this.flushAcc >= 2) { // 30 Hz frame rate 133 + this.flushAcc = 0; 134 + this._flush(); 135 + } 136 + } 137 + 138 + _flush() { 139 + if (this.cmdOutbox.length === 0) return; 140 + const frame = { 141 + handle: this.handle, 142 + firstSeq: this.cmdOutbox[0].seq, 143 + ack: this.lastSnapAck, 144 + cmds: this.cmdOutbox.map((e) => e.cmd), 145 + }; 146 + this._send("arena:cmd", frame); 147 + this.cmdSent++; 148 + } 149 + 150 + _send(type, content) { 151 + if (this.ws?.readyState !== WebSocket.OPEN) return; 152 + this.ws.send(JSON.stringify({ type, content })); 153 + } 154 + 155 + bye() { 156 + this.wantClose = true; 157 + if (this.helloSent) this._send("arena:bye", { handle: this.handle }); 158 + setTimeout(() => this.ws?.close(), 100); 159 + } 160 + } 161 + 162 + function safeParse(s) { 163 + try { return JSON.parse(s); } catch { return s; } 164 + } 165 + 166 + async function cmdSwarm(opts) { 167 + const target = opts.target || DEFAULT_TARGET; 168 + const count = parseInt(opts.count, 10) || 4; 169 + const duration = parseInt(opts.duration, 10) || 30; 170 + console.log(`🏟️ swarm: ${count} clients → ${target} for ${duration}s`); 171 + 172 + const clients = []; 173 + const stats = { totalSnaps: 0, totalCmds: 0, lastReport: Date.now() }; 174 + 175 + for (let i = 0; i < count; i++) { 176 + const handle = rngHandle("swarm"); 177 + const c = new ArenaWSClient({ 178 + target, 179 + handle, 180 + onWelcome: (msg) => { 181 + const peers = (msg.roster || []).filter((h) => h !== handle).length; 182 + console.log(`✅ ${handle} welcome — ${peers} peer(s) already in arena`); 183 + }, 184 + onClose: () => console.log(`👋 ${handle} closed (snaps=${c.snapCount} cmds=${c.cmdSent})`), 185 + }); 186 + clients.push(c); 187 + // Stagger connects so we don't burst the server. 188 + setTimeout(() => c.connect(), i * 80); 189 + } 190 + 191 + const reporter = setInterval(() => { 192 + const totalSnaps = clients.reduce((s, c) => s + c.snapCount, 0); 193 + const totalCmds = clients.reduce((s, c) => s + c.cmdSent, 0); 194 + const dSnaps = totalSnaps - stats.totalSnaps; 195 + const dCmds = totalCmds - stats.totalCmds; 196 + const dt = (Date.now() - stats.lastReport) / 1000; 197 + stats.totalSnaps = totalSnaps; 198 + stats.totalCmds = totalCmds; 199 + stats.lastReport = Date.now(); 200 + const live = clients.filter((c) => c.ws?.readyState === WebSocket.OPEN).length; 201 + console.log(`📊 live=${live}/${count} cmd-tx=${(dCmds / dt).toFixed(0)}/s snap-rx=${(dSnaps / dt).toFixed(0)}/s`); 202 + }, 5000); 203 + 204 + const stopAt = Date.now() + duration * 1000; 205 + await new Promise((resolve) => { 206 + const tick = () => { 207 + if (Date.now() >= stopAt) return resolve(); 208 + setTimeout(tick, 250); 209 + }; 210 + tick(); 211 + process.on("SIGINT", () => { console.log("\n^C received"); resolve(); }); 212 + }); 213 + 214 + clearInterval(reporter); 215 + console.log(`🛑 closing ${clients.length} clients...`); 216 + for (const c of clients) c.bye(); 217 + await new Promise((r) => setTimeout(r, 800)); 218 + 219 + // Final summary. 220 + let snaps = 0, cmds = 0; 221 + for (const c of clients) { snaps += c.snapCount; cmds += c.cmdSent; } 222 + console.log(`✓ done — total snap rx=${snaps}, cmd tx=${cmds}`); 223 + } 224 + 225 + async function cmdWatch(opts) { 226 + const target = opts.target || DEFAULT_TARGET; 227 + const duration = parseInt(opts.duration, 10) || 0; // 0 = until SIGINT 228 + const handle = rngHandle("watch"); 229 + console.log(`👀 watch ${handle} → ${target}${duration ? ` for ${duration}s` : ""}`); 230 + 231 + let lastSnapMs = 0; 232 + let snapsThisInterval = 0; 233 + const c = new ArenaWSClient({ 234 + target, 235 + handle, 236 + mode: "probe", 237 + onWelcome: (msg) => { 238 + const roster = msg.roster || []; 239 + console.log(`✅ welcome — ${roster.length} player(s) in arena: ${roster.join(", ") || "(empty)"}`); 240 + }, 241 + onSnap: (snap) => { 242 + snapsThisInterval++; 243 + lastSnapMs = Date.now(); 244 + const players = snap.players || []; 245 + if (snap.tick % 30 === 0) { 246 + const heads = players.slice(0, 6).map((p) => `${p.h}@(${p.x.toFixed(1)},${p.z.toFixed(1)})`); 247 + console.log(`📸 t=${snap.tick} players=${players.length} ${heads.join(" ")}${players.length > 6 ? " …" : ""}`); 248 + } 249 + }, 250 + onClose: () => {}, 251 + }); 252 + c.connect(); 253 + 254 + const reporter = setInterval(() => { 255 + const ageMs = lastSnapMs ? Date.now() - lastSnapMs : -1; 256 + console.log(`📊 snaps/5s=${snapsThisInterval} last-snap-age=${ageMs}ms`); 257 + snapsThisInterval = 0; 258 + }, 5000); 259 + 260 + await new Promise((resolve) => { 261 + if (duration > 0) setTimeout(resolve, duration * 1000); 262 + process.on("SIGINT", () => { console.log("\n^C received"); resolve(); }); 263 + }); 264 + 265 + clearInterval(reporter); 266 + c.bye(); 267 + await new Promise((r) => setTimeout(r, 300)); 268 + console.log(`✓ done`); 269 + } 270 + 271 + async function main() { 272 + const args = parseArgs(process.argv.slice(2)); 273 + const sub = args._[0]; 274 + if (sub === "swarm") return cmdSwarm(args); 275 + if (sub === "watch") return cmdWatch(args); 276 + console.log(`Usage: 277 + node tools/ac-arena.mjs swarm [--count=N] [--duration=Ns] [--target=wss://...] 278 + node tools/ac-arena.mjs watch [--target=wss://...] [--duration=Ns] 279 + 280 + Default target: ${DEFAULT_TARGET}`); 281 + process.exit(1); 282 + } 283 + 284 + main().catch((err) => { 285 + console.error("fatal:", err); 286 + process.exit(2); 287 + });