diff --git a/documentation/docs/journey/multiplayer-p2p.md b/documentation/docs/journey/multiplayer-p2p.md new file mode 100644 index 00000000..252c8101 --- /dev/null +++ b/documentation/docs/journey/multiplayer-p2p.md @@ -0,0 +1,200 @@ +--- +sidebar_position: 12 +--- + +# Multiplayer P2P + +Adding real-time P2P multiplayer using WebRTC via [Trystero](https://github.com/dmotz/trystero) (NOSTR signaling — no dedicated server required). + +## Package split: client, server, P2P + +The initial `@webgamekit/multiplayer` was a Socket.IO client only. It was replaced by three focused packages: + +| Package | Role | +| -------------------------------- | -------------------------------------------------------- | +| `@webgamekit/multiplayer-client` | Socket.IO client — connects to a dedicated server | +| `@webgamekit/multiplayer-server` | Socket.IO server — Node.js only, manages player registry | +| `@webgamekit/multiplayer-p2p` | WebRTC via Trystero — fully serverless | + +## Why Trystero over PeerJS + +PeerJS requires a signaling server you own and is no longer actively maintained. Trystero uses public NOSTR relays as a signaling layer — no account, no server, no configuration. The tradeoff is that NOSTR relay availability is out of your control, but multiple redundant relays are used by default. + +## Bug: DataPayload type constraint + +**Symptom:** TypeScript error `Type 'PlayerPosition' does not satisfy constraint 'DataPayload'`. + +**Cause:** Trystero's `makeAction` requires `T extends DataPayload`. `DataPayload` includes `JsonValue`, which requires `{ [key: string]: JsonValue }` index signatures on all objects. A plain `interface { x: number; y: number; z: number }` doesn't satisfy this. + +**Fix:** Use intersection types to add the index signature without changing the named fields: + +```ts +// Does not satisfy DataPayload — missing index signature +interface PlayerPosition { + x: number + y: number + z: number +} + +// Satisfies DataPayload +type PlayerPosition = { x: number; y: number; z: number } & Record +``` + +For generic data channels constrain the type parameter: + +```ts +export const p2pSendData = ( + session: P2PSession, + channel: string, + payload: T +): void => { ... } +``` + +## Bug: TypeScript can't resolve Trystero types + +**Symptom:** `Cannot find module '@trystero-p2p/core' or its corresponding type declarations`. + +**Cause:** Two sub-issues: + +1. `@trystero-p2p/core` is a transitive dependency — not directly importable. +2. Trystero ships `.d.mts` declaration files alongside `.mjs` entry points. TypeScript's `Node` `moduleResolution` cannot resolve `.d.mts` files. + +**Fix:** Import types from `trystero/nostr` (which re-exports everything via `export *`), and set `"moduleResolution": "Bundler"` in the package `tsconfig.json`: + +```ts +import type { Room, DataPayload } from 'trystero/nostr' +``` + +```json +{ "compilerOptions": { "moduleResolution": "Bundler" } } +``` + +## Bug: crypto.subtle crash in Vite pre-bundling + +**Symptom:** `TypeError: Cannot read properties of undefined (reading 'digest')` thrown at startup. + +**Cause:** Vite's dependency pre-optimizer runs in Node.js before the browser loads. Trystero calls `crypto.subtle.digest` at module initialization (to hash room IDs). Node.js doesn't expose `crypto.subtle` globally during Vite's pre-bundling phase. + +**Fix:** Exclude Trystero from Vite's optimizer so it is served as native ESM directly to the browser: + +```ts +// vite.config.ts +optimizeDeps: { + exclude: ['@dimforge/rapier3d-compat', 'trystero', 'trystero/nostr'] +} +``` + +## Bug: crypto.subtle unavailable on plain HTTP origins + +**Symptom:** `TypeError: Cannot read properties of undefined (reading 'importKey')` at runtime when accessing the app over HTTP on a custom hostname. + +**Cause:** `crypto.subtle` (the Web Crypto API) is only available in secure contexts — HTTPS or `localhost`. Browsers deliberately block it on plain HTTP to prevent key extraction. All Trystero strategies use `crypto.subtle` for room ID hashing, so none will work on plain HTTP. + +**Fix:** Add a pre-flight check before calling `joinRoom`, and surface a clear error in the UI: + +```ts +export const p2pIsSupported = (): boolean => { + return typeof window !== 'undefined' && typeof window.crypto?.subtle !== 'undefined' +} +``` + +The view calls this on mount and shows a banner if the app isn't on HTTPS/localhost instead of silently failing. + +## Bug: torrent WebSocket trackers fail to connect + +**Symptom:** Switched to `@trystero-p2p/torrent` to avoid `crypto.subtle`, but `WebSocket connection to 'wss://tracker.webtorrent.dev/' failed`. + +**Cause:** The public BitTorrent WebSocket trackers used by the torrent strategy (`tracker.webtorrent.dev`, `tracker.btorrent.xyz`) are unreliable. Peers can never discover each other when trackers are down. + +**Fix:** Switch back to `trystero/nostr`. The torrent strategy was only chosen to avoid `crypto.subtle`, but that issue is better solved with `p2pIsSupported()` + HTTPS. NOSTR relays are more reliably available. + +## Bug: peers only appear after broadcasting a position + +**Symptom:** After joining a room, the peer list shows 0 peers even though another tab is connected. + +**Cause:** The peer list was populated only via `p2pOnPlayers` (position data). There was no join/leave tracking — a peer only appeared once they sent their first position. + +**Fix:** Use `room.onPeerJoin` and `room.onPeerLeave` to track peer presence independently of position data: + +```ts +export const p2pOnPeerJoin = (session: P2PSession, callback: (peerId: string) => void): void => { + session.room.onPeerJoin(callback) +} +``` + +Peers now appear immediately on join with "waiting for position…" until their first broadcast arrives. + +## Bug: first tab doesn't see second tab until a third tab joins + +**Symptom:** Open tab A (already in room), open tab B — B doesn't appear in A's peer list. Open tab C, and suddenly all three tabs see each other. + +**Cause:** `onPeerJoin` only fires for peers who join _after_ you register the callback. If tab B was already in the room when tab A joined, A's `onPeerJoin` never fired for B. Tab C joining triggered a new round of peer handshakes, which is what made B visible to A. + +**Fix:** Call `room.getPeers()` immediately after joining to get the list of already-connected peers: + +```ts +export const p2pGetPeerIds = (session: P2PSession): string[] => Object.keys(session.room.getPeers()) +``` + +Call this right after registering `onPeerJoin` and add any returned IDs to the peer list: + +```ts +p2pOnPeerJoin(newSession, (peerId) => { + /* add to list */ +}) + +// Catch peers already in the room +p2pGetPeerIds(newSession).forEach((peerId) => { + /* add to list */ +}) +``` + +## Testing strategy + +Unit tests mock `trystero/nostr` but only verify individual functions in isolation. They don't catch cross-peer bugs — a mock that registers a callback without ever invoking it will pass even if the whole peer communication chain is broken. + +The integration test uses a room-aware mock that routes sends between sessions and fires `onPeerJoin`/`onPeerLeave` across all sessions, matching real Trystero behavior. It also implements `getPeers()` returning the actual connected peer map to test the late-joiner fix: + +```ts +getPeers: () => + Object.fromEntries([...room.entries()].filter(([id]) => id !== peerId).map(([id]) => [id, {}])) +``` + +Key scenarios tested: + +- Peer A sees Peer B join via `onPeerJoin` +- Peer A receives Peer B's position broadcast +- Peer A is notified when Peer B leaves +- Peer A sees Peer B via `p2pGetPeerIds` when A joins after B (late-joiner fix) +- Sender does not receive their own broadcast +- Three-peer scenario: both B and C appear in A's list + +## Bug: player movement fires once per second + +**Symptom:** The local player barely moves when pressing WASD, crawling at ~0.5 units/sec with visible stutter. + +**Cause:** Both `local-player` and `remote-players` timeline actions used `frequency: 60`. The timeline engine skips action execution when `frame % frequency !== 0`, so at 60 FPS the movement callback ran only on frames 0, 60, 120 — once per second. The `MOVEMENT_SPEED = 0.5` constant was applied once per second instead of 30 times per second. + +**Fix:** Changed `frequency: 60` to `frequency: 2` on both actions, matching the convention used by MixamoPlayground and MazeGame. At `frequency: 2` the action fires every other frame (30x/sec at 60 FPS), giving an effective movement speed of ~15 units/sec — matching MixamoPlayground's `distance: 0.5` at `frequency: 2`. + +The value 60 likely came from confusing "60 FPS target" with the frequency parameter's meaning. `frequency` is a frame-skip divisor, not a rate — `frequency: N` means "run every Nth frame." + +## Design note: direct position update vs controllerForward + +MixamoPlayground uses `controllerForward()` which handles obstacle checks, ground checks, and calls `moveCharacter()` internally. MultiplayerP2P uses direct `position.x += direction.x * speed` instead. This was deliberate: + +1. The P2P arena has no obstacles or complex terrain — just a flat ground plane +2. `controllerForward` requires `obstacles` and `groundBodies` arrays, adding unnecessary complexity +3. The position is authoritative on each client — simpler math is easier to reason about for netcode + +The tradeoff is no built-in obstacle collision. If the arena gains obstacles later, switching to `controllerForward` would be appropriate. + +## Design note: no delta-time scaling on movement + +Movement uses fixed `MOVEMENT_SPEED` without multiplying by `getDelta()`. At a fixed `frequency: 2`, the action fires at a consistent rate regardless of actual frame time. This matches MixamoPlayground which also uses fixed `distance` without delta scaling. + +Delta scaling would be needed if `frequency` were set to 1 (every frame) and frame rate varied. At `frequency: 2`, the movement rate is effectively decoupled from frame-rate variance — if a frame takes longer, the frequency skip still ensures roughly consistent execution counts per second. + +## Known limitation: remote player position snapping + +Remote player positions are set directly via `model.position.set()` without interpolation. With the 30ms send throttle in `p2pSendPosition`, remote players visually "snap" between positions. Linear interpolation (lerping toward the last received position each frame) would smooth this, but adds complexity and latency. Acceptable for the current prototype scope. diff --git a/eslint.config.js b/eslint.config.js index a8448f33..0fa31064 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -478,7 +478,10 @@ export default [ 'packages/controls/**/*.ts', 'packages/audio/**/*.ts', 'packages/game/**/*.ts', - 'packages/recording/**/*.ts' + 'packages/recording/**/*.ts', + 'packages/multiplayer-client/**/*.ts', + 'packages/multiplayer-server/**/*.ts', + 'packages/multiplayer-p2p/**/*.ts' ], rules: { 'functional/immutable-data': 'warn', diff --git a/package.json b/package.json index 037b6c7d..0262a4a5 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,11 @@ "dependencies": { "@webgamekit/controls": "workspace:^", "@webgamekit/logic": "workspace:^", + "@webgamekit/multiplayer-client": "workspace:^", + "@webgamekit/multiplayer-p2p": "workspace:^", "@webgamekit/threejs": "workspace:^", + "trystero": "^0.23.0", + "socket.io-client": "^4.0.0", "dat.gui": "^0.7.9", "p5": "^1.9.0", "pinia": "^2.3.0", diff --git a/packages/multiplayer-p2p/package.json b/packages/multiplayer-p2p/package.json new file mode 100644 index 00000000..76537bef --- /dev/null +++ b/packages/multiplayer-p2p/package.json @@ -0,0 +1,35 @@ +{ + "name": "@webgamekit/multiplayer-p2p", + "version": "0.0.1", + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/index.umd.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.umd.cjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "vite build && tsc", + "prepare": "pnpm run build", + "prepublishOnly": "pnpm run build" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "trystero": "^0.23.0" + }, + "devDependencies": { + "trystero": "^0.23.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vitest": "^4.0.18" + } +} diff --git a/packages/multiplayer-p2p/src/data.ts b/packages/multiplayer-p2p/src/data.ts new file mode 100644 index 00000000..84d18809 --- /dev/null +++ b/packages/multiplayer-p2p/src/data.ts @@ -0,0 +1,34 @@ +import type { DataPayload } from 'trystero/nostr' +import type { P2PSession } from './types' + +/** + * Send typed data to all peers on a named channel. + * @param session - The active P2P session + * @param channel - Channel name (must match receiver's channel name) + * @param payload - Serializable data to broadcast + */ +export const p2pSendData = ( + session: P2PSession, + channel: string, + payload: T +): void => { + const [send] = session.room.makeAction(channel) + send(payload) +} + +/** + * Subscribe to typed data arriving on a named channel from any peer. + * @param session - The active P2P session + * @param channel - Channel name to listen on + * @param callback - Called with the payload and the sender's peer ID + * @returns Unsubscribe function (stops the listener) + */ +export const p2pOnData = ( + session: P2PSession, + channel: string, + callback: (payload: T, peerId: string) => void +): (() => void) => { + const [, onData] = session.room.makeAction(channel) + onData((data: T, peerId: string) => callback(data, peerId)) + return () => {} +} diff --git a/packages/multiplayer-p2p/src/index.test.ts b/packages/multiplayer-p2p/src/index.test.ts new file mode 100644 index 00000000..67176115 --- /dev/null +++ b/packages/multiplayer-p2p/src/index.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { PlayerState } from './types' + +// ── Mock trystero/nostr ──────────────────────────────────────────────────── +type ActionCallback = (data: T, peerId: string) => void + +const mockSend = vi.fn() +const mockLeave = vi.fn() +const mockGetPeers = vi.fn(() => [] as string[]) + +const actionCallbacks = new Map>() +const peerJoinCallbacks: ((peerId: string) => void)[] = [] +const peerLeaveCallbacks: ((peerId: string) => void)[] = [] + +const simulatePeerJoin = (peerId: string) => + peerJoinCallbacks.forEach((callback) => callback(peerId)) +const simulatePeerLeave = (peerId: string) => + peerLeaveCallbacks.forEach((callback) => callback(peerId)) + +const mockRoom = { + makeAction: vi.fn((channel: string): [typeof mockSend, ActionCallback] => { + return [ + mockSend, + (callback: ActionCallback) => { + actionCallbacks.set(channel, callback as ActionCallback) + } + ] + }), + leave: mockLeave, + onPeerJoin: (callback: (peerId: string) => void) => peerJoinCallbacks.push(callback), + onPeerLeave: (callback: (peerId: string) => void) => peerLeaveCallbacks.push(callback), + getPeers: mockGetPeers +} + +vi.mock('trystero/nostr', () => ({ + selfId: 'local-peer', + joinRoom: vi.fn(() => mockRoom) +})) + +import { + p2pJoin, + p2pLeave, + p2pSendPosition, + p2pOnPlayers, + p2pSendData, + p2pOnData, + p2pOnPeerJoin, + p2pOnPeerLeave +} from './index' + +describe('p2pJoin', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 + }) + + it('returns a session with room and destroy function', async () => { + const session = p2pJoin('room-1') + expect(session).toHaveProperty('room') + expect(session).toHaveProperty('destroy') + expect(typeof session.destroy).toBe('function') + }) + + it('calls joinRoom with the provided roomId and default appId', async () => { + const { joinRoom } = await import('trystero/nostr') + p2pJoin('level-abc') + expect(joinRoom).toHaveBeenCalledWith({ appId: 'webgamekit' }, 'level-abc') + }) + + it('uses custom appId from config', async () => { + const { joinRoom } = await import('trystero/nostr') + p2pJoin('room-2', { appId: 'my-game' }) + expect(joinRoom).toHaveBeenCalledWith({ appId: 'my-game' }, 'room-2') + }) +}) + +describe('p2pLeave', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 + }) + + it('calls room.leave()', () => { + const session = p2pJoin('room-1') + p2pLeave(session) + expect(mockLeave).toHaveBeenCalled() + }) + + it('session.destroy() also calls room.leave()', () => { + const session = p2pJoin('room-1') + session.destroy() + expect(mockLeave).toHaveBeenCalled() + }) +}) + +describe('p2pOnPeerJoin', () => { + beforeEach(() => { + vi.clearAllMocks() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 + }) + + it('calls callback with peer ID when a peer joins', () => { + const session = p2pJoin('room-1') + const callback = vi.fn() + p2pOnPeerJoin(session, callback) + simulatePeerJoin('peer-abc') + expect(callback).toHaveBeenCalledWith('peer-abc') + }) + + it('supports multiple join callbacks', () => { + const session = p2pJoin('room-1') + const callbackA = vi.fn() + const callbackB = vi.fn() + p2pOnPeerJoin(session, callbackA) + p2pOnPeerJoin(session, callbackB) + simulatePeerJoin('peer-xyz') + expect(callbackA).toHaveBeenCalledWith('peer-xyz') + expect(callbackB).toHaveBeenCalledWith('peer-xyz') + }) +}) + +describe('p2pOnPeerLeave', () => { + beforeEach(() => { + vi.clearAllMocks() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 + }) + + it('calls callback with peer ID when a peer leaves', () => { + const session = p2pJoin('room-1') + const callback = vi.fn() + p2pOnPeerLeave(session, callback) + simulatePeerLeave('peer-abc') + expect(callback).toHaveBeenCalledWith('peer-abc') + }) +}) + +describe('p2pSendPosition', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + actionCallbacks.clear() + peerJoinCallbacks.length = 0 + peerLeaveCallbacks.length = 0 + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('sends position and rotation via the pos action', () => { + const session = p2pJoin('room-1') + const position = { x: 1, y: 2, z: 3 } + const rotation = { x: 0, y: 0.5, z: 0 } + p2pSendPosition(session, position, rotation) + vi.advanceTimersByTime(30) + expect(mockSend).toHaveBeenCalledWith({ position, rotation }) + }) + + it('throttles rapid calls — only one send per interval', () => { + const session = p2pJoin('room-1') + const rotation = { x: 0, y: 0, z: 0 } + p2pSendPosition(session, { x: 1, y: 0, z: 0 }, rotation) + p2pSendPosition(session, { x: 2, y: 0, z: 0 }, rotation) + p2pSendPosition(session, { x: 3, y: 0, z: 0 }, rotation) + vi.advanceTimersByTime(30) + expect(mockSend).toHaveBeenCalledTimes(1) + }) + + it('respects custom throttleMs', () => { + const session = p2pJoin('room-1') + const position = { x: 5, y: 0, z: 0 } + p2pSendPosition(session, position, { x: 0, y: 0, z: 0 }, { throttleMs: 100 }) + vi.advanceTimersByTime(50) + expect(mockSend).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ position })) + }) +}) + +describe('p2pOnPlayers', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + }) + + it('calls callback when position data arrives from a peer', () => { + const session = p2pJoin('room-1') + const callback = vi.fn() + p2pOnPlayers(session, callback) + + const onPos = actionCallbacks.get('pos') as ActionCallback<{ + position: PlayerState['position'] + rotation: PlayerState['rotation'] + }> + onPos?.({ position: { x: 5, y: 0, z: 3 }, rotation: { x: 0, y: 1, z: 0 } }, 'peer-42') + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ id: 'peer-42', position: { x: 5, y: 0, z: 3 } }) + ) + }) + + it('returns an unsubscribe function', () => { + const session = p2pJoin('room-1') + const unsubscribe = p2pOnPlayers(session, vi.fn()) + expect(typeof unsubscribe).toBe('function') + unsubscribe() + }) +}) + +describe('p2pSendData', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + }) + + it('sends typed payload on the given channel', () => { + const session = p2pJoin('room-1') + p2pSendData(session, 'coin:collected', { coinId: 'c1' }) + expect(mockSend).toHaveBeenCalledWith({ coinId: 'c1' }) + }) + + it('supports any serializable payload', () => { + const session = p2pJoin('room-1') + p2pSendData(session, 'game:event', { type: 'levelUp', score: 100 }) + expect(mockSend).toHaveBeenCalledWith({ type: 'levelUp', score: 100 }) + }) +}) + +describe('p2pOnData', () => { + beforeEach(() => { + vi.clearAllMocks() + actionCallbacks.clear() + }) + + it('calls callback with typed payload and sender peerId', () => { + const session = p2pJoin('room-1') + const callback = vi.fn() + p2pOnData<{ coinId: string }>(session, 'coin:collected', callback) + + const onChannel = actionCallbacks.get('coin:collected') as ActionCallback<{ coinId: string }> + onChannel?.({ coinId: 'c1' }, 'peer-7') + + expect(callback).toHaveBeenCalledWith({ coinId: 'c1' }, 'peer-7') + }) + + it('returns an unsubscribe function', () => { + const session = p2pJoin('room-1') + const unsubscribe = p2pOnData(session, 'game:event', vi.fn()) + expect(typeof unsubscribe).toBe('function') + }) +}) diff --git a/packages/multiplayer-p2p/src/index.ts b/packages/multiplayer-p2p/src/index.ts new file mode 100644 index 00000000..a3ff1b17 --- /dev/null +++ b/packages/multiplayer-p2p/src/index.ts @@ -0,0 +1,18 @@ +export type { + PlayerPosition, + PlayerRotation, + PlayerState, + PlayerAction, + P2PConfig, + P2PSession +} from './types' +export { + p2pJoin, + p2pLeave, + p2pOnPeerJoin, + p2pOnPeerLeave, + p2pIsSupported, + p2pGetPeerIds +} from './room' +export { p2pSendPosition, p2pOnPlayers, p2pSendAction, p2pOnAction } from './players' +export { p2pSendData, p2pOnData } from './data' diff --git a/packages/multiplayer-p2p/src/p2p.integration.test.ts b/packages/multiplayer-p2p/src/p2p.integration.test.ts new file mode 100644 index 00000000..ebe1ba35 --- /dev/null +++ b/packages/multiplayer-p2p/src/p2p.integration.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// ── Room simulation ──────────────────────────────────────────────────────── +// Simulates two peers joining the same WebRTC room and routing data between +// them, mirroring the real Trystero behaviour: +// - onPeerJoin fires on existing sessions when a new peer joins +// - makeAction sends are delivered to all other sessions' receivers +// - onPeerLeave fires on remaining sessions when a peer leaves + +type Receiver = (data: unknown, peerId: string) => void + +type PeerState = { + peerJoinCallbacks: ((id: string) => void)[] + peerLeaveCallbacks: ((id: string) => void)[] + receivers: Map +} + +const rooms = new Map>() +let nextPeerId = 0 + +const resetRooms = () => { + rooms.clear() + nextPeerId = 0 +} + +vi.mock('trystero/nostr', () => { + return { + get selfId() { + return 'local-peer' + }, + joinRoom: vi.fn((_config: unknown, roomId: string) => { + const peerId = `peer-${nextPeerId++}` + + if (!rooms.has(roomId)) { + rooms.set(roomId, new Map()) + } + const room = rooms.get(roomId)! + + const state: PeerState = { + peerJoinCallbacks: [], + peerLeaveCallbacks: [], + receivers: new Map() + } + + // Notify all existing peers that this peer has joined + room.forEach((existingState) => { + existingState.peerJoinCallbacks.forEach((callback) => callback(peerId)) + }) + + room.set(peerId, state) + + return { + onPeerJoin: (callback: (id: string) => void) => state.peerJoinCallbacks.push(callback), + onPeerLeave: (callback: (id: string) => void) => state.peerLeaveCallbacks.push(callback), + makeAction: (channel: string) => { + const send = (data: unknown) => { + room.forEach((otherState, otherId) => { + if (otherId !== peerId) { + otherState.receivers.get(channel)?.(data, peerId) + } + }) + } + const onReceive = (callback: Receiver) => { + state.receivers.set(channel, callback) + } + return [send, onReceive, vi.fn()] + }, + leave: () => { + room.delete(peerId) + room.forEach((otherState) => { + otherState.peerLeaveCallbacks.forEach((callback) => callback(peerId)) + }) + }, + getPeers: () => + Object.fromEntries( + [...room.entries()].filter(([id]) => id !== peerId).map(([id]) => [id, {}]) + ) + } + }) + } +}) + +import { + p2pJoin, + p2pLeave, + p2pOnPeerJoin, + p2pOnPeerLeave, + p2pGetPeerIds, + p2pSendPosition, + p2pOnPlayers, + p2pSendAction, + p2pOnAction +} from './index' + +describe('P2P room integration — two peers', () => { + beforeEach(() => { + vi.useFakeTimers() + resetRooms() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('peer A sees peer B via p2pGetPeerIds when A joins after B', () => { + const sessionB = p2pJoin('room-1') // B joins first + const sessionA = p2pJoin('room-1') // A joins after — onPeerJoin already fired for B + + // A would have missed the onPeerJoin for B, so use p2pGetPeerIds to catch up + const existingIds = p2pGetPeerIds(sessionA) + expect(existingIds).toContain(sessionB.peerId === 'local-peer' ? 'peer-0' : sessionB.peerId) + expect(existingIds.length).toBe(1) + }) + + it('peer A sees peer B when B joins the same room', () => { + const sessionA = p2pJoin('room-1') + const onJoin = vi.fn() + p2pOnPeerJoin(sessionA, onJoin) + + p2pJoin('room-1') // peer B joins + + expect(onJoin).toHaveBeenCalledTimes(1) + expect(onJoin).toHaveBeenCalledWith(expect.stringContaining('peer-')) + }) + + it('peer A receives the position broadcast by peer B', () => { + const sessionA = p2pJoin('room-1') + const onPlayers = vi.fn() + p2pOnPlayers(sessionA, onPlayers) + + const sessionB = p2pJoin('room-1') + const position = { x: 3, y: 0, z: 5 } + const rotation = { x: 0, y: 1, z: 0 } + p2pSendPosition(sessionB, position, rotation) + vi.advanceTimersByTime(30) + + expect(onPlayers).toHaveBeenCalledWith(expect.objectContaining({ position, rotation })) + }) + + it('peer A is notified when peer B leaves', () => { + const sessionA = p2pJoin('room-1') + const onLeave = vi.fn() + p2pOnPeerLeave(sessionA, onLeave) + + const sessionB = p2pJoin('room-1') + p2pLeave(sessionB) + + expect(onLeave).toHaveBeenCalledTimes(1) + }) + + it('position from peer B includes the correct sender ID', () => { + const sessionA = p2pJoin('room-1') + const onPlayers = vi.fn() + p2pOnPlayers(sessionA, onPlayers) + + const sessionB = p2pJoin('room-1') + p2pSendPosition(sessionB, { x: 1, y: 0, z: 2 }, { x: 0, y: 0, z: 0 }) + vi.advanceTimersByTime(30) + + const [{ id }] = onPlayers.mock.calls[0] as [{ id: string }] + expect(id).toMatch(/^peer-/) + }) + + it('peer A does not receive its own position broadcast', () => { + const sessionA = p2pJoin('room-1') + const onPlayers = vi.fn() + p2pOnPlayers(sessionA, onPlayers) + + p2pSendPosition(sessionA, { x: 1, y: 0, z: 2 }, { x: 0, y: 0, z: 0 }) + vi.advanceTimersByTime(30) + + expect(onPlayers).not.toHaveBeenCalled() + }) + + it("three peers: B and C both appear in A's player list", () => { + const sessionA = p2pJoin('room-1') + const onPlayers = vi.fn() + p2pOnPlayers(sessionA, onPlayers) + + const sessionB = p2pJoin('room-1') + const sessionC = p2pJoin('room-1') + + p2pSendPosition(sessionB, { x: 1, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }) + p2pSendPosition(sessionC, { x: 2, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }) + vi.advanceTimersByTime(30) + + expect(onPlayers).toHaveBeenCalledTimes(2) + const receivedPositions = onPlayers.mock.calls.map( + ([p]: [{ position: { x: number } }]) => p.position.x + ) + expect(receivedPositions).toContain(1) + expect(receivedPositions).toContain(2) + }) + + it('peer A receives animation action broadcast by peer B', () => { + const sessionA = p2pJoin('room-1') + const onAction = vi.fn() + p2pOnAction(sessionA, onAction) + + const sessionB = p2pJoin('room-1') + p2pSendAction(sessionB, 'wave') + + expect(onAction).toHaveBeenCalledTimes(1) + const [peerId, action] = onAction.mock.calls[0] as [string, { name: string }] + expect(peerId).toMatch(/^peer-/) + expect(action.name).toBe('wave') + }) + + it('peer A does not receive its own action broadcast', () => { + const sessionA = p2pJoin('room-1') + const onAction = vi.fn() + p2pOnAction(sessionA, onAction) + + p2pSendAction(sessionA, 'attack') + + expect(onAction).not.toHaveBeenCalled() + }) +}) diff --git a/packages/multiplayer-p2p/src/players.ts b/packages/multiplayer-p2p/src/players.ts new file mode 100644 index 00000000..45f6ef82 --- /dev/null +++ b/packages/multiplayer-p2p/src/players.ts @@ -0,0 +1,135 @@ +import type { + PlayerPosition, + PlayerRotation, + PlayerState, + PlayerAction, + P2PConfig, + P2PSession +} from './types' + +type PositionRecord = Record> + +const defaultThrottleMs = 30 + +const pendingTimers = new WeakMap>() +const lastSentAt = new WeakMap() + +type SendFunction = (data: unknown) => void +type ReceiveFunction = (callback: (data: unknown, peerId: string) => void) => void + +const sendCache = new WeakMap>() +const recvRegistered = new WeakMap>() + +const getSend = (session: P2PSession, channel: string): SendFunction => { + if (!sendCache.has(session)) sendCache.set(session, new Map()) + const cache = sendCache.get(session)! + if (!cache.has(channel)) { + const [send] = session.room.makeAction(channel) + cache.set(channel, send as SendFunction) + } + return cache.get(channel)! +} + +const getRecv = (session: P2PSession, channel: string): ReceiveFunction | null => { + if (!recvRegistered.has(session)) recvRegistered.set(session, new Set()) + const registered = recvRegistered.get(session)! + if (registered.has(channel)) return null + registered.add(channel) + const [, onReceive] = session.room.makeAction(channel) + return onReceive as ReceiveFunction +} + +/** + * Broadcast the local player's position and rotation to all peers, throttled. + * @param session - The active P2P session + * @param position - Current player position {x, y, z} + * @param rotation - Current player rotation {x, y, z} + * @param config - Optional config carrying throttleMs + */ +export const p2pSendPosition = ( + session: P2PSession, + position: PlayerPosition, + rotation: PlayerRotation, + config?: P2PConfig +): void => { + const throttleMs = config?.throttleMs ?? defaultThrottleMs + const sendPos = getSend(session, 'pos') + const now = performance.now() + const last = lastSentAt.get(session) ?? 0 + + const doSend = (): void => { + lastSentAt.set(session, performance.now()) + sendPos({ position, rotation }) + } + + if (now - last >= throttleMs) { + const existing = pendingTimers.get(session) + if (existing !== undefined) clearTimeout(existing) + doSend() + } else { + const existing = pendingTimers.get(session) + if (existing !== undefined) clearTimeout(existing) + pendingTimers.set( + session, + setTimeout( + () => { + pendingTimers.delete(session) + doSend() + }, + throttleMs - (now - last) + ) + ) + } +} + +/** + * Subscribe to position updates from other peers. + * @param session - The active P2P session + * @param callback - Called with the sender's PlayerState on each update + * @returns Unsubscribe function + */ +export const p2pOnPlayers = ( + session: P2PSession, + callback: (player: PlayerState) => void +): (() => void) => { + const onPos = getRecv(session, 'pos') + if (onPos) { + onPos((data: unknown, peerId: string) => { + const record = data as PositionRecord + const pos = record['position'] as PlayerPosition + const rot = record['rotation'] as PlayerRotation + callback({ id: peerId, position: pos, rotation: rot }) + }) + } + + return () => {} +} + +/** + * Broadcast a named animation action to all peers. + * @param session - The active P2P session + * @param actionName - Animation clip name to play (e.g. "wave", "attack") + */ +export const p2pSendAction = (session: P2PSession, actionName: string): void => { + const sendAction = getSend(session, 'action') + sendAction({ name: actionName }) +} + +/** + * Subscribe to animation action broadcasts from other peers. + * @param session - The active P2P session + * @param callback - Called with the sender's peer ID and action name + * @returns Unsubscribe function + */ +export const p2pOnAction = ( + session: P2PSession, + callback: (peerId: string, action: PlayerAction) => void +): (() => void) => { + const onAction = getRecv(session, 'action') + if (onAction) { + onAction((data: unknown, peerId: string) => { + callback(peerId, data as PlayerAction) + }) + } + return () => {} +} diff --git a/packages/multiplayer-p2p/src/room.ts b/packages/multiplayer-p2p/src/room.ts new file mode 100644 index 00000000..e15e166a --- /dev/null +++ b/packages/multiplayer-p2p/src/room.ts @@ -0,0 +1,90 @@ +import { joinRoom, selfId } from 'trystero/nostr' +import type { P2PConfig, P2PSession } from './types' + +/** + * Check whether the browser exposes crypto.subtle (requires HTTPS or localhost). + * @returns true if the environment supports P2P + */ +export const p2pIsSupported = (): boolean => { + const supported = typeof window !== 'undefined' && typeof window.crypto?.subtle !== 'undefined' + if (!supported) { + console.warn( + '[p2p] crypto.subtle is not available. ' + + 'P2P requires a secure context (HTTPS or localhost). ' + + `Current origin: ${typeof window !== 'undefined' ? window.location.origin : 'unknown'}` + ) + } + return supported +} + +/** + * Join a P2P room using WebRTC via Trystero (torrent signaling — no server required). + * All peers in the same roomId can communicate directly. + * Requires a secure context (HTTPS or localhost) — throws if crypto.subtle is unavailable. + * @param roomId - Shared identifier for the game room (e.g. "level-1-abc123") + * @param config - Optional configuration overrides + * @returns A P2PSession with the room handle and destroy function + */ +export const p2pJoin = (roomId: string, config?: P2PConfig): P2PSession => { + if (!p2pIsSupported()) { + throw new Error( + `P2P is not supported on this origin (${window.location.origin}). ` + + 'Serve the app over HTTPS or use localhost.' + ) + } + + const appId = config?.appId ?? 'webgamekit' + const room = joinRoom({ appId }, roomId) + + const destroy = () => { + room.leave() + } + + return { + get peerId() { + return selfId + }, + room, + destroy + } +} + +/** + * Return the IDs of all peers currently connected in the room. + * Use this immediately after joining to catch peers that joined before you. + * @param session - The active P2P session + * @returns Array of peer IDs already in the room + */ +export const p2pGetPeerIds = (session: P2PSession): string[] => { + return Object.keys(session.room.getPeers()) +} + +/** + * Leave a P2P room and clean up all connections. + * @param session - The session returned by p2pJoin + */ +export const p2pLeave = (session: P2PSession): void => { + session.destroy() +} + +/** + * Subscribe to a callback fired when a remote peer joins the room. + * @param session - The active P2P session + * @param callback - Called with the peer's ID when they join + */ +export const p2pOnPeerJoin = (session: P2PSession, callback: (peerId: string) => void): void => { + session.room.onPeerJoin((peerId) => { + callback(peerId) + }) +} + +/** + * Subscribe to a callback fired when a remote peer leaves the room. + * @param session - The active P2P session + * @param callback - Called with the peer's ID when they leave + */ +export const p2pOnPeerLeave = (session: P2PSession, callback: (peerId: string) => void): void => { + session.room.onPeerLeave((peerId) => { + callback(peerId) + }) +} diff --git a/packages/multiplayer-p2p/src/types.ts b/packages/multiplayer-p2p/src/types.ts new file mode 100644 index 00000000..74247041 --- /dev/null +++ b/packages/multiplayer-p2p/src/types.ts @@ -0,0 +1,34 @@ +import type { Room, DataPayload } from 'trystero/nostr' + +export type { DataPayload } + +export interface P2PConfig { + /** App identifier — must be unique to your game (default: "webgamekit") */ + appId?: string + /** Minimum ms between position broadcasts (default: 30) */ + throttleMs?: number +} + +export interface P2PSession { + /** Local peer ID assigned by Trystero */ + peerId: string + /** The underlying Trystero Room */ + room: Room + /** Leave the room and clean up */ + destroy: () => void +} + +/** Serializable position — compatible with Trystero DataPayload */ +export type PlayerPosition = { x: number; y: number; z: number } & Record + +/** Serializable rotation — compatible with Trystero DataPayload */ +export type PlayerRotation = { x: number; y: number; z: number } & Record + +export interface PlayerState { + id: string + position: PlayerPosition + rotation: PlayerRotation +} + +/** Serializable animation action — compatible with Trystero DataPayload */ +export type PlayerAction = { name: string } & Record diff --git a/packages/multiplayer-p2p/tsconfig.json b/packages/multiplayer-p2p/tsconfig.json new file mode 100644 index 00000000..db2a21b8 --- /dev/null +++ b/packages/multiplayer-p2p/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Bundler", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/packages/multiplayer-p2p/vite.config.ts b/packages/multiplayer-p2p/vite.config.ts new file mode 100644 index 00000000..1299b7bd --- /dev/null +++ b/packages/multiplayer-p2p/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import { resolve } from 'path' + +export default defineConfig({ + build: { + lib: { + entry: resolve(import.meta.dirname, 'src/index.ts'), + name: 'WebGameToolkitMultiplayerP2P', + fileName: 'index' + }, + rollupOptions: { + external: ['trystero/nostr'], + output: { globals: { 'trystero/nostr': 'trysteroNostr' } } + } + } +}) diff --git a/packages/threejs/src/getters.ts b/packages/threejs/src/getters.ts index d5be243f..ed7e0736 100644 --- a/packages/threejs/src/getters.ts +++ b/packages/threejs/src/getters.ts @@ -76,7 +76,8 @@ export const getGround = ( ) => { const defaultProps = { color } const groundSizes: CoordinateTuple = Array.isArray(size) ? size : [size, 0.01, size] - position[1] = position[1] - (groundSizes[1] || 0.01) / 2 + const groundCenterY = position[1] - (groundSizes[1] || 0.01) / 2 + const groundPosition: CoordinateTuple = [position[0], groundCenterY, position[2]] const geometry = new THREE.BoxGeometry(...groundSizes) const material = new THREE.MeshStandardMaterial({ ...defaultProps, @@ -86,13 +87,13 @@ export const getGround = ( const mesh = new THREE.Mesh(geometry, material) mesh.name = 'ground' mesh.receiveShadow = true - mesh.position.set(...position) + mesh.position.set(...groundPosition) mesh.userData.physics = { mass: 0 } scene.add(mesh) const { rigidBody, collider } = getPhysic(world, { - position: mesh.position.toArray(), + position: groundPosition, size: groundSizes, boundary: 0.5 }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04a96f85..5c853eb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@webgamekit/logic': specifier: workspace:^ version: link:packages/logic + '@webgamekit/multiplayer-client': + specifier: workspace:^ + version: link:packages/multiplayer-client + '@webgamekit/multiplayer-p2p': + specifier: workspace:^ + version: link:packages/multiplayer-p2p '@webgamekit/threejs': specifier: workspace:^ version: link:packages/threejs @@ -26,12 +32,18 @@ importers: pinia: specifier: ^2.3.0 version: 2.3.1(typescript@5.4.5)(vue@3.5.25(typescript@5.4.5)) + socket.io-client: + specifier: ^4.0.0 + version: 4.8.3 stats.js: specifier: ^0.17.0 version: 0.17.0 three: specifier: ^0.178.0 version: 0.178.0 + trystero: + specifier: ^0.23.0 + version: 0.23.0 vite-plugin-wasm: specifier: ^3.3.0 version: 3.5.0(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) @@ -203,10 +215,10 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) packages/audio: devDependencies: @@ -215,7 +227,7 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) packages/controls: devDependencies: @@ -224,7 +236,7 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) packages/game: devDependencies: @@ -233,7 +245,7 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) packages/logic: devDependencies: @@ -242,10 +254,58 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + + packages/multiplayer-client: + devDependencies: + socket.io-client: + specifier: ^4.8.1 + version: 4.8.3 + typescript: + specifier: ^5.0.0 + version: 5.4.5 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + + packages/multiplayer-p2p: + devDependencies: + trystero: + specifier: ^0.23.0 + version: 0.23.0 + typescript: + specifier: ^5.0.0 + version: 5.4.5 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + + packages/multiplayer-server: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + socket.io: + specifier: ^4.8.0 + version: 4.8.3 + typescript: + specifier: ^5.0.0 + version: 5.4.5 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) packages/recording: devDependencies: @@ -254,7 +314,7 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) packages/threejs: dependencies: @@ -276,10 +336,10 @@ importers: version: 5.4.5 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + version: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + version: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) packages: @@ -896,6 +956,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/secp256k1@3.0.0': + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1172,6 +1235,9 @@ packages: resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} engines: {node: '>=18'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1186,6 +1252,12 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 + '@trystero-p2p/core@0.23.0': + resolution: {integrity: sha512-ozhtgxKDZH11Gdef0wH8xivwAE/L0/lDFvEcNFWPJWnHZlxWPPyfeonwE287ssGevQNi10vnj6x2ZcOi0n1bQQ==} + + '@trystero-p2p/nostr@0.23.0': + resolution: {integrity: sha512-KSqUR2c1KVfv4zeErcntuegtyKzFTzNNiitIKGD0LiKA/4H3CeTF81ROk2h+X/PNvP4mv7Gp5eVxFYwfMu4Nrg==} + '@tsconfig/node20@20.1.8': resolution: {integrity: sha512-Em+IdPfByIzWRRpqWL4Z7ArLHZGxmc36BxE3jCz9nBFSm+5aLaPMZyjwu4yetvyKXeogWcxik4L1jB5JTWfw7A==} @@ -1195,6 +1267,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/dat.gui@0.7.13': resolution: {integrity: sha512-LmG4Pr0mbsB/1WVttf8xaHx45BvSBDGY5D9+0/9AZCbI4vvOguwBHZdKyJMxt5Yst8+icRSHKmr7ClV2u5ZtDA==} @@ -1216,6 +1291,9 @@ packages: '@types/node@20.19.25': resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/p5@1.7.7': resolution: {integrity: sha512-WFuP7jqc5CkkMtCK/NphgvMnJz1Qi9CMuK7t6xLu/tuXkRdGQA4q4AD0dUYcChC0Oibe8PE8gbKSFPNF0BqVNw==} @@ -1240,6 +1318,9 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.54.0': resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1442,6 +1523,10 @@ packages: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1515,6 +1600,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -1628,12 +1717,20 @@ packages: config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + copy-anything@2.0.6: resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} core-js-compat@3.48.0: resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1718,6 +1815,17 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + engine.io-client@6.6.4: + resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.6: + resolution: {integrity: sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==} + engines: {node: '>=10.2.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2441,6 +2549,10 @@ packages: engines: {node: '>= 4.4.x'} hasBin: true + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -2467,6 +2579,10 @@ packages: nwsapi@2.2.22: resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} @@ -2789,6 +2905,21 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2920,6 +3051,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trystero@0.23.0: + resolution: {integrity: sha512-HsM0FT0w41XW9bhXmYAfxr7PT+F6KwMzOxfJK2MbfipnMk/XrAIsV8lMTgU8NwWQqwCiS/b0b1qDsOf6IGxTmg==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -2985,6 +3119,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-plugin-wasm@3.5.0: resolution: {integrity: sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==} peerDependencies: @@ -3220,6 +3358,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -3663,6 +3805,8 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 optional: true + '@noble/secp256k1@3.0.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3851,6 +3995,8 @@ snapshots: '@sindresorhus/base62@1.0.0': {} + '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.18': @@ -3864,6 +4010,13 @@ snapshots: '@tanstack/virtual-core': 3.13.18 vue: 3.5.25(typescript@5.4.5) + '@trystero-p2p/core@0.23.0': {} + + '@trystero-p2p/nostr@0.23.0': + dependencies: + '@noble/secp256k1': 3.0.0 + '@trystero-p2p/core': 0.23.0 + '@tsconfig/node20@20.1.8': {} '@tweenjs/tween.js@23.1.3': {} @@ -3873,6 +4026,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.19.17 + '@types/dat.gui@0.7.13': {} '@types/deep-eql@4.0.2': {} @@ -3897,6 +4054,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + '@types/p5@1.7.7': {} '@types/postcss-modules-local-by-default@4.0.2': @@ -3925,6 +4086,10 @@ snapshots: '@types/webxr@0.5.24': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.17 + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.4.5))(eslint@9.39.2(jiti@2.6.1))(typescript@5.4.5)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4034,6 +4199,20 @@ snapshots: - utf-8-validate - vite + '@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18)': + dependencies: + '@vitest/browser': 4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) + playwright: 1.58.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.0.18(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18)': dependencies: '@vitest/mocker': 4.0.18(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) @@ -4051,6 +4230,24 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18)': + dependencies: + '@vitest/mocker': 4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)) + '@vitest/utils': 4.0.18 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -4068,6 +4265,15 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + '@vitest/mocker@4.0.18(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) + optional: true + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@20.19.25)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 @@ -4076,6 +4282,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.25)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -4102,7 +4316,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + vitest: 4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) '@vitest/utils@4.0.18': dependencies: @@ -4247,6 +4461,11 @@ snapshots: abbrev@2.0.0: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4307,6 +4526,8 @@ snapshots: balanced-match@1.0.2: {} + base64id@2.0.0: {} + baseline-browser-mapping@2.9.19: {} boolbase@1.0.0: {} @@ -4408,6 +4629,8 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 + cookie@0.7.2: {} + copy-anything@2.0.6: dependencies: is-what: 3.14.1 @@ -4416,6 +4639,11 @@ snapshots: dependencies: browserslist: 4.28.1 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4481,6 +4709,37 @@ snapshots: emoji-regex@9.2.2: {} + engine.io-client@6.6.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.6: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.19.17 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + entities@4.5.0: {} entities@6.0.1: {} @@ -5260,6 +5519,8 @@ snapshots: sax: 1.4.4 optional: true + negotiator@0.6.3: {} + node-addon-api@7.1.1: optional: true @@ -5288,6 +5549,8 @@ snapshots: nwsapi@2.2.22: {} + object-assign@4.1.1: {} + object-deep-merge@2.0.0: {} obug@2.1.1: {} @@ -5619,6 +5882,47 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.4 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.6 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5751,6 +6055,10 @@ snapshots: dependencies: punycode: 2.3.1 + trystero@0.23.0: + dependencies: + '@trystero-p2p/nostr': 0.23.0 + ts-api-utils@2.4.0(typescript@5.4.5): dependencies: typescript: 5.4.5 @@ -5832,6 +6140,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + vite-plugin-wasm@3.5.0(vite@5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)): dependencies: vite: 5.4.21(@types/node@20.19.25)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1) @@ -5850,6 +6160,20 @@ snapshots: stylus: 0.62.0 terser: 5.46.1 + vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.3 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + less: 4.5.1 + lightningcss: 1.30.2 + sass: 1.97.3 + stylus: 0.62.0 + terser: 5.46.1 + vite@7.3.1(@types/node@20.19.25)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -5869,6 +6193,25 @@ snapshots: terser: 5.46.1 yaml: 2.8.2 + vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + jiti: 2.6.1 + less: 4.5.1 + lightningcss: 1.30.2 + sass: 1.97.3 + stylus: 0.62.0 + terser: 5.46.1 + yaml: 2.8.2 + vitest@4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 @@ -5909,6 +6252,46 @@ snapshots: - tsx - yaml + vitest@4.0.18(@types/node@22.19.17)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@24.1.3)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.17)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.0)(vite@5.4.21(@types/node@22.19.17)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.1))(vitest@4.0.18) + '@vitest/ui': 4.0.18(vitest@4.0.18) + jsdom: 24.1.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vscode-uri@3.1.0: {} vue-component-type-helpers@2.2.12: {} @@ -6024,6 +6407,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + yaml@1.10.2: {} yaml@2.8.2: {} diff --git a/public/office_elevator_door.glb b/public/office_elevator_door.glb index f7c154ca..d6ab62ec 100644 Binary files a/public/office_elevator_door.glb and b/public/office_elevator_door.glb differ diff --git a/src/components/ControlsLogger.vue b/src/components/ControlsLogger.vue index f181b436..ff42abc1 100644 --- a/src/components/ControlsLogger.vue +++ b/src/components/ControlsLogger.vue @@ -1,13 +1,25 @@ @@ -17,14 +29,40 @@ defineProps<{ display: flex; flex-direction: column; align-self: center; - font-size: 24px; - line-height: 1.2em; - margin: 1rem; + font-size: var(--font-size-md); + line-height: 1.4; + margin: var(--spacing-4); + gap: var(--spacing-1); +} + +.controls-logger__header { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.controls-logger__toggle { + display: none; + background: none; + border: none; + font-size: var(--font-size-md); + cursor: pointer; + color: inherit; + padding: 0 var(--spacing-1); + line-height: 1; } @media (max-width: 600px) { - .controls-logger { - line-height: 0.2em; + .controls-logger__toggle { + display: inline; + } + + .controls-logger__body { + display: none; + } + + .controls-logger__body--expanded { + display: block; } } diff --git a/src/components/panels/PanelContainer.vue b/src/components/panels/PanelContainer.vue index a797cd4a..2b4af5b9 100644 --- a/src/components/panels/PanelContainer.vue +++ b/src/components/panels/PanelContainer.vue @@ -81,6 +81,7 @@ defineProps<{ background-color: var(--color-background); padding: var(--spacing-2); overflow-x: hidden; + max-height: 100%; display: flex; flex-direction: column; diff --git a/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue new file mode 100644 index 00000000..89773928 --- /dev/null +++ b/src/views/Experiments/MultiplayerP2P/MultiplayerP2P.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/vite.config.ts b/vite.config.ts index 1a776af9..6e1d48ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,17 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import wasm from 'vite-plugin-wasm' -const packages = ['animation', 'threejs', 'audio', 'game', 'controls', 'recording', 'logic'] +const packages = [ + 'animation', + 'threejs', + 'audio', + 'game', + 'controls', + 'recording', + 'logic', + 'multiplayer-client', + 'multiplayer-p2p' +] const packageAliases = Object.fromEntries( packages.map((package_) => [ `@webgamekit/${package_}`, @@ -31,7 +41,7 @@ export default defineConfig({ } }, optimizeDeps: { - exclude: ['@dimforge/rapier3d-compat'] + exclude: ['@dimforge/rapier3d-compat', 'trystero', 'trystero/nostr'] }, server: { allowedHosts: ['cnotv.xyz', 'test.cnotv.xyz', 'game.cnotv.xyz']