perf(playground): multiplayer optimization sweep (5Hz + skip-still + world-scope + push-counts)

Client (use-playground-multiplayer.ts):
- Drop presence to 5 Hz (200ms, was 100ms). Halves bandwidth, identical look.
- Skip-send when player static (POS_EPSILON 0.04, YAW_EPSILON ~1.4°).
- Avatar config sent only on signature change, not every tick.
- Position-delta gate before re-render (RENDER_POS_EPSILON 0.03).
- World-scoped local rendering (visibleRemotes) — never see remotes from
  other worlds.
- Connection state ('offline' | 'broadcast' | 'ws' | 'both') exposed for HUD.
- Server-pushed count consumed via 'count' wire kind (no /stats polling).

Worker (playground-ws-worker/src/worker.ts) v1:
- World-scoped fan-out: only broadcast to recipients in same world.
- Push 'count' messages on every join/leave (zero poll cost on clients).
- Per-socket token bucket rate limit: 30 msgs/sec.
- 50ms presence dedupe per player (drops floods).
- Stale prune at 5s (matches client).
- Send live count baseline on connect handshake.

HUD (playground-online-chip.tsx):
- Consume server-pushed 'hermes-playground-count' CustomEvent.
- /stats fetch only as 3s fallback if no push arrives.
- Connection-state dot: green (live), yellow (local-only), red (offline).
- Tooltip shows peak + per-world breakdown.
- Pulsing animation when fully connected.

World-3d:
- Coalesced position poll to 200ms (matches presence cadence).
- Surface transport + serverCount on window for HUD chip.

Verified: pnpm build clean, worker deploy clean, WS handshake + count
push + byWorld breakdown all working against the live worker.
This commit is contained in:
Aurora release bot
2026-05-03 09:02:06 -04:00
parent 8a5971c97d
commit 01affa551b
6 changed files with 1345 additions and 107 deletions

