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:
975
playground-ws-worker/pnpm-lock.yaml
generated
Normal file
975
playground-ws-worker/pnpm-lock.yaml
generated
Normal 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: {}
|
||||
@@ -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)
|
||||
|
||||
@@ -9,4 +9,4 @@ class_name = "PlaygroundHub"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["PlaygroundHub"]
|
||||
new_sqlite_classes = ["PlaygroundHub"]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user