Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

arena: fix ghost-players and frozen-positions after reload

Two bugs users hit in the first live test:

1. Ghost players never leave. arena:hello handler didn't set
clients[id].handle, so the WS-close handler couldn't find a
handle to call playerLeave with. Player records leaked forever
on every hello. Fixed by setting clients[id].handle in the
arena:hello handler, same shape as the chat login path.

2. Positions stuck after page refresh. ArenaManager.playerJoin
reused the existing record on rejoin (good for continuity), but
kept its lastCmdSeq/lastCmdMs/lastAckMessageNum from the prior
session. New client's cmds start at seq=1 so they got dropped
as "already seen", freezing the avatar. Fix: reset those
counters and snapHistory on rejoin. Position continuity is
preserved, cmd stream starts fresh.

Plus two belt-and-suspenders:

- playerLeave(handle, wsId?) — guards the reload race so a late
close from the old socket doesn't delete the rebound player.
- sweepStale() — every second, evict players whose lastSeenMs is
older than 30s. Catches crashed tabs / NATed mobile backgrounds
that the close handler would otherwise miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+59 -7
+48 -5
session-server/arena-manager.mjs
··· 20 20 const SNAP_EVERY = TICK_RATE / SNAP_RATE; 21 21 const SNAP_RING = 32; // per-client snapshot history depth 22 22 const POS_HISTORY_MS = 500; // rolling pos history for lag comp 23 + const STALE_TIMEOUT_MS = 30_000; // evict players idle this long 23 24 24 25 // Default arena world config — must match disks/arena.mjs. 25 26 export const ARENA_CFG = Object.freeze({ ··· 89 90 90 91 let rec = this.players.get(handle); 91 92 if (rec) { 92 - // Re-join (page reload / reconnect): reuse the body but refresh wsId. 93 + // Re-join (page reload / reconnect): keep the body continuity but reset 94 + // the per-connection bookkeeping. The new client starts its cmd seq and 95 + // snap-ack counters from zero, so we must wipe ours or every new cmd 96 + // would be "already seen" and dropped, freezing the avatar in place. 93 97 rec.wsId = wsId; 94 98 rec.udpChannelId = this.resolveUdpForHandle?.(handle) ?? null; 99 + rec.lastCmdSeq = 0; 100 + rec.lastCmdMs = 0; 101 + rec.lastAckMessageNum = 0; 102 + rec.nextMessageNum = 1; 103 + rec.snapHistory.fill(null); 104 + rec.lastSeenMs = this.now(); 95 105 } else { 96 106 const spawn = SPAWNS[this.players.size % SPAWNS.length]; 97 107 rec = { ··· 125 135 this.ensureTick(); 126 136 } 127 137 128 - playerLeave(handle) { 138 + /** 139 + * Leave. `onlyIfWsId` (optional) guards the reload race: when a tab 140 + * reloads quickly, the new ws may hello before the old ws's close 141 + * handler fires. Without this guard, the close would delete the 142 + * freshly-rebound player. Pass the wsId that was on the closing socket 143 + * and we only delete if it's still the active one. 144 + */ 145 + playerLeave(handle, onlyIfWsId = undefined) { 129 146 if (!handle) return; 130 147 if (this.probes.delete(handle)) { 131 148 console.log(`🏟️ probe left: ${handle} (${this.probes.size} probes)`); 132 149 this.maybeStopTick(); 133 150 return; 134 151 } 135 - if (this.players.delete(handle)) { 136 - this.broadcastWS?.("arena:leave", { handle }); 137 - console.log(`🏟️ left: ${handle} (${this.players.size} players)`); 152 + const rec = this.players.get(handle); 153 + if (!rec) return; 154 + if (onlyIfWsId !== undefined && rec.wsId !== onlyIfWsId) { 155 + console.log(`🏟️ stale leave for ${handle} (wsId=${onlyIfWsId} ≠ active ${rec.wsId}) — ignored`); 156 + return; 138 157 } 158 + this.players.delete(handle); 159 + this.broadcastWS?.("arena:leave", { handle }); 160 + console.log(`🏟️ left: ${handle} (${this.players.size} players)`); 139 161 this.maybeStopTick(); 140 162 } 141 163 ··· 219 241 } 220 242 } 221 243 244 + // Stale sweep: if we haven't seen a hello / cmd / ack from a player in 245 + // STALE_TIMEOUT_MS, evict. Belt-and-suspenders for cases the ws close 246 + // handler misses (crashed tabs, NATed mobile backgrounds, etc). 247 + if (this.tick % TICK_RATE === 0) this.sweepStale(nowMs); 248 + 222 249 if (this.tick % SNAP_EVERY === 0) this.broadcastSnapshots(); 250 + } 251 + 252 + sweepStale(nowMs) { 253 + for (const [handle, rec] of this.players) { 254 + if (nowMs - rec.lastSeenMs > STALE_TIMEOUT_MS) { 255 + console.log(`🏟️ sweep ${handle} (idle ${((nowMs - rec.lastSeenMs) / 1000).toFixed(1)}s)`); 256 + this.players.delete(handle); 257 + this.broadcastWS?.("arena:leave", { handle }); 258 + } 259 + } 260 + for (const [handle, p] of this.probes) { 261 + // Probes also expire; lastSeen isn't tracked for them today, so use 262 + // a simple heuristic: if the ws is gone (we don't know), skip. No-op. 263 + void handle; void p; 264 + } 265 + this.maybeStopTick(); 223 266 } 224 267 225 268 // -- Snapshots --
+11 -2
session-server/session.mjs
··· 2875 2875 // 🏟️ Arena messages — routed to ArenaManager (server-authoritative) 2876 2876 if (msg.type === "arena:hello") { 2877 2877 const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2878 - if (parsed?.handle) arenaManager.playerJoin(parsed.handle, id, { probe: !!parsed.probe }); 2878 + if (parsed?.handle) { 2879 + // Ensure clients[id].handle is set so the WS-close handler can 2880 + // look it up and call playerLeave. Without this, probes + any 2881 + // client that hasn't sent a chat login message would leak forever. 2882 + if (!clients[id]) clients[id] = {}; 2883 + clients[id].handle = parsed.handle; 2884 + arenaManager.playerJoin(parsed.handle, id, { probe: !!parsed.probe }); 2885 + } 2879 2886 return; 2880 2887 } 2881 2888 if (msg.type === "arena:bye") { ··· 2905 2912 const departingHandle = normalizeProfileHandle(clients?.[id]?.handle); 2906 2913 if (departingHandle) duelManager.playerLeave(departingHandle); 2907 2914 // Arena uses the raw handle (matches arena:hello), not the @-normalized form. 2915 + // Pass the closing wsId so a quick reload-race doesn't delete the 2916 + // freshly-rebound player (the new hello will have set a different wsId). 2908 2917 const rawDepartingHandle = clients?.[id]?.handle; 2909 - if (rawDepartingHandle) arenaManager.playerLeave(rawDepartingHandle); 2918 + if (rawDepartingHandle) arenaManager.playerLeave(rawDepartingHandle, id); 2910 2919 removeNotepatMidiSubscriber(id); 2911 2920 2912 2921 // Remove from VSCode clients if present