975
playground-ws-worker/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,975 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250403.0
version: 4.20260503.1
typescript:
specifier: ^5.6.0
version: 5.9.3
wrangler:
specifier: ^3.107.0
version: 3.114.17(@cloudflare/workers-types@4.20260503.1)
packages:
'@cloudflare/kv-asset-handler@0.3.4':
resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==}
engines: {node: '>=16.13'}
'@cloudflare/unenv-preset@2.0.2':
resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==}
peerDependencies:
unenv: 2.0.0-rc.14
workerd: ^1.20250124.0
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20250718.0':
resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20250718.0':
resolution: {integrity: sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20250718.0':
resolution: {integrity: sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20250718.0':
resolution: {integrity: sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20250718.0':
resolution: {integrity: sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260503.1':
resolution: {integrity: sha512-8VKtafR4fNMtddutOnam3yq3AQvrl9bzuMio3B3AEAfrdx7xaaDV0Oyxz54P07lODwX0jydukGLC1rpDdYXAAA==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild-plugins/node-globals-polyfill@0.2.3':
resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==}
peerDependencies:
esbuild: '*'
'@esbuild-plugins/node-modules-polyfill@0.2.2':
resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==}
peerDependencies:
esbuild: '*'
'@esbuild/android-arm64@0.17.19':
resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.17.19':
resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.17.19':
resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.17.19':
resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.17.19':
resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.17.19':
resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.17.19':
resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.17.19':
resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.17.19':
resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.17.19':
resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.17.19':
resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.17.19':
resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.17.19':
resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.17.19':
resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.17.19':
resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.17.19':
resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.17.19':
resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.17.19':
resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.17.19':
resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.17.19':
resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.17.19':
resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.17.19':
resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.33.5':
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.0.4':
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.0.4':
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.0.4':
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-ia32@0.33.5':
resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.33.5':
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
acorn-walk@8.3.2:
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
engines: {node: '>=0.4.0'}
acorn@8.14.0:
resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
engines: {node: '>=0.4.0'}
hasBin: true
as-table@1.0.55:
resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
data-uri-to-buffer@2.0.2:
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
esbuild@0.17.19:
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
engines: {node: '>=12'}
hasBin: true
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
estree-walker@0.6.1:
resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==}
exit-hook@2.2.1:
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
engines: {node: '>=6'}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-source@2.0.12:
resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==}
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
hasBin: true
miniflare@3.20250718.3:
resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==}
engines: {node: '>=16.13'}
hasBin: true
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
printable-characters@1.0.42:
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
rollup-plugin-inject@3.0.2:
resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==}
deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
rollup-plugin-node-polyfills@0.2.1:
resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==}
rollup-pluginutils@2.8.2:
resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
deprecated: Please use @jridgewell/sourcemap-codec instead
stacktracey@2.2.0:
resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==}
stoppable@1.1.0:
resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==}
engines: {node: '>=4', npm: '>=6'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
undici@5.29.0:
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
engines: {node: '>=14.0'}
unenv@2.0.0-rc.14:
resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==}
workerd@1.20250718.0:
resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==}
engines: {node: '>=16'}
hasBin: true
wrangler@3.114.17:
resolution: {integrity: sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==}
engines: {node: '>=16.17.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20250408.0
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch@3.3.4:
resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==}
zod@3.22.3:
resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==}
snapshots:
'@cloudflare/kv-asset-handler@0.3.4':
dependencies:
mime: 3.0.0
'@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)':
dependencies:
unenv: 2.0.0-rc.14
optionalDependencies:
workerd: 1.20250718.0
'@cloudflare/workerd-darwin-64@1.20250718.0':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20250718.0':
optional: true
'@cloudflare/workerd-linux-64@1.20250718.0':
optional: true
'@cloudflare/workerd-linux-arm64@1.20250718.0':
optional: true
'@cloudflare/workerd-windows-64@1.20250718.0':
optional: true
'@cloudflare/workers-types@4.20260503.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)':
dependencies:
esbuild: 0.17.19
'@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)':
dependencies:
esbuild: 0.17.19
escape-string-regexp: 4.0.0
rollup-plugin-node-polyfills: 0.2.1
'@esbuild/android-arm64@0.17.19':
optional: true
'@esbuild/android-arm@0.17.19':
optional: true
'@esbuild/android-x64@0.17.19':
optional: true
'@esbuild/darwin-arm64@0.17.19':
optional: true
'@esbuild/darwin-x64@0.17.19':
optional: true
'@esbuild/freebsd-arm64@0.17.19':
optional: true
'@esbuild/freebsd-x64@0.17.19':
optional: true
'@esbuild/linux-arm64@0.17.19':
optional: true
'@esbuild/linux-arm@0.17.19':
optional: true
'@esbuild/linux-ia32@0.17.19':
optional: true
'@esbuild/linux-loong64@0.17.19':
optional: true
'@esbuild/linux-mips64el@0.17.19':
optional: true
'@esbuild/linux-ppc64@0.17.19':
optional: true
'@esbuild/linux-riscv64@0.17.19':
optional: true
'@esbuild/linux-s390x@0.17.19':
optional: true
'@esbuild/linux-x64@0.17.19':
optional: true
'@esbuild/netbsd-x64@0.17.19':
optional: true
'@esbuild/openbsd-x64@0.17.19':
optional: true
'@esbuild/sunos-x64@0.17.19':
optional: true
'@esbuild/win32-arm64@0.17.19':
optional: true
'@esbuild/win32-ia32@0.17.19':
optional: true
'@esbuild/win32-x64@0.17.19':
optional: true
'@fastify/busboy@2.1.1': {}
'@img/sharp-darwin-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
optional: true
'@img/sharp-darwin-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.0.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm@1.0.5':
optional: true
'@img/sharp-libvips-linux-s390x@1.0.4':
optional: true
'@img/sharp-libvips-linux-x64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
optional: true
'@img/sharp-linux-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.0.4
optional: true
'@img/sharp-linux-arm@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5
optional: true
'@img/sharp-linux-s390x@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.0.4
optional: true
'@img/sharp-linux-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4
optional: true
'@img/sharp-linuxmusl-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
optional: true
'@img/sharp-linuxmusl-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
optional: true
'@img/sharp-wasm32@0.33.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-ia32@0.33.5':
optional: true
'@img/sharp-win32-x64@0.33.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
acorn-walk@8.3.2: {}
acorn@8.14.0: {}
as-table@1.0.55:
dependencies:
printable-characters: 1.0.42
blake3-wasm@2.1.5: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
optional: true
color-name@1.1.4:
optional: true
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
optional: true
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
optional: true
cookie@0.7.2: {}
data-uri-to-buffer@2.0.2: {}
defu@6.1.7: {}
detect-libc@2.1.2:
optional: true
esbuild@0.17.19:
optionalDependencies:
'@esbuild/android-arm': 0.17.19
'@esbuild/android-arm64': 0.17.19
'@esbuild/android-x64': 0.17.19
'@esbuild/darwin-arm64': 0.17.19
'@esbuild/darwin-x64': 0.17.19
'@esbuild/freebsd-arm64': 0.17.19
'@esbuild/freebsd-x64': 0.17.19
'@esbuild/linux-arm': 0.17.19
'@esbuild/linux-arm64': 0.17.19
'@esbuild/linux-ia32': 0.17.19
'@esbuild/linux-loong64': 0.17.19
'@esbuild/linux-mips64el': 0.17.19
'@esbuild/linux-ppc64': 0.17.19
'@esbuild/linux-riscv64': 0.17.19
'@esbuild/linux-s390x': 0.17.19
'@esbuild/linux-x64': 0.17.19
'@esbuild/netbsd-x64': 0.17.19
'@esbuild/openbsd-x64': 0.17.19
'@esbuild/sunos-x64': 0.17.19
'@esbuild/win32-arm64': 0.17.19
'@esbuild/win32-ia32': 0.17.19
'@esbuild/win32-x64': 0.17.19
escape-string-regexp@4.0.0: {}
estree-walker@0.6.1: {}
exit-hook@2.2.1: {}
exsolve@1.0.8: {}
fsevents@2.3.3:
optional: true
get-source@2.0.12:
dependencies:
data-uri-to-buffer: 2.0.2
source-map: 0.6.1
glob-to-regexp@0.4.1: {}
is-arrayish@0.3.4:
optional: true
magic-string@0.25.9:
dependencies:
sourcemap-codec: 1.4.8
mime@3.0.0: {}
miniflare@3.20250718.3:
dependencies:
'@cspotcode/source-map-support': 0.8.1
acorn: 8.14.0
acorn-walk: 8.3.2
exit-hook: 2.2.1
glob-to-regexp: 0.4.1
stoppable: 1.1.0
undici: 5.29.0
workerd: 1.20250718.0
ws: 8.18.0
youch: 3.3.4
zod: 3.22.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
mustache@4.2.0: {}
ohash@2.0.11: {}
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
printable-characters@1.0.42: {}
rollup-plugin-inject@3.0.2:
dependencies:
estree-walker: 0.6.1
magic-string: 0.25.9
rollup-pluginutils: 2.8.2
rollup-plugin-node-polyfills@0.2.1:
dependencies:
rollup-plugin-inject: 3.0.2
rollup-pluginutils@2.8.2:
dependencies:
estree-walker: 0.6.1
semver@7.7.4:
optional: true
sharp@0.33.5:
dependencies:
color: 4.2.3
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5
'@img/sharp-libvips-darwin-arm64': 1.0.4
'@img/sharp-libvips-darwin-x64': 1.0.4
'@img/sharp-libvips-linux-arm': 1.0.5
'@img/sharp-libvips-linux-arm64': 1.0.4
'@img/sharp-libvips-linux-s390x': 1.0.4
'@img/sharp-libvips-linux-x64': 1.0.4
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
'@img/sharp-linux-arm': 0.33.5
'@img/sharp-linux-arm64': 0.33.5
'@img/sharp-linux-s390x': 0.33.5
'@img/sharp-linux-x64': 0.33.5
'@img/sharp-linuxmusl-arm64': 0.33.5
'@img/sharp-linuxmusl-x64': 0.33.5
'@img/sharp-wasm32': 0.33.5
'@img/sharp-win32-ia32': 0.33.5
'@img/sharp-win32-x64': 0.33.5
optional: true
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
optional: true
source-map@0.6.1: {}
sourcemap-codec@1.4.8: {}
stacktracey@2.2.0:
dependencies:
as-table: 1.0.55
get-source: 2.0.12
stoppable@1.1.0: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
ufo@1.6.4: {}
undici@5.29.0:
dependencies:
'@fastify/busboy': 2.1.1
unenv@2.0.0-rc.14:
dependencies:
defu: 6.1.7
exsolve: 1.0.8
ohash: 2.0.11
pathe: 2.0.3
ufo: 1.6.4
workerd@1.20250718.0:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20250718.0
'@cloudflare/workerd-darwin-arm64': 1.20250718.0
'@cloudflare/workerd-linux-64': 1.20250718.0
'@cloudflare/workerd-linux-arm64': 1.20250718.0
'@cloudflare/workerd-windows-64': 1.20250718.0
wrangler@3.114.17(@cloudflare/workers-types@4.20260503.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.3.4
'@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)
'@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19)
'@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19)
blake3-wasm: 2.1.5
esbuild: 0.17.19
miniflare: 3.20250718.3
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.14
workerd: 1.20250718.0
optionalDependencies:
'@cloudflare/workers-types': 4.20260503.1
fsevents: 2.3.3
sharp: 0.33.5
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch@3.3.4:
dependencies:
cookie: 0.7.2
mustache: 4.2.0
stacktracey: 2.2.0
zod@3.22.3: {}

View File

@@ -5,10 +5,17 @@
* that mirrors the Node sidecar (`scripts/playground-ws.mjs`) protocol so
* the client (`use-playground-multiplayer.ts`) connects unchanged.
*
* v1 hardening (2026-05-03):
* - World-scoped fan-out: only broadcast presence to clients in same world.
* - Server pushes `count` events on changes (HUD doesn't need to poll).
* - Per-socket rate limit: 30 msgs/sec token bucket (drop excess).
* - Dedupe: skip relaying identical presence within 50ms per player.
* - Stale prune at 5s (matches client).
*
* Endpoints
* GET /playground — WebSocket upgrade (presence + chat fan-out)
* GET /stats — JSON { online, byWorld, peakToday, ts }
* GET /health — JSON { ok: true }
* GET /health — JSON { ok: true, online, ts }
*/
export interface Env {
@@ -19,30 +26,47 @@ interface PresenceMsg {
kind: 'presence'
id: string
worldId?: string
world?: string
x?: number
y?: number
z?: number
yaw?: number
ts?: number
[key: string]: unknown
}
const STALE_AFTER_MS = 6000
const STALE_AFTER_MS = 5000
const CHAT_RING_MAX = 50
const PRESENCE_DEDUPE_MS = 50
const RATE_BUCKET_CAP = 30 // msgs
const RATE_REFILL_PER_SEC = 30
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// Single global room for v0; partition by ?room= later if needed.
const id = env.PLAYGROUND_HUB.idFromName('global')
const stub = env.PLAYGROUND_HUB.get(id)
return stub.fetch(request)
},
}
interface SocketMeta {
playerId?: string
world?: string
bucket: number
bucketTs: number
lastPresenceTs: number
}
export class PlaygroundHub {
state: DurableObjectState
sockets = new Set<WebSocket>()
socketMeta = new WeakMap<WebSocket, { playerId?: string }>()
socketMeta = new WeakMap<WebSocket, SocketMeta>()
presence = new Map<string, PresenceMsg & { ts: number }>()
chatRing: any[] = []
peakToday = 0
peakDay = ''
// Sliding count for push notifications when set changes.
lastBroadcastCount = -1
constructor(state: DurableObjectState) {
this.state = state
@@ -53,7 +77,6 @@ export class PlaygroundHub {
this.peakDay = stored.day
}
})
// Periodic prune for stale presence
this.state.blockConcurrencyWhile(async () => {
this.scheduleAlarm()
})
@@ -73,18 +96,28 @@ export class PlaygroundHub {
pruneStale() {
const cutoff = Date.now() - STALE_AFTER_MS
let removed = false
for (const [id, p] of this.presence) {
if (p.ts < cutoff) {
const ts = (p as any).ts
if (typeof ts === 'number' && ts < cutoff) {
this.presence.delete(id)
this.broadcast(null, { kind: 'leave', id })
const world = (p.world || p.worldId) as string | undefined
this.broadcast(null, { kind: 'leave', id }, { world })
removed = true
}
}
if (removed) this.maybeBroadcastCount()
}
broadcast(origin: WebSocket | null, data: any) {
worldOf(socket: WebSocket): string | undefined {
return this.socketMeta.get(socket)?.world
}
broadcast(origin: WebSocket | null, data: any, opts?: { world?: string }) {
const payload = typeof data === 'string' ? data : JSON.stringify(data)
for (const sock of this.sockets) {
if (sock === origin) continue
if (opts?.world && this.worldOf(sock) && this.worldOf(sock) !== opts.world) continue
try { sock.send(payload) } catch {}
}
}
@@ -99,27 +132,65 @@ export class PlaygroundHub {
this.peakDay = today
this.peakToday = 0
}
if (this.sockets.size > this.peakToday) {
this.peakToday = this.sockets.size
const live = this.presence.size
if (live > this.peakToday) {
this.peakToday = live
await this.state.storage.put('peak', { peak: this.peakToday, day: this.peakDay })
}
}
statsJson() {
const byWorld: Record<string, number> = {}
byWorld(): Record<string, number> {
const out: Record<string, number> = {}
for (const p of this.presence.values()) {
const w = (p.worldId as string) || 'unknown'
byWorld[w] = (byWorld[w] || 0) + 1
const w = (p.world || p.worldId) as string | undefined
if (!w) continue
out[w] = (out[w] || 0) + 1
}
return out
}
countMessage() {
return JSON.stringify({
kind: 'count',
online: this.presence.size,
byWorld: this.byWorld(),
peakToday: this.peakToday,
ts: Date.now(),
})
}
/** Push a count update to all sockets when the count actually changed. */
maybeBroadcastCount() {
const live = this.presence.size
if (live === this.lastBroadcastCount) return
this.lastBroadcastCount = live
const payload = this.countMessage()
for (const sock of this.sockets) {
try { sock.send(payload) } catch {}
}
}
statsJson() {
return {
online: this.presence.size,
byWorld,
byWorld: this.byWorld(),
peakToday: this.peakToday,
peakDay: this.peakDay,
ts: Date.now(),
}
}
// Token bucket: returns true if allowed, false if rate-limited.
spend(meta: SocketMeta): boolean {
const now = Date.now()
const dt = (now - meta.bucketTs) / 1000
meta.bucket = Math.min(RATE_BUCKET_CAP, meta.bucket + dt * RATE_REFILL_PER_SEC)
meta.bucketTs = now
if (meta.bucket < 1) return false
meta.bucket -= 1
return true
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url)
const cors = {
@@ -134,7 +205,7 @@ export class PlaygroundHub {
if (url.pathname === '/health' || url.pathname === '/') {
return Response.json(
{ ok: true, online: this.sockets.size, ts: Date.now() },
{ ok: true, online: this.presence.size, ts: Date.now() },
{ headers: cors },
)
}
@@ -162,13 +233,18 @@ export class PlaygroundHub {
async handleSocket(socket: WebSocket) {
socket.accept()
this.sockets.add(socket)
this.socketMeta.set(socket, {})
await this.bumpPeak()
this.socketMeta.set(socket, {
bucket: RATE_BUCKET_CAP,
bucketTs: Date.now(),
lastPresenceTs: 0,
})
await this.scheduleAlarm()
try {
socket.send(JSON.stringify({ kind: 'hello', server: 'hermes.playground.cf-worker.v0', ts: Date.now() }))
// bootstrap snapshot
socket.send(JSON.stringify({ kind: 'hello', server: 'hermes.playground.cf-worker.v1', ts: Date.now() }))
// Send current count baseline immediately for HUD.
socket.send(this.countMessage())
// bootstrap presence snapshot
for (const p of this.presence.values()) {
try { socket.send(JSON.stringify(p)) } catch {}
}
@@ -177,23 +253,45 @@ export class PlaygroundHub {
}
} catch {}
socket.addEventListener('message', (evt) => {
socket.addEventListener('message', async (evt) => {
const meta = this.socketMeta.get(socket)
if (!meta) return
if (!this.spend(meta)) return // dropped (rate limited)
let msg: any
try { msg = JSON.parse(typeof evt.data === 'string' ? evt.data : new TextDecoder().decode(evt.data as ArrayBuffer)) } catch { return }
if (!msg || typeof msg.kind !== 'string') return
if (msg.kind === 'presence' && msg.id) {
const m = { ...msg, ts: Date.now() } as PresenceMsg & { ts: number }
this.presence.set(msg.id, m)
const meta = this.socketMeta.get(socket)
if (meta) meta.playerId = msg.id
this.broadcast(socket, msg)
} else if (msg.kind === 'chat' && msg.id) {
if (msg.kind === 'presence' && typeof msg.id === 'string') {
const now = Date.now()
if (now - meta.lastPresenceTs < PRESENCE_DEDUPE_MS) return
meta.lastPresenceTs = now
const world = (msg.world || msg.worldId) as string | undefined
meta.playerId = msg.id
meta.world = world
const wire: PresenceMsg & { ts: number } = { ...msg, ts: now }
const wasNew = !this.presence.has(msg.id)
this.presence.set(msg.id, wire)
if (wasNew) {
await this.bumpPeak()
this.maybeBroadcastCount()
}
// World-scoped fan-out
this.broadcast(socket, wire, { world })
} else if (msg.kind === 'chat' && typeof msg.id === 'string') {
// Truncate text defensively
if (typeof msg.text === 'string' && msg.text.length > 240) {
msg.text = msg.text.slice(0, 240)
}
this.chatRing.push(msg)
if (this.chatRing.length > CHAT_RING_MAX) this.chatRing.shift()
this.broadcast(socket, msg)
} else if (msg.kind === 'leave' && msg.id) {
const world = (msg.world || msg.worldId) as string | undefined
this.broadcast(socket, msg, { world })
} else if (msg.kind === 'leave' && typeof msg.id === 'string') {
const prior = this.presence.get(msg.id)
const world = (prior?.world || prior?.worldId) as string | undefined
this.presence.delete(msg.id)
this.broadcast(socket, msg)
this.broadcast(socket, msg, { world })
this.maybeBroadcastCount()
}
})
@@ -201,8 +299,11 @@ export class PlaygroundHub {
this.sockets.delete(socket)
const meta = this.socketMeta.get(socket)
if (meta?.playerId && this.presence.has(meta.playerId)) {
const prior = this.presence.get(meta.playerId)
const world = (prior?.world || prior?.worldId) as string | undefined
this.presence.delete(meta.playerId)
this.broadcast(null, { kind: 'leave', id: meta.playerId })
this.broadcast(null, { kind: 'leave', id: meta.playerId }, { world })
this.maybeBroadcastCount()
}
}
socket.addEventListener('close', cleanup)

View File

@@ -9,4 +9,4 @@ class_name = "PlaygroundHub"
[[migrations]]
tag = "v1"
new_classes = ["PlaygroundHub"]
new_sqlite_classes = ["PlaygroundHub"]

View File

@@ -1,11 +1,21 @@
/**
* Live "agents online now" chip.
* Live "agents online now" chip with connection-state indicator.
*
* Polls VITE_PLAYGROUND_STATS_URL (e.g. CF Worker /stats endpoint) every 5s.
* Falls back gracefully if no stats URL configured or endpoint unreachable.
* Strategy:
* 1. Prefer server-pushed count via `hermes-playground-count` CustomEvent
* (emitted by the multiplayer hook on every server `count` message).
* Zero polling, real-time.
* 2. Fall back to one /stats fetch on mount if no WS push has arrived in
* ~3s (so the chip works on the title screen before a player has
* connected to /playground).
* 3. Hide the chip if no VITE_PLAYGROUND_STATS_URL is configured AND
* no live event ever arrives.
*
* Use as a floating HUD element to show real-time multiplayer presence
* count for the demo / hackathon judges / pitch deck.
* Connection states (driven by the hook's `transport`):
* - both: green dot, "live"
* - ws: green dot, "live"
* - broadcast: yellow dot, "local-only"
* - offline: red dot, "offline"
*/
import { useEffect, useState } from 'react'
@@ -16,22 +26,45 @@ type Stats = {
ts?: number
}
type Transport = 'offline' | 'broadcast' | 'ws' | 'both'
const STATS_URL =
(typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_PLAYGROUND_STATS_URL) || ''
const POLL_MS = 5000
export function PlaygroundOnlineChip({ accent = '#34d399' }: { accent?: string }) {
const [stats, setStats] = useState<Stats | null>(null)
const [transport, setTransport] = useState<Transport>('offline')
const [reachable, setReachable] = useState<boolean | null>(null)
useEffect(() => {
if (!STATS_URL) {
setReachable(false)
return
}
let cancelled = false
const tick = async () => {
const onCount = (ev: Event) => {
const detail = (ev as CustomEvent).detail as Stats
if (!detail) return
setStats(detail)
setReachable(true)
}
const onTransport = (ev: Event) => {
const detail = (ev as CustomEvent).detail as Transport
if (detail) setTransport(detail)
}
window.addEventListener('hermes-playground-count', onCount)
window.addEventListener('hermes-playground-transport', onTransport)
// Pre-populate from window globals if hook fired before mount.
const cur = (window as any).__hermesPlaygroundLiveCount as Stats | undefined
if (cur) setStats(cur)
const curT = (window as any).__hermesPlaygroundLiveTransport as Transport | undefined
if (curT) setTransport(curT)
// Fallback: one-shot /stats fetch if no push arrives in 3s.
const fallbackId = window.setTimeout(async () => {
if (cancelled || stats) return
if (!STATS_URL) {
setReachable(false)
return
}
try {
const r = await fetch(STATS_URL, { cache: 'no-store' })
if (!r.ok) throw new Error(String(r.status))
@@ -43,22 +76,47 @@ export function PlaygroundOnlineChip({ accent = '#34d399' }: { accent?: string }
if (cancelled) return
setReachable(false)
}
}
}, 3000)
void tick()
const id = window.setInterval(tick, POLL_MS)
return () => {
cancelled = true
window.clearInterval(id)
window.clearTimeout(fallbackId)
window.removeEventListener('hermes-playground-count', onCount)
window.removeEventListener('hermes-playground-transport', onTransport)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Hide the chip entirely when no stats endpoint is configured. We don't
// want a stale "0 online" sitting on the HUD during local dev.
if (!STATS_URL || reachable === false) return null
// Hide entirely when no stats URL configured AND no WS event ever arrived.
if (!STATS_URL && !stats) return null
if (!stats && reachable === false) return null
const n = stats?.online ?? 0
const dotColor = n > 0 ? accent : '#94a3b8'
const status: { color: string; label: string } = (() => {
switch (transport) {
case 'both':
case 'ws':
return { color: '#34d399', label: 'live' }
case 'broadcast':
return { color: '#facc15', label: 'local-only' }
case 'offline':
default:
return { color: '#94a3b8', label: 'offline' }
}
})()
const byWorldEntries = stats?.byWorld
? Object.entries(stats.byWorld).filter(([, v]) => v > 0)
: []
const tooltip = [
stats?.peakToday ? `Peak today: ${stats.peakToday}` : null,
byWorldEntries.length
? byWorldEntries.map(([w, v]) => `${w}: ${v}`).join(' · ')
: null,
`Status: ${status.label}`,
]
.filter(Boolean)
.join(' \u2022 ')
return (
<div
@@ -67,19 +125,16 @@ export function PlaygroundOnlineChip({ accent = '#34d399' }: { accent?: string }
right: 16,
boxShadow: `0 0 12px ${accent}33, 0 8px 24px rgba(0,0,0,.5)`,
}}
title={
stats?.peakToday
? `Peak today: ${stats.peakToday}`
: 'Live multiplayer count'
}
title={tooltip}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: dotColor,
boxShadow: `0 0 8px ${dotColor}`,
background: status.color,
boxShadow: `0 0 8px ${status.color}`,
animation: status.color === '#34d399' ? 'pulse-online 2s ease-in-out infinite' : undefined,
}}
/>
<span>
@@ -88,6 +143,12 @@ export function PlaygroundOnlineChip({ accent = '#34d399' }: { accent?: string }
{stats?.peakToday && stats.peakToday > 0 && (
<span className="text-white/45">· peak {stats.peakToday}</span>
)}
<style>{`
@keyframes pulse-online {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.65; transform: scale(0.85); }
}
`}</style>
</div>
)
}

View File

@@ -1738,12 +1738,14 @@ export function PlaygroundWorld3D({
const positionForMp = useRef<{ x: number; y: number; z: number } | null>({ x: 0, y: 0, z: 6 })
// Sync simple position object for multiplayer hook (it doesn't use THREE)
useEffect(() => {
// Sample player position at presence cadence (~5Hz). The hook
// skip-sends when delta < epsilon, so this is cheap.
const id = window.setInterval(() => {
positionForMp.current = { x: playerPos.current.x, y: playerPos.current.y, z: playerPos.current.z }
}, 80)
}, 200)
return () => window.clearInterval(id)
}, [])
const { remotePlayers, online, sendChat, myName, myColor, selfId } = usePlaygroundMultiplayer({
const { remotePlayers, online, transport, serverCount, sendChat, myName, myColor, selfId } = usePlaygroundMultiplayer({
world: worldId,
interior: null,
positionRef: positionForMp,
@@ -1751,15 +1753,30 @@ export function PlaygroundWorld3D({
name: multiplayerName,
onChat: onIncomingChat,
})
// Expose sendChat globally so the screen-level chat panel can broadcast.
// Expose sendChat + multiplayer info globally so HUD/chat panel can read it.
useEffect(() => {
;(window as any).__hermesPlaygroundSendChat = (text: string) => sendChat(text)
;(window as any).__hermesPlaygroundMpInfo = () => ({ online, myName, myColor, selfId, remoteCount: Object.keys(remotePlayers).length })
;(window as any).__hermesPlaygroundMpInfo = () => ({
online,
transport,
myName,
myColor,
selfId,
remoteCount: Object.keys(remotePlayers).length,
serverCount,
})
// Push live count for the HUD chip without polling /stats.
if (serverCount) {
;(window as any).__hermesPlaygroundLiveCount = serverCount
window.dispatchEvent(new CustomEvent('hermes-playground-count', { detail: serverCount }))
}
;(window as any).__hermesPlaygroundLiveTransport = transport
window.dispatchEvent(new CustomEvent('hermes-playground-transport', { detail: transport }))
return () => {
try { delete (window as any).__hermesPlaygroundSendChat } catch {}
try { delete (window as any).__hermesPlaygroundMpInfo } catch {}
}
}, [sendChat, online, myName, myColor, selfId, remotePlayers])
}, [sendChat, online, transport, myName, myColor, selfId, remotePlayers, serverCount])
return (
<div

View File

@@ -1,11 +1,18 @@
/**
* Playground multiplayer hook.
* Playground multiplayer hook (optimized).
*
* v0 transport: BroadcastChannel for same-origin tabs (zero-server demo).
* v1 transport: swap to WebSocket once server is deployed; same shape.
* Transports (lazy/parallel):
* - BroadcastChannel for same-origin tabs (zero-server).
* - WebSocket for cross-machine (when VITE_PLAYGROUND_WS_URL set).
*
* Each client publishes presence: id, name, color, world, position, yaw,
* lastChat, ts. Snapshots are merged into a remotePlayers map.
* Optimizations vs v0:
* - 5 Hz presence (was 10 Hz). Halves bandwidth, looks identical with lerp.
* - Skip-send when player hasn't moved/turned within an epsilon.
* - Avatar config sent only on change (signature compare).
* - World-scoped local rendering: hide remotes from other worlds.
* - Position-delta gate before re-render: <0.04u changes are dropped.
* - Server-pushed online count via `count` events (zero polling).
* - Connection state: 'offline' | 'broadcast' | 'ws' | 'both' for HUD.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { PlaygroundWorldId } from '../lib/playground-rpg'
@@ -31,11 +38,16 @@ export type RemotePlayer = {
type PresenceWire = RemotePlayer & { kind: 'presence' }
type ChatWire = { kind: 'chat'; id: string; name: string; color: string; world: PlaygroundWorldId; text: string; ts: number }
type LeaveWire = { kind: 'leave'; id: string }
type Wire = PresenceWire | ChatWire | LeaveWire
type CountWire = { kind: 'count'; online: number; byWorld?: Record<string, number>; peakToday?: number; ts: number }
type Wire = PresenceWire | ChatWire | LeaveWire | CountWire
const CHANNEL_NAME = 'hermes.playground.v0'
const PRESENCE_INTERVAL_MS = 100
const STALE_AFTER_MS = 4000
const PRESENCE_INTERVAL_MS = 200 // 5 Hz, was 100
const KEEPALIVE_MS = 1500 // force a packet at least this often even if static
const STALE_AFTER_MS = 5000 // matches server prune
const POS_EPSILON = 0.04 // skip-send if both deltas under this
const YAW_EPSILON = 0.025 // ~1.4°
const RENDER_POS_EPSILON = 0.03 // suppress re-render for ultra-small jitters
let _selfId: string | null = null
function getSelfId() {
@@ -60,8 +72,15 @@ function pickColor(id: string) {
return COLORS[Math.abs(h) % COLORS.length]
}
function avatarSig(a: AvatarConfig | null | undefined): string {
if (!a) return ''
return [a.skin, a.hair, a.hairStyle, a.eyes, a.outfit, a.outfitAccent, a.cape, a.helmet, a.weapon, a.portrait].join('|')
}
export type IncomingChat = { id: string; name: string; color: string; world: PlaygroundWorldId; text: string; ts: number }
export type ConnectionState = 'offline' | 'broadcast' | 'ws' | 'both'
export function usePlaygroundMultiplayer({
world,
interior,
@@ -85,8 +104,16 @@ export function usePlaygroundMultiplayer({
const wsRef = useRef<WebSocket | null>(null)
const wsOpenRef = useRef(false)
const avatarRef = useRef<AvatarConfig | null>(loadAvatarConfig())
const lastAvatarSigRef = useRef<string>(avatarSig(avatarRef.current))
const lastSentRef = useRef<{ x: number; y: number; z: number; yaw: number; ts: number; world: PlaygroundWorldId | null }>({
x: NaN, y: NaN, z: NaN, yaw: NaN, ts: 0, world: null,
})
useEffect(() => {
const update = () => { avatarRef.current = loadAvatarConfig() }
const update = () => {
const next = loadAvatarConfig()
avatarRef.current = next
lastAvatarSigRef.current = '' // force resend on next tick
}
if (typeof window !== 'undefined') {
window.addEventListener('hermes-playground-avatar-changed', update)
window.addEventListener('storage', update)
@@ -98,12 +125,33 @@ export function usePlaygroundMultiplayer({
}, [])
const [remotePlayers, setRemotePlayers] = useState<Record<string, RemotePlayer>>({})
const [online, setOnline] = useState(false)
const [transport, setTransport] = useState<'broadcast' | 'ws' | 'both'>('broadcast')
const [transport, setTransport] = useState<ConnectionState>('offline')
const [serverCount, setServerCount] = useState<{ online: number; byWorld?: Record<string, number>; peakToday?: number } | null>(null)
// Stable refs to avoid re-subscribing
const onChatRef = useRef(onChat)
useEffect(() => { onChatRef.current = onChat }, [onChat])
// Merge a presence into remotePlayers, skipping if delta is tiny.
const mergePresence = useCallback((msg: RemotePlayer) => {
setRemotePlayers((prev) => {
const cur = prev[msg.id]
if (cur) {
const dx = Math.abs(cur.x - msg.x)
const dz = Math.abs(cur.z - msg.z)
const dyaw = Math.abs(cur.yaw - msg.yaw)
const sameWorld = cur.world === msg.world
const sameAvatar = avatarSig(cur.avatar) === avatarSig(msg.avatar)
const noChat = (cur.lastChatAt || 0) === (msg.lastChatAt || 0)
if (sameWorld && sameAvatar && noChat && dx < RENDER_POS_EPSILON && dz < RENDER_POS_EPSILON && dyaw < YAW_EPSILON) {
// tiny delta — keep ts fresh but skip render
return { ...prev, [msg.id]: { ...cur, ts: msg.ts } }
}
}
return { ...prev, [msg.id]: msg }
})
}, [])
// Open WebSocket transport (optional, controlled by VITE_PLAYGROUND_WS_URL)
useEffect(() => {
if (typeof window === 'undefined') return
@@ -123,6 +171,9 @@ export function usePlaygroundMultiplayer({
ws.addEventListener('open', () => {
wsOpenRef.current = true
retry = 0
// Force avatar resend on reconnect
lastAvatarSigRef.current = ''
lastSentRef.current = { x: NaN, y: NaN, z: NaN, yaw: NaN, ts: 0, world: null }
setTransport((t) => (t === 'broadcast' ? 'both' : 'ws'))
})
ws.addEventListener('message', (ev) => {
@@ -130,8 +181,10 @@ export function usePlaygroundMultiplayer({
try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '') } catch { return }
if (!msg || !('kind' in msg)) return
if (msg.kind === 'hello') return
if (msg.kind === 'presence' && msg.id !== selfId) {
setRemotePlayers((prev) => ({ ...prev, [msg.id]: msg as RemotePlayer }))
if (msg.kind === 'count') {
setServerCount({ online: msg.online, byWorld: msg.byWorld, peakToday: msg.peakToday })
} else if (msg.kind === 'presence' && msg.id !== selfId) {
mergePresence(msg as RemotePlayer)
} else if (msg.kind === 'leave' && msg.id !== selfId) {
setRemotePlayers((prev) => { const { [msg.id]: _, ...rest } = prev; return rest })
} else if (msg.kind === 'chat' && msg.id !== selfId) {
@@ -141,7 +194,7 @@ export function usePlaygroundMultiplayer({
ws.addEventListener('close', () => {
wsOpenRef.current = false
wsRef.current = null
setTransport((t) => (t === 'both' ? 'broadcast' : t === 'ws' ? 'broadcast' : t))
setTransport((t) => (t === 'both' ? 'broadcast' : t === 'ws' ? 'offline' : t))
if (!stop) {
retry = Math.min(8, retry + 1)
window.setTimeout(open, retry * 500)
@@ -155,20 +208,21 @@ export function usePlaygroundMultiplayer({
try { ws?.close() } catch {}
wsRef.current = null
}
}, [selfId])
}, [selfId, mergePresence])
// Open channel
// Open BroadcastChannel
useEffect(() => {
if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return
const ch = new BroadcastChannel(CHANNEL_NAME)
channelRef.current = ch
setOnline(true)
setTransport((t) => (t === 'offline' ? 'broadcast' : t))
const onMessage = (ev: MessageEvent) => {
const msg = ev.data as Wire
if (!msg || !msg.kind) return
if (msg.kind === 'presence') {
if (msg.id === selfId) return
setRemotePlayers((prev) => ({ ...prev, [msg.id]: msg as RemotePlayer }))
mergePresence(msg as RemotePlayer)
} else if (msg.kind === 'leave') {
if (msg.id === selfId) return
setRemotePlayers((prev) => {
@@ -183,6 +237,7 @@ export function usePlaygroundMultiplayer({
ch.addEventListener('message', onMessage)
const onUnload = () => {
try { ch.postMessage({ kind: 'leave', id: selfId } satisfies LeaveWire) } catch {}
try { wsRef.current?.send(JSON.stringify({ kind: 'leave', id: selfId })) } catch {}
}
window.addEventListener('beforeunload', onUnload)
return () => {
@@ -193,35 +248,53 @@ export function usePlaygroundMultiplayer({
channelRef.current = null
setOnline(false)
}
}, [selfId])
}, [selfId, mergePresence])
// Tick: broadcast presence and prune stale remotes
// Tick: broadcast presence (skip-when-still) and prune stale remotes.
useEffect(() => {
if (typeof window === 'undefined') return
const tick = window.setInterval(() => {
const ch = channelRef.current
if (!ch) return
const pos = positionRef.current
if (!pos) return
const wire: PresenceWire = {
kind: 'presence',
id: selfId,
name: myName,
color: myColor,
world,
interior,
x: pos.x,
y: pos.y,
z: pos.z,
yaw: yawRef.current,
ts: Date.now(),
avatar: avatarRef.current || undefined,
const yaw = yawRef.current
const last = lastSentRef.current
const now = Date.now()
const moved = Math.abs(pos.x - last.x) >= POS_EPSILON
|| Math.abs(pos.z - last.z) >= POS_EPSILON
|| Math.abs(yaw - last.yaw) >= YAW_EPSILON
|| world !== last.world
const stale = now - last.ts >= KEEPALIVE_MS
const avatarNow = avatarRef.current
const sigNow = avatarSig(avatarNow)
const avatarChanged = sigNow !== lastAvatarSigRef.current
if (!moved && !stale && !avatarChanged) {
// Even when not sending, prune local stale remotes
} else {
const wire: PresenceWire = {
kind: 'presence',
id: selfId,
name: myName,
color: myColor,
world,
interior,
x: pos.x,
y: pos.y,
z: pos.z,
yaw,
ts: now,
// Only attach avatar config when it changed (or on keepalive every Nth)
avatar: avatarChanged || stale ? (avatarNow || undefined) : undefined,
}
try { ch?.postMessage(wire) } catch {}
if (wsOpenRef.current && wsRef.current) {
try { wsRef.current.send(JSON.stringify(wire)) } catch {}
}
lastSentRef.current = { x: pos.x, y: pos.y, z: pos.z, yaw, ts: now, world }
if (avatarChanged) lastAvatarSigRef.current = sigNow
}
try { ch.postMessage(wire) } catch {}
if (wsOpenRef.current && wsRef.current) {
try { wsRef.current.send(JSON.stringify(wire)) } catch {}
}
const cutoff = Date.now() - STALE_AFTER_MS
// Stale prune
const cutoff = now - STALE_AFTER_MS
setRemotePlayers((prev) => {
let dirty = false
const next: Record<string, RemotePlayer> = {}
@@ -236,30 +309,41 @@ export function usePlaygroundMultiplayer({
}, [selfId, myName, myColor, world, interior, positionRef, yawRef])
const sendChat = useCallback((text: string) => {
const ch = channelRef.current
if (!ch || !text.trim()) return
const trimmed = text.trim()
if (!trimmed) return
const wire: ChatWire = {
kind: 'chat',
id: selfId,
name: myName,
color: myColor,
world,
text: text.trim().slice(0, 240),
text: trimmed.slice(0, 240),
ts: Date.now(),
}
try { ch.postMessage(wire) } catch {}
try { channelRef.current?.postMessage(wire) } catch {}
if (wsOpenRef.current && wsRef.current) {
try { wsRef.current.send(JSON.stringify(wire)) } catch {}
}
}, [selfId, myName, myColor, world])
// World-scoped remote players: never render people from other worlds.
const visibleRemotes = useMemo(() => {
const out: Record<string, RemotePlayer> = {}
for (const [id, p] of Object.entries(remotePlayers)) {
if (p.world === world) out[id] = p
}
return out
}, [remotePlayers, world])
return {
selfId,
myName,
myColor,
online,
transport,
remotePlayers,
remotePlayers: visibleRemotes,
allRemotes: remotePlayers,
serverCount,
sendChat,
}
}