From 1ba040aa3841f282a91374ce862bb509b179ba94 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 24 Apr 2026 15:55:34 -0700 Subject: [PATCH] arena: spectator-styled watchers + ac-arena CLI for end-to-end testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../public/aesthetic.computer/disks/arena.mjs | 62 +++- tools/ac-arena.mjs | 287 ++++++++++++++++++ 2 files changed, 336 insertions(+), 13 deletions(-) create mode 100755 tools/ac-arena.mjs diff --git a/system/public/aesthetic.computer/disks/arena.mjs b/system/public/aesthetic.computer/disks/arena.mjs index 51c23f2d95..2ba61bf3dc 100644 --- a/system/public/aesthetic.computer/disks/arena.mjs +++ b/system/public/aesthetic.computer/disks/arena.mjs @@ -465,12 +465,21 @@ function lerpAngle(a, b, k) { return a + d * k; } +// Anonymous tabs (guest_xxxx or swarm_xxx from the load-test CLI) get a +// "watcher" treatment — gray, translucent, tiny on the minimap — so the +// visual prominence in the arena is reserved for people who claimed a handle. +function isGuestHandle(h) { + return typeof h === "string" && (h.startsWith("guest_") || h.startsWith("swarm_")); +} + // Build a small humanoid stick-figure as a single line Form. Cheap to clone // per remote (handful of verts) and matches the arena's visual language. -function buildRemoteBody(Form, colorRGB) { +function buildRemoteBody(Form, colorRGB, watcher = false) { const [r, g, b] = colorRGB; - const col = [r / 255, g / 255, b / 255, 0.9]; - const colDim = [r / 255, g / 255, b / 255, 0.5]; + const a = watcher ? 0.35 : 0.9; + const aDim = watcher ? 0.18 : 0.5; + const col = [r / 255, g / 255, b / 255, a]; + const colDim = [r / 255, g / 255, b / 255, aDim]; const head = 0.3, shoulder = -0.4, hip = -1.1, foot = -2.0; // coords are (x, y, z, w); y is "up" in the form's local space. const pts = [ @@ -515,7 +524,11 @@ function paintRemotes(ink, form, Form) { for (const [handle, o] of Object.entries(others)) { const sample = sampleOther(o, t); if (!sample) continue; - if (!o.body) o.body = buildRemoteBody(Form, handleColor(handle)); + if (!o.body) { + const watcher = isGuestHandle(handle); + const color = watcher ? [170, 175, 195] : handleColor(handle); + o.body = buildRemoteBody(Form, color, watcher); + } // Mirror local body positioning: Form.position uses (-x, y, -z). o.body.position[0] = -sample.x; o.body.position[1] = sample.y; @@ -1879,22 +1892,34 @@ function paint({ wipe, ink, screen, write, box, system, pen, canvas, api, painti px: Math.round(ox + (wx - xMin) * sx), py: Math.round(oy + (wz - zMin) * sz), }); - // Remote players (interpolated like the 3D bodies). + // Remote players (interpolated like the 3D bodies). Watchers (anon + // guest_/swarm_) render as a small dim 1px dot; named players get a + // bright 3×3 colored square. const tNow = renderTimeNow(); for (const [handle, o] of Object.entries(others)) { const s = sampleOther(o, tNow); if (!s) continue; const { px, py } = project(s.x, s.z); - const [r, g, b] = handleColor(handle); - ink(r, g, b).box(px - 1, py - 1, 3, 3); + if (isGuestHandle(handle)) { + ink(150, 155, 175, 160).box(px, py, 1, 1); + } else { + const [r, g, b] = handleColor(handle); + ink(r, g, b).box(px - 1, py - 1, 3, 3); + } } - // Self — bigger white dot with a yaw notch. + // Self — white if you have a real handle, dim if anonymous, plus a + // yellow yaw notch so the map is orientation-readable. const camRef = system?.fps?.doll?.cam; if (camRef) { const myX = -camRef.x; const myZ = -camRef.z; const { px, py } = project(myX, myZ); - ink(255, 255, 255).box(px - 2, py - 2, 5, 5); + const meWatching = isGuestHandle(myHandle); + if (meWatching) { + ink(180, 185, 205).box(px - 1, py - 1, 3, 3); + } else { + ink(255, 255, 255).box(px - 2, py - 2, 5, 5); + } const yawR = camRef.rotY * Math.PI / 180; const tipX = px + Math.round(Math.sin(yawR) * 6); const tipY = py + Math.round(Math.cos(yawR) * 6); @@ -1906,14 +1931,25 @@ function paint({ wipe, ink, screen, write, box, system, pen, canvas, api, painti let lineY = margin + mapSize + 4; const advance = () => { lineY += lineH; }; - // Players: count + your handle. - const peers = Object.keys(others).length; - const total = peers + (netSpectator ? 0 : 1); + // Players + watchers — split named handles from anon guests so the + // count tells you who's *here* versus who's just looking around. + let namedRemotes = 0; + let watcherRemotes = 0; + for (const h of Object.keys(others)) { + if (isGuestHandle(h)) watcherRemotes++; else namedRemotes++; + } + const meIsWatcher = isGuestHandle(myHandle); + const namedTotal = namedRemotes + (netSpectator || meIsWatcher ? 0 : 1); + const watcherTotal = watcherRemotes + (netSpectator || meIsWatcher ? 1 : 0); rightLabelMulti( - [[dim, "players "], [peers > 0 ? [180, 230, 180] : [200, 200, 200], `${total}`]], + [[dim, "players "], [namedTotal > 0 ? [180, 230, 180] : [180, 180, 190], `${namedTotal}`]], lineY, ); advance(); + if (watcherTotal > 0) { + rightLabelMulti([[dim, "watchers "], [[170, 175, 200], `${watcherTotal}`]], lineY); + advance(); + } // Connection: transport + how stale snaps are + lag. { diff --git a/tools/ac-arena.mjs b/tools/ac-arena.mjs new file mode 100755 index 0000000000..a484b0c679 --- /dev/null +++ b/tools/ac-arena.mjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node +// ac-arena — multiplayer end-to-end test client for arena.mjs. +// +// node tools/ac-arena.mjs swarm [--count=4] [--duration=30] [--target=...] +// node tools/ac-arena.mjs watch [--target=...] [--duration=30] +// +// `swarm` opens N WebSocket-only fake clients that send arena:hello + +// arena:cmd at 60 Hz with mild random motion. They do not connect over +// UDP/geckos (Node has no built-in WebRTC), which exercises the WS +// fallback paths server-side. Use it to load-test arena-manager and +// confirm the cmd direction is reaching receiveCmd. +// +// `watch` opens a single passive probe and prints arena:welcome roster + +// per-second snap counts. Useful for diagnosing whether snaps are +// flowing at all without involving the browser/PWA. + +// Uses Node's built-in WebSocket (>=22). No `ws` dep required. +import { packCmd, BTN } from "../system/public/aesthetic.computer/lib/pmove.mjs"; + +const DEFAULT_TARGET = "wss://session-server.aesthetic.computer"; +const CMD_HZ = 60; +const CMD_BACKUP = 3; + +function parseArgs(argv) { + const out = { _: [] }; + for (const arg of argv) { + if (arg.startsWith("--")) { + const [k, v] = arg.slice(2).split("="); + out[k] = v ?? true; + } else { + out._.push(arg); + } + } + return out; +} + +function rngHandle(prefix) { + return `${prefix}_${Math.floor(Math.random() * 9_000) + 1000}`; +} + +class ArenaWSClient { + constructor({ target, handle, onSnap, onWelcome, onClose, onError, mode = "active" }) { + this.target = target; + this.handle = handle; + this.mode = mode; // "active" | "probe" + this.onSnap = onSnap || (() => {}); + this.onWelcome = onWelcome || (() => {}); + this.onClose = onClose || (() => {}); + this.onError = onError || ((e) => console.error("ws err:", e?.message || e)); + this.connectedAt = 0; + this.cmdSeq = 0; + this.cmdOutbox = []; // last CMD_BACKUP cmds + this.lastSnapAck = 0; + this.cmdInterval = null; + this.flushAcc = 0; + this.snapCount = 0; + this.cmdSent = 0; + this.wantClose = false; + this.ws = null; + this.helloSent = false; + } + + connect() { + this.ws = new WebSocket(this.target); + this.ws.addEventListener("open", () => this._onOpen()); + this.ws.addEventListener("message", (ev) => this._onMessage(ev.data)); + this.ws.addEventListener("close", () => this._onClose()); + this.ws.addEventListener("error", (ev) => this.onError(ev?.error || ev?.message || ev)); + } + + _onOpen() { + this.connectedAt = Date.now(); + this._send("arena:hello", { handle: this.handle, ...(this.mode === "probe" ? { probe: true } : {}) }); + this.helloSent = true; + if (this.mode === "active") { + // 60 Hz tick: produce one cmd per tick, flush every 2 ticks (30 Hz frames). + this.cmdInterval = setInterval(() => this._tick(), 1000 / CMD_HZ); + } + } + + _onClose() { + if (this.cmdInterval) { clearInterval(this.cmdInterval); this.cmdInterval = null; } + this.onClose(); + } + + _onMessage(raw) { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + if (msg.type === "connected" || msg.type === "joined") return; + const content = typeof msg.content === "string" ? safeParse(msg.content) : msg.content; + if (msg.type === "arena:welcome") { + this.onWelcome(content); + return; + } + if (msg.type === "arena:snap") { + this.snapCount++; + if (typeof content?.messageNum === "number" && content.messageNum > this.lastSnapAck) { + this.lastSnapAck = content.messageNum; + } + this.onSnap(content); + return; + } + if (msg.type === "arena:takeover") { + console.warn(`[${this.handle}] kicked to spectator (taken over by another tab)`); + } + } + + _tick() { + // Compose a usercmd. Random walk: change direction every ~0.5s. + if (!this._dir || Math.random() < 1 / (CMD_HZ * 0.5)) { + this._dir = { + fwd: Math.random() < 0.4 ? (Math.random() < 0.5 ? -1 : 1) : 0, + right: Math.random() < 0.4 ? (Math.random() < 0.5 ? -1 : 1) : 0, + yaw: Math.random() * 360 - 180, + jump: Math.random() < 0.05, + }; + } + const ms = Date.now() - this.connectedAt; + const cmd = packCmd({ + ms, + fwd: this._dir.fwd, + right: this._dir.right, + yaw: this._dir.yaw, + pitch: 0, + buttons: this._dir.jump ? BTN.JUMP : 0, + }); + this.cmdSeq++; + this.cmdOutbox.push({ seq: this.cmdSeq, cmd }); + while (this.cmdOutbox.length > CMD_BACKUP) this.cmdOutbox.shift(); + + this.flushAcc++; + if (this.flushAcc >= 2) { // 30 Hz frame rate + this.flushAcc = 0; + this._flush(); + } + } + + _flush() { + if (this.cmdOutbox.length === 0) return; + const frame = { + handle: this.handle, + firstSeq: this.cmdOutbox[0].seq, + ack: this.lastSnapAck, + cmds: this.cmdOutbox.map((e) => e.cmd), + }; + this._send("arena:cmd", frame); + this.cmdSent++; + } + + _send(type, content) { + if (this.ws?.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ type, content })); + } + + bye() { + this.wantClose = true; + if (this.helloSent) this._send("arena:bye", { handle: this.handle }); + setTimeout(() => this.ws?.close(), 100); + } +} + +function safeParse(s) { + try { return JSON.parse(s); } catch { return s; } +} + +async function cmdSwarm(opts) { + const target = opts.target || DEFAULT_TARGET; + const count = parseInt(opts.count, 10) || 4; + const duration = parseInt(opts.duration, 10) || 30; + console.log(`🏟️ swarm: ${count} clients → ${target} for ${duration}s`); + + const clients = []; + const stats = { totalSnaps: 0, totalCmds: 0, lastReport: Date.now() }; + + for (let i = 0; i < count; i++) { + const handle = rngHandle("swarm"); + const c = new ArenaWSClient({ + target, + handle, + onWelcome: (msg) => { + const peers = (msg.roster || []).filter((h) => h !== handle).length; + console.log(`✅ ${handle} welcome — ${peers} peer(s) already in arena`); + }, + onClose: () => console.log(`👋 ${handle} closed (snaps=${c.snapCount} cmds=${c.cmdSent})`), + }); + clients.push(c); + // Stagger connects so we don't burst the server. + setTimeout(() => c.connect(), i * 80); + } + + const reporter = setInterval(() => { + const totalSnaps = clients.reduce((s, c) => s + c.snapCount, 0); + const totalCmds = clients.reduce((s, c) => s + c.cmdSent, 0); + const dSnaps = totalSnaps - stats.totalSnaps; + const dCmds = totalCmds - stats.totalCmds; + const dt = (Date.now() - stats.lastReport) / 1000; + stats.totalSnaps = totalSnaps; + stats.totalCmds = totalCmds; + stats.lastReport = Date.now(); + const live = clients.filter((c) => c.ws?.readyState === WebSocket.OPEN).length; + console.log(`📊 live=${live}/${count} cmd-tx=${(dCmds / dt).toFixed(0)}/s snap-rx=${(dSnaps / dt).toFixed(0)}/s`); + }, 5000); + + const stopAt = Date.now() + duration * 1000; + await new Promise((resolve) => { + const tick = () => { + if (Date.now() >= stopAt) return resolve(); + setTimeout(tick, 250); + }; + tick(); + process.on("SIGINT", () => { console.log("\n^C received"); resolve(); }); + }); + + clearInterval(reporter); + console.log(`🛑 closing ${clients.length} clients...`); + for (const c of clients) c.bye(); + await new Promise((r) => setTimeout(r, 800)); + + // Final summary. + let snaps = 0, cmds = 0; + for (const c of clients) { snaps += c.snapCount; cmds += c.cmdSent; } + console.log(`✓ done — total snap rx=${snaps}, cmd tx=${cmds}`); +} + +async function cmdWatch(opts) { + const target = opts.target || DEFAULT_TARGET; + const duration = parseInt(opts.duration, 10) || 0; // 0 = until SIGINT + const handle = rngHandle("watch"); + console.log(`👀 watch ${handle} → ${target}${duration ? ` for ${duration}s` : ""}`); + + let lastSnapMs = 0; + let snapsThisInterval = 0; + const c = new ArenaWSClient({ + target, + handle, + mode: "probe", + onWelcome: (msg) => { + const roster = msg.roster || []; + console.log(`✅ welcome — ${roster.length} player(s) in arena: ${roster.join(", ") || "(empty)"}`); + }, + onSnap: (snap) => { + snapsThisInterval++; + lastSnapMs = Date.now(); + const players = snap.players || []; + if (snap.tick % 30 === 0) { + const heads = players.slice(0, 6).map((p) => `${p.h}@(${p.x.toFixed(1)},${p.z.toFixed(1)})`); + console.log(`📸 t=${snap.tick} players=${players.length} ${heads.join(" ")}${players.length > 6 ? " …" : ""}`); + } + }, + onClose: () => {}, + }); + c.connect(); + + const reporter = setInterval(() => { + const ageMs = lastSnapMs ? Date.now() - lastSnapMs : -1; + console.log(`📊 snaps/5s=${snapsThisInterval} last-snap-age=${ageMs}ms`); + snapsThisInterval = 0; + }, 5000); + + await new Promise((resolve) => { + if (duration > 0) setTimeout(resolve, duration * 1000); + process.on("SIGINT", () => { console.log("\n^C received"); resolve(); }); + }); + + clearInterval(reporter); + c.bye(); + await new Promise((r) => setTimeout(r, 300)); + console.log(`✓ done`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const sub = args._[0]; + if (sub === "swarm") return cmdSwarm(args); + if (sub === "watch") return cmdWatch(args); + console.log(`Usage: + node tools/ac-arena.mjs swarm [--count=N] [--duration=Ns] [--target=wss://...] + node tools/ac-arena.mjs watch [--target=wss://...] [--duration=Ns] + +Default target: ${DEFAULT_TARGET}`); + process.exit(1); +} + +main().catch((err) => { + console.error("fatal:", err); + process.exit(2); +}); -- 2.51.2