From 39137cfea5b62e1fcf1dc450435acb41f118121c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 02:22:43 +0000 Subject: [PATCH] feat: 8-player multiplayer support with command relay - Update multiplayerStore.ts with multi-peer infrastructure: - Add peerChannels Map for tracking multiple connections - Add remotePeerIds array for all connected peers - Implement addPeer/removePeer for managing connections - Add broadcastMessage() for sending to all peers - Add sendToPeer() for targeted messages - Host automatically relays commands to other guests - Update Game.ts lockstep barrier for N players: - getExpectedPlayerIds() now returns all peer IDs - Supports 2-8 players in lockstep synchronization - Update useMultiplayer.ts for multi-peer sync: - Sync ALL guest data channels using addPeer() - Guest mode also uses addPeer for consistency - Delete dead code (~1400 lines): - src/hooks/useP2P.ts (superseded by useMultiplayer.ts) - src/engine/network/p2p/ConnectionCode.ts (not used) - src/rendering/GroundDetail.ts (CrystalField never imported) - tests/engine/network/connectionCode.test.ts https://claude.ai/code/session_01V7DKiAVN3pWV2NxZ7iYsjb --- src/engine/core/Game.ts | 22 +- src/engine/network/p2p/ConnectionCode.ts | 455 ------------------- src/engine/network/p2p/index.ts | 19 +- src/hooks/useMultiplayer.ts | 44 +- src/hooks/useP2P.ts | 465 -------------------- src/rendering/GroundDetail.ts | 312 ------------- src/store/multiplayerStore.ts | 315 ++++++++++++- tests/engine/network/connectionCode.test.ts | 61 --- 8 files changed, 355 insertions(+), 1338 deletions(-) delete mode 100644 src/engine/network/p2p/ConnectionCode.ts delete mode 100644 src/hooks/useP2P.ts delete mode 100644 src/rendering/GroundDetail.ts delete mode 100644 tests/engine/network/connectionCode.test.ts diff --git a/src/engine/core/Game.ts b/src/engine/core/Game.ts index 57784775..1c6f1b93 100644 --- a/src/engine/core/Game.ts +++ b/src/engine/core/Game.ts @@ -23,6 +23,7 @@ import { useMultiplayerStore, getAdaptiveCommandDelay, getLatencyStats, + getAllPeerIds, } from '@/store/multiplayerStore'; // Multiplayer message types @@ -470,14 +471,27 @@ export class Game extends GameCore { /** * Get the set of player IDs that should send commands for lockstep. - * In 2-player multiplayer, this is local player + remote peer. + * Supports up to 8 players: local player + all remote peers. */ private getExpectedPlayerIds(): string[] { const players: string[] = [this.config.playerId]; - const remotePeerId = useMultiplayerStore.getState().remotePeerId; - if (remotePeerId) { - players.push(remotePeerId); + + // Get all remote peer IDs (supports 2-8 players) + const remotePeerIds = getAllPeerIds(); + for (const peerId of remotePeerIds) { + if (!players.includes(peerId)) { + players.push(peerId); + } + } + + // Fallback for legacy single-peer mode + if (remotePeerIds.length === 0) { + const remotePeerId = useMultiplayerStore.getState().remotePeerId; + if (remotePeerId && !players.includes(remotePeerId)) { + players.push(remotePeerId); + } } + return players; } diff --git a/src/engine/network/p2p/ConnectionCode.ts b/src/engine/network/p2p/ConnectionCode.ts deleted file mode 100644 index c063719d..00000000 --- a/src/engine/network/p2p/ConnectionCode.ts +++ /dev/null @@ -1,455 +0,0 @@ -/** - * Connection Code System - * Encodes WebRTC SDP offers into human-shareable codes - * Format: VOID-XXXX-XXXX-XXXX-XXXX-XXXX - */ - -import pako from 'pako'; -import { debugNetworking } from '@/utils/debugLogger'; - -// Crockford's Base32 alphabet - exactly 32 chars, avoids confusing chars (no I/L/O/U) -// This is a standard encoding that's human-readable and case-insensitive -const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; -const CODE_PREFIX = 'VOID'; -const CODE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes - -// STUN servers for NAT traversal -const ICE_SERVERS: RTCIceServer[] = [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - { urls: 'stun:stun.cloudflare.com:3478' }, -]; - -/** - * Data encoded in a connection code - */ -export interface ConnectionCodeData { - v: 1; // Version - sdp: string; // SDP offer/answer - ice: string[]; // ICE candidates - ts: number; // Timestamp for expiry - type: 'offer' | 'answer'; // SDP type - mode?: '1v1' | '2v2'; // Game mode - map?: string; // Map ID -} - -export class ConnectionCodeError extends Error { - constructor(message: string) { - super(message); - this.name = 'ConnectionCodeError'; - } -} - -/** - * Gather ICE candidates with timeout - */ -async function gatherICECandidates( - pc: RTCPeerConnection, - timeout: number = 3000 -): Promise { - const candidates: RTCIceCandidate[] = []; - - return new Promise((resolve) => { - const timer = setTimeout(() => { - debugNetworking.log(`[ConnectionCode] ICE gathering timed out with ${candidates.length} candidates`); - resolve(candidates); - }, timeout); - - pc.onicecandidate = (event) => { - if (event.candidate) { - candidates.push(event.candidate); - } else { - // ICE gathering complete - clearTimeout(timer); - debugNetworking.log(`[ConnectionCode] ICE gathering complete: ${candidates.length} candidates`); - resolve(candidates); - } - }; - - pc.onicegatheringstatechange = () => { - if (pc.iceGatheringState === 'complete') { - clearTimeout(timer); - resolve(candidates); - } - }; - }); -} - -/** - * Encode bytes to our alphabet (base32-like) - */ -function encodeToAlphabet(bytes: Uint8Array): string { - let result = ''; - let bits = 0; - let value = 0; - - for (const byte of bytes) { - value = (value << 8) | byte; - bits += 8; - - while (bits >= 5) { - bits -= 5; - result += ALPHABET[(value >> bits) & 0x1f]; - } - } - - if (bits > 0) { - result += ALPHABET[(value << (5 - bits)) & 0x1f]; - } - - return result; -} - -/** - * Decode from our alphabet to bytes - */ -function decodeFromAlphabet(str: string): Uint8Array { - const bytes: number[] = []; - let bits = 0; - let value = 0; - - for (const char of str.toUpperCase()) { - const index = ALPHABET.indexOf(char); - if (index === -1) continue; - - value = (value << 5) | index; - bits += 5; - - while (bits >= 8) { - bits -= 8; - bytes.push((value >> bits) & 0xff); - } - } - - return new Uint8Array(bytes); -} - -/** - * Format encoded string with prefix and dashes - */ -function formatCode(encoded: string): string { - const chunks = encoded.match(/.{1,4}/g) || []; - return CODE_PREFIX + '-' + chunks.join('-'); -} - -/** - * Remove formatting from code - */ -function unformatCode(code: string): string { - return code.replace(new RegExp(`^${CODE_PREFIX}-?`, 'i'), '').replace(/-/g, ''); -} - -/** - * Create a new RTCPeerConnection with our ICE servers - */ -export function createPeerConnection(): RTCPeerConnection { - return new RTCPeerConnection({ iceServers: ICE_SERVERS }); -} - -/** - * Generate a connection code from a WebRTC offer - */ -export async function generateOfferCode( - pc: RTCPeerConnection, - options?: { mode?: '1v1' | '2v2'; map?: string } -): Promise<{ code: string; pc: RTCPeerConnection }> { - // Create data channel (required to generate offer with media) - // CRITICAL: ordered:true ensures commands arrive in correct sequence for lockstep - const channel = pc.createDataChannel('game', { - ordered: true, - }); - - // Create offer - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - if (!offer.sdp) { - throw new ConnectionCodeError('Failed to create offer: no SDP'); - } - - // Gather ICE candidates - const iceCandidates = await gatherICECandidates(pc); - - // Filter out any undefined/null candidates - const validCandidates = iceCandidates - .map(c => c.candidate) - .filter((c): c is string => typeof c === 'string' && c.length > 0); - - // Sanitize SDP - remove any "undefined" strings that might have crept in - // This can happen with some WebRTC implementations - const sanitizedSdp = offer.sdp.replace(/undefined/gi, ''); - - // Also check if SDP looks valid - if (!sanitizedSdp.includes('v=0') || !sanitizedSdp.includes('m=')) { - debugNetworking.error('[ConnectionCode] Invalid SDP:', sanitizedSdp.slice(0, 200)); - throw new ConnectionCodeError('Failed to create offer: invalid SDP format'); - } - - // Build payload - only include defined values - const payload: ConnectionCodeData = { - v: 1, - sdp: sanitizedSdp, - ice: validCandidates, - ts: Date.now(), - type: 'offer', - }; - - // Only add mode/map if defined - if (options?.mode) payload.mode = options.mode; - if (options?.map) payload.map = options.map; - - // Compress with pako - const json = JSON.stringify(payload); - - // Debug: check for undefined in JSON - if (json.includes('undefined')) { - debugNetworking.error('[ConnectionCode] JSON contains undefined:', json.slice(0, 500)); - throw new ConnectionCodeError('Internal error: undefined value in connection data'); - } - - const compressed = pako.deflate(json, { level: 9 }); - - // Encode to alphabet (uppercase only) - const encoded = encodeToAlphabet(compressed); - - // Final safety check - Crockford's Base32 doesn't have 'I', 'L', 'O', 'U' - // If these appear, something went wrong - if (/[ILOU]/i.test(encoded)) { - debugNetworking.error('[ConnectionCode] Encoded contains invalid chars:', encoded.slice(0, 100)); - throw new ConnectionCodeError('Internal error: encoding produced invalid characters'); - } - - // Format with prefix and dashes - ensure uppercase - const code = formatCode(encoded).toUpperCase(); - - debugNetworking.log(`[ConnectionCode] Generated offer code: ${code.length} chars`); - - return { code, pc }; -} - -/** - * Parse a connection code and return the data - */ -export function parseConnectionCode(code: string): ConnectionCodeData { - try { - // Remove formatting - const cleaned = unformatCode(code); - - if (cleaned.length < 10) { - throw new ConnectionCodeError('Invalid connection code: too short'); - } - - // Decode from alphabet - const compressed = decodeFromAlphabet(cleaned); - - // Decompress - const json = pako.inflate(compressed, { to: 'string' }); - - // Parse JSON - const data = JSON.parse(json) as ConnectionCodeData; - - // Validate version - if (data.v !== 1) { - throw new ConnectionCodeError(`Unsupported connection code version: ${data.v}`); - } - - // Check expiry - if (Date.now() - data.ts > CODE_EXPIRY_MS) { - throw new ConnectionCodeError('Connection code has expired. Please request a new one.'); - } - - return data; - } catch (error) { - if (error instanceof ConnectionCodeError) { - throw error; - } - if (error instanceof Error) { - throw new ConnectionCodeError(`Failed to parse connection code: ${error.message}`); - } - throw new ConnectionCodeError('Failed to parse connection code: Invalid format'); - } -} - -/** - * Generate an answer code in response to an offer code - */ -export async function generateAnswerCode( - offerCode: string -): Promise<{ code: string; pc: RTCPeerConnection }> { - const offerData = parseConnectionCode(offerCode); - - if (offerData.type !== 'offer') { - throw new ConnectionCodeError('Expected an offer code, got an answer code'); - } - - // Create peer connection - const pc = createPeerConnection(); - - // Set up data channel handler - pc.ondatachannel = (event) => { - debugNetworking.log('[ConnectionCode] Received data channel:', event.channel.label); - }; - - // Set remote description (the offer) - await pc.setRemoteDescription({ - type: 'offer', - sdp: offerData.sdp, - }); - - // Add ICE candidates from offer - for (const candidate of offerData.ice) { - try { - await pc.addIceCandidate({ candidate, sdpMid: '0', sdpMLineIndex: 0 }); - } catch (e) { - debugNetworking.warn('[ConnectionCode] Failed to add ICE candidate:', e); - } - } - - // Create answer - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - if (!answer.sdp) { - throw new ConnectionCodeError('Failed to create answer: no SDP'); - } - - // Sanitize SDP - const sanitizedSdp = answer.sdp.replace(/undefined/gi, ''); - - // Gather our ICE candidates - const iceCandidates = await gatherICECandidates(pc); - - // Filter out any undefined/null candidates - const validCandidates = iceCandidates - .map(c => c.candidate) - .filter((c): c is string => typeof c === 'string' && c.length > 0); - - // Build answer payload - only include defined values - const payload: ConnectionCodeData = { - v: 1, - sdp: sanitizedSdp, - ice: validCandidates, - ts: Date.now(), - type: 'answer', - }; - - // Only add mode/map if defined - if (offerData.mode) payload.mode = offerData.mode; - if (offerData.map) payload.map = offerData.map; - - // Compress and encode - const json = JSON.stringify(payload); - - // Debug: check for undefined in JSON - if (json.includes('undefined')) { - debugNetworking.error('[ConnectionCode] JSON contains undefined:', json.slice(0, 500)); - throw new ConnectionCodeError('Internal error: undefined value in connection data'); - } - - const compressed = pako.deflate(json, { level: 9 }); - const encoded = encodeToAlphabet(compressed); - - // Safety check - Crockford's Base32 doesn't have 'I', 'L', 'O', 'U' - if (/[ILOU]/i.test(encoded)) { - debugNetworking.error('[ConnectionCode] Encoded contains invalid chars:', encoded.slice(0, 100)); - throw new ConnectionCodeError('Internal error: encoding produced invalid characters'); - } - - const code = formatCode(encoded).toUpperCase(); - - debugNetworking.log(`[ConnectionCode] Generated answer code: ${code.length} chars`); - - return { code, pc }; -} - -/** - * Complete connection with an answer code - */ -export async function completeConnection( - pc: RTCPeerConnection, - answerCode: string -): Promise { - const answerData = parseConnectionCode(answerCode); - - if (answerData.type !== 'answer') { - throw new ConnectionCodeError('Expected an answer code, got an offer code'); - } - - // Set remote description (the answer) - await pc.setRemoteDescription({ - type: 'answer', - sdp: answerData.sdp, - }); - - // Add ICE candidates from answer - for (const candidate of answerData.ice) { - try { - await pc.addIceCandidate({ candidate, sdpMid: '0', sdpMLineIndex: 0 }); - } catch (e) { - debugNetworking.warn('[ConnectionCode] Failed to add ICE candidate:', e); - } - } - - debugNetworking.log('[ConnectionCode] Connection completed'); -} - -/** - * Wait for peer connection to be fully connected - */ -export function waitForConnection( - pc: RTCPeerConnection, - timeout: number = 10000 -): Promise { - return new Promise((resolve, reject) => { - if (pc.connectionState === 'connected') { - resolve(); - return; - } - - const timer = setTimeout(() => { - reject(new ConnectionCodeError('Connection timed out')); - }, timeout); - - pc.onconnectionstatechange = () => { - if (pc.connectionState === 'connected') { - clearTimeout(timer); - resolve(); - } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { - clearTimeout(timer); - reject(new ConnectionCodeError(`Connection ${pc.connectionState}`)); - } - }; - }); -} - -/** - * Wait for a data channel to be received from the remote peer. - * - * Use this on the answerer side to wait for the offerer's data channel. - * The offerer should store their own channel reference from createDataChannel(). - * - * @param pc - The RTCPeerConnection to wait on - * @param timeout - Maximum time to wait in milliseconds (default: 10000) - * @returns Promise resolving to the received RTCDataChannel - */ -export function waitForDataChannel( - pc: RTCPeerConnection, - timeout: number = 10000 -): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new ConnectionCodeError('Timed out waiting for data channel')); - }, timeout); - - pc.ondatachannel = (event) => { - clearTimeout(timer); - const channel = event.channel; - if (channel.readyState === 'open') { - resolve(channel); - } else { - channel.onopen = () => resolve(channel); - channel.onerror = () => reject(new ConnectionCodeError('Data channel error')); - } - }; - }); -} diff --git a/src/engine/network/p2p/index.ts b/src/engine/network/p2p/index.ts index 4aff8f0a..8d2a2d12 100644 --- a/src/engine/network/p2p/index.ts +++ b/src/engine/network/p2p/index.ts @@ -3,20 +3,7 @@ * Serverless peer-to-peer networking for VOIDSTRIKE */ -// Connection Codes (Phase 1) -export { - generateOfferCode, - generateAnswerCode, - parseConnectionCode, - completeConnection, - createPeerConnection, - waitForConnection, - waitForDataChannel, - ConnectionCodeError, - type ConnectionCodeData, -} from './ConnectionCode'; - -// Nostr Relays +// Nostr Relays - used for lobby signaling export { getRelays, checkRelayHealth, @@ -24,7 +11,7 @@ export { NostrRelayError, } from './NostrRelays'; -// Nostr Matchmaking (Phase 3) +// Nostr Matchmaking (Phase 3) - for future "Find Match" feature export { NostrMatchmaking, NostrMatchmakingError, @@ -32,7 +19,7 @@ export { type ReceivedSignal, } from './NostrMatchmaking'; -// Peer Relay (Phase 4) +// Peer Relay (Phase 4) - for NAT fallback when direct connections fail export { PeerRelayNetwork, } from './PeerRelay'; diff --git a/src/hooks/useMultiplayer.ts b/src/hooks/useMultiplayer.ts index a823b99b..06cabde0 100644 --- a/src/hooks/useMultiplayer.ts +++ b/src/hooks/useMultiplayer.ts @@ -845,25 +845,45 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { }; }, [status, reconnect]); - // Sync data channel to multiplayerStore for game integration + // Sync data channel to multiplayerStore for game integration (guest mode) useEffect(() => { if (hostConnection && hostConnection.readyState === 'open') { + const store = useMultiplayerStore.getState(); + const hostPubkey = pubkeyRef.current ? `host-${pubkeyRef.current.slice(0, 8)}` : 'host'; + debugNetworking.log('[Lobby] Syncing host connection to multiplayerStore'); - useMultiplayerStore.getState().setDataChannel(hostConnection); - useMultiplayerStore.getState().setMultiplayer(true); - useMultiplayerStore.getState().setHost(false); + + // Use addPeer for consistency with multi-peer architecture + store.addPeer(hostPubkey, hostConnection); + store.setMultiplayer(true); + store.setHost(false); } }, [hostConnection]); - // Sync guest data channels to multiplayerStore (for host mode) + // Sync ALL guest data channels to multiplayerStore (for host mode - 8 player support) useEffect(() => { - const connectedGuest = guests.find(g => g.dataChannel?.readyState === 'open'); - if (connectedGuest?.dataChannel) { - debugNetworking.log('[Lobby] Syncing guest connection to multiplayerStore'); - useMultiplayerStore.getState().setDataChannel(connectedGuest.dataChannel); - useMultiplayerStore.getState().setMultiplayer(true); - useMultiplayerStore.getState().setHost(true); - useMultiplayerStore.getState().setRemotePeerId(connectedGuest.pubkey); + const connectedGuests = guests.filter(g => g.dataChannel?.readyState === 'open'); + + if (connectedGuests.length > 0) { + const store = useMultiplayerStore.getState(); + + // Enable multiplayer mode + store.setMultiplayer(true); + store.setHost(true); + + // Add all connected guests to the peer channels + for (const guest of connectedGuests) { + if (guest.dataChannel) { + // Check if this peer is already added + const existingChannel = store.getPeerChannel(guest.pubkey); + if (!existingChannel) { + debugNetworking.log(`[Lobby] Adding guest ${guest.name} (${guest.pubkey.slice(0, 8)}...) to multiplayerStore`); + store.addPeer(guest.pubkey, guest.dataChannel); + } + } + } + + debugNetworking.log(`[Lobby] Total connected guests in store: ${store.getConnectedPeerCount()}`); } }, [guests]); diff --git a/src/hooks/useP2P.ts b/src/hooks/useP2P.ts deleted file mode 100644 index 3c62ebcb..00000000 --- a/src/hooks/useP2P.ts +++ /dev/null @@ -1,465 +0,0 @@ -'use client'; - -import { useState, useCallback, useRef, useEffect } from 'react'; -import { debugNetworking } from '@/utils/debugLogger'; -import { - generateOfferCode, - generateAnswerCode, - completeConnection, - createPeerConnection, - waitForConnection, - NostrMatchmaking, - type MatchedOpponent, - type ReceivedSignal, -} from '@/engine/network/p2p'; - -export type P2PStatus = - | 'idle' - | 'generating_code' - | 'waiting_for_peer' - | 'connecting' - | 'connected' - | 'searching' - | 'match_found' - | 'error'; - -export interface P2PState { - status: P2PStatus; - error: string | null; - offerCode: string | null; - peerConnection: RTCPeerConnection | null; - dataChannel: RTCDataChannel | null; - nostrStatus: string | null; - matchedOpponent: MatchedOpponent | null; -} - -export interface UseP2PReturn { - state: P2PState; - // Connection code methods (Phase 1) - hostGame: (options?: { mode?: '1v1' | '2v2'; map?: string }) => Promise; - joinWithCode: (code: string) => Promise; - completeWithAnswerCode: (answerCode: string) => Promise; - // Nostr matchmaking methods (Phase 3) - findMatch: (options: { mode: '1v1' | '2v2' | 'ffa'; skill?: number }) => Promise; - cancelSearch: () => Promise; - // Common - disconnect: () => void; - sendMessage: (data: unknown) => void; - onMessage: (handler: (data: unknown) => void) => void; - offMessage: (handler: (data: unknown) => void) => void; -} - -export function useP2P(): UseP2PReturn { - const [state, setState] = useState({ - status: 'idle', - error: null, - offerCode: null, - peerConnection: null, - dataChannel: null, - nostrStatus: null, - matchedOpponent: null, - }); - - const nostrRef = useRef(null); - const messageHandlersRef = useRef<((data: unknown) => void)[]>([]); - const pcRef = useRef(null); - const dcRef = useRef(null); - - // Cleanup on unmount - useEffect(() => { - return () => { - nostrRef.current?.destroy(); - pcRef.current?.close(); - // Clear message handlers to prevent memory leaks - messageHandlersRef.current.length = 0; - }; - }, []); - - /** - * Set up data channel handlers - */ - const setupDataChannel = useCallback((channel: RTCDataChannel) => { - dcRef.current = channel; - - channel.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - for (const handler of messageHandlersRef.current) { - handler(data); - } - } catch (e) { - debugNetworking.error('[P2P] Failed to parse message:', e); - } - }; - - channel.onclose = () => { - debugNetworking.log('[P2P] Data channel closed'); - setState(s => ({ ...s, status: 'idle', dataChannel: null })); - }; - - channel.onerror = (e) => { - debugNetworking.error('[P2P] Data channel error:', e); - }; - - setState(s => ({ ...s, dataChannel: channel })); - }, []); - - /** - * Host a game using connection codes (Phase 1) - */ - const hostGame = useCallback(async (options?: { mode?: '1v1' | '2v2'; map?: string }) => { - try { - setState(s => ({ ...s, status: 'generating_code', error: null, offerCode: null })); - - const pc = createPeerConnection(); - pcRef.current = pc; - - // Set up data channel - pc.ondatachannel = (event) => { - debugNetworking.log('[P2P] Received data channel'); - setupDataChannel(event.channel); - }; - - // Generate offer code - const { code } = await generateOfferCode(pc, options); - - // Get the data channel we created - const channels = (pc as unknown as { _channels?: RTCDataChannel[] })._channels; - if (channels && channels.length > 0) { - setupDataChannel(channels[0]); - } - - setState(s => ({ - ...s, - status: 'waiting_for_peer', - offerCode: code, - peerConnection: pc, - })); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create game'; - setState(s => ({ ...s, status: 'error', error: message })); - } - }, [setupDataChannel]); - - /** - * Join a game using a connection code (Phase 1) - */ - const joinWithCode = useCallback(async (code: string) => { - try { - setState(s => ({ ...s, status: 'connecting', error: null })); - - // Generate answer and get peer connection - const { code: answerCode, pc } = await generateAnswerCode(code); - pcRef.current = pc; - - // Set up data channel handler - pc.ondatachannel = (event) => { - debugNetworking.log('[P2P] Received data channel'); - setupDataChannel(event.channel); - }; - - // Wait for connection - await waitForConnection(pc, 15000); - - setState(s => ({ - ...s, - status: 'connected', - offerCode: answerCode, // This is the answer code to send back - peerConnection: pc, - })); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to join game'; - setState(s => ({ ...s, status: 'error', error: message })); - } - }, [setupDataChannel]); - - /** - * Complete connection with answer code (for host) - */ - const completeWithAnswerCode = useCallback(async (answerCode: string) => { - try { - if (!pcRef.current) { - throw new Error('No peer connection. Call hostGame first.'); - } - - setState(s => ({ ...s, status: 'connecting', error: null })); - - await completeConnection(pcRef.current, answerCode); - await waitForConnection(pcRef.current, 15000); - - setState(s => ({ ...s, status: 'connected' })); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to complete connection'; - setState(s => ({ ...s, status: 'error', error: message })); - } - }, []); - - /** - * Find a match using Nostr (Phase 3) - */ - const findMatch = useCallback(async (options: { mode: '1v1' | '2v2' | 'ffa'; skill?: number }) => { - try { - setState(s => ({ - ...s, - status: 'searching', - error: null, - nostrStatus: 'Initializing...', - matchedOpponent: null, - })); - - // Create Nostr matchmaking instance - const nostr = new NostrMatchmaking(); - nostrRef.current = nostr; - - nostr.onStatusChange = (status) => { - setState(s => ({ ...s, nostrStatus: status })); - }; - - nostr.onError = (error) => { - setState(s => ({ ...s, status: 'error', error: error.message })); - }; - - nostr.onMatchFound = async (opponent) => { - debugNetworking.log('[P2P] Match found:', opponent.pubkey.slice(0, 8) + '...'); - setState(s => ({ ...s, status: 'match_found', matchedOpponent: opponent })); - - // Determine who initiates (lower pubkey) - if (nostr.shouldInitiate(opponent.pubkey)) { - debugNetworking.log('[P2P] We initiate the connection'); - // Create WebRTC offer and send via Nostr - const pc = createPeerConnection(); - pcRef.current = pc; - - pc.ondatachannel = (event) => { - setupDataChannel(event.channel); - }; - - // Create data channel - const channel = pc.createDataChannel('game', { - ordered: false, - maxRetransmits: 2, - }); - setupDataChannel(channel); - - // Create offer - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - // Gather ICE candidates - const iceCandidates: string[] = []; - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 3000); - const cleanup = () => { - clearTimeout(timeout); - pc.onicecandidate = null; - pc.onicegatheringstatechange = null; - }; - pc.onicecandidate = (event) => { - if (event.candidate) { - iceCandidates.push(event.candidate.candidate); - } else { - cleanup(); - resolve(); - } - }; - pc.onicegatheringstatechange = () => { - if (pc.iceGatheringState === 'complete') { - cleanup(); - resolve(); - } - }; - }); - - // Send offer via Nostr - await nostr.sendOffer(opponent.pubkey, offer.sdp!, iceCandidates, { - mode: options.mode, - }); - - setState(s => ({ ...s, peerConnection: pc, nostrStatus: 'Sent offer, waiting for answer...' })); - } else { - debugNetworking.log('[P2P] Waiting for them to initiate'); - setState(s => ({ ...s, nostrStatus: 'Waiting for opponent to initiate...' })); - } - }; - - nostr.onOfferReceived = async (signal) => { - debugNetworking.log('[P2P] Received offer from:', signal.fromPubkey.slice(0, 8) + '...'); - setState(s => ({ ...s, nostrStatus: 'Received offer, creating answer...' })); - - // Create peer connection and answer - const pc = createPeerConnection(); - pcRef.current = pc; - - pc.ondatachannel = (event) => { - setupDataChannel(event.channel); - }; - - // Set remote description - await pc.setRemoteDescription({ type: 'offer', sdp: signal.sdp }); - - // Add ICE candidates - for (const candidate of signal.ice) { - try { - await pc.addIceCandidate({ candidate, sdpMid: '0', sdpMLineIndex: 0 }); - } catch (e) { - debugNetworking.warn('[P2P] Failed to add ICE candidate:', e); - } - } - - // Create answer - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - // Gather our ICE candidates - const iceCandidates: string[] = []; - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 3000); - const cleanup = () => { - clearTimeout(timeout); - pc.onicecandidate = null; - pc.onicegatheringstatechange = null; - }; - pc.onicecandidate = (event) => { - if (event.candidate) { - iceCandidates.push(event.candidate.candidate); - } else { - cleanup(); - resolve(); - } - }; - pc.onicegatheringstatechange = () => { - if (pc.iceGatheringState === 'complete') { - cleanup(); - resolve(); - } - }; - }); - - // Send answer via Nostr - await nostr.sendAnswer(signal.fromPubkey, answer.sdp!, iceCandidates); - - setState(s => ({ ...s, peerConnection: pc, nostrStatus: 'Sent answer, connecting...' })); - - // Wait for connection - try { - await waitForConnection(pc, 15000); - setState(s => ({ ...s, status: 'connected', nostrStatus: 'Connected!' })); - nostr.destroy(); // No longer need Nostr - } catch (e) { - setState(s => ({ ...s, status: 'error', error: 'Connection timed out' })); - } - }; - - nostr.onAnswerReceived = async (signal) => { - debugNetworking.log('[P2P] Received answer from:', signal.fromPubkey.slice(0, 8) + '...'); - setState(s => ({ ...s, nostrStatus: 'Received answer, connecting...' })); - - if (!pcRef.current) { - debugNetworking.error('[P2P] No peer connection'); - return; - } - - // Set remote description - await pcRef.current.setRemoteDescription({ type: 'answer', sdp: signal.sdp }); - - // Add ICE candidates - for (const candidate of signal.ice) { - try { - await pcRef.current.addIceCandidate({ candidate, sdpMid: '0', sdpMLineIndex: 0 }); - } catch (e) { - debugNetworking.warn('[P2P] Failed to add ICE candidate:', e); - } - } - - // Wait for connection - try { - await waitForConnection(pcRef.current, 15000); - setState(s => ({ ...s, status: 'connected', nostrStatus: 'Connected!' })); - nostr.destroy(); // No longer need Nostr - } catch (e) { - setState(s => ({ ...s, status: 'error', error: 'Connection timed out' })); - } - }; - - // Start seeking - await nostr.seekGame(options); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to find match'; - setState(s => ({ ...s, status: 'error', error: message })); - } - }, [setupDataChannel]); - - /** - * Cancel matchmaking search - */ - const cancelSearch = useCallback(async () => { - await nostrRef.current?.cancelSeek(); - nostrRef.current?.destroy(); - nostrRef.current = null; - setState(s => ({ ...s, status: 'idle', nostrStatus: null, matchedOpponent: null })); - }, []); - - /** - * Disconnect from peer - */ - const disconnect = useCallback(() => { - dcRef.current?.close(); - pcRef.current?.close(); - nostrRef.current?.destroy(); - - dcRef.current = null; - pcRef.current = null; - nostrRef.current = null; - - setState({ - status: 'idle', - error: null, - offerCode: null, - peerConnection: null, - dataChannel: null, - nostrStatus: null, - matchedOpponent: null, - }); - }, []); - - /** - * Send a message to the peer - */ - const sendMessage = useCallback((data: unknown) => { - if (dcRef.current && dcRef.current.readyState === 'open') { - dcRef.current.send(JSON.stringify(data)); - } else { - debugNetworking.warn('[P2P] Cannot send message: data channel not open'); - } - }, []); - - /** - * Register a message handler - */ - const onMessage = useCallback((handler: (data: unknown) => void) => { - messageHandlersRef.current.push(handler); - }, []); - - /** - * Unregister a message handler - */ - const offMessage = useCallback((handler: (data: unknown) => void) => { - const index = messageHandlersRef.current.indexOf(handler); - if (index !== -1) { - messageHandlersRef.current.splice(index, 1); - } - }, []); - - return { - state, - hostGame, - joinWithCode, - completeWithAnswerCode, - findMatch, - cancelSearch, - disconnect, - sendMessage, - onMessage, - offMessage, - }; -} diff --git a/src/rendering/GroundDetail.ts b/src/rendering/GroundDetail.ts deleted file mode 100644 index 7ffeea27..00000000 --- a/src/rendering/GroundDetail.ts +++ /dev/null @@ -1,312 +0,0 @@ -import * as THREE from 'three'; -import { BiomeConfig } from './Biomes'; -import { MapData } from '@/data/maps'; - -/** - * Crystals for void/frozen biomes using GPU instancing - * - * PERF: Uses InstancedMesh to batch all crystals into a single draw call. - * Previously created 500-1500 individual meshes = 500-1500 draw calls. - * Now uses 1 InstancedMesh = 1 draw call regardless of crystal count. - * - * Emissive control is delegated to EmissiveDecorationManager for centralized - * animation and intensity control. Use getInstancedMesh() to register with manager. - */ -export class CrystalField { - public group: THREE.Group; - private instancedMesh: THREE.InstancedMesh | null = null; - - // Reusable objects to avoid allocations - private static readonly tempMatrix = new THREE.Matrix4(); - private static readonly tempPosition = new THREE.Vector3(); - private static readonly tempRotation = new THREE.Euler(); - private static readonly tempQuaternion = new THREE.Quaternion(); - private static readonly tempScale = new THREE.Vector3(); - - constructor( - mapData: MapData, - biome: BiomeConfig, - getHeightAt: (x: number, y: number) => number - ) { - this.group = new THREE.Group(); - - if (biome.crystalDensity <= 0) return; - - const crystalCount = Math.floor(mapData.width * mapData.height * biome.crystalDensity * 0.01); - const maxClusters = Math.min(crystalCount, 500); - // Each cluster has 1-3 crystals, average ~2, so max instances = maxClusters * 3 - const maxInstances = maxClusters * 3; - - // Crystal colors based on biome - let crystalColor = new THREE.Color(0x80c0ff); // Default ice blue - let emissiveColor = new THREE.Color(0x204060); - - if (biome.name === 'Void') { - crystalColor = new THREE.Color(0xa060ff); - emissiveColor = new THREE.Color(0x4020a0); - } else if (biome.name === 'Volcanic') { - crystalColor = new THREE.Color(0xff8040); - emissiveColor = new THREE.Color(0x802010); - } - - const crystalMaterial = new THREE.MeshStandardMaterial({ - color: crystalColor, - roughness: 0.1, - metalness: 0.3, - emissive: emissiveColor, - emissiveIntensity: 0.5, // Base intensity, controlled by EmissiveDecorationManager - transparent: true, - opacity: 0.85, - }); - - // Create base geometry - unit cone that will be scaled per instance - // Base radius 0.15, height 1.0, 6 segments - const baseGeometry = new THREE.ConeGeometry(0.15, 1.0, 6); - // Translate geometry so origin is at the base (not center) - baseGeometry.translate(0, 0.5, 0); - - // Create instanced mesh with maximum capacity - this.instancedMesh = new THREE.InstancedMesh(baseGeometry, crystalMaterial, maxInstances); - this.instancedMesh.castShadow = false; - this.instancedMesh.receiveShadow = false; - // Enable frustum culling for the entire batch - this.instancedMesh.frustumCulled = true; - - // Collect all crystal transforms - let instanceIndex = 0; - const { tempMatrix, tempPosition, tempRotation, tempQuaternion, tempScale } = CrystalField; - - for (let i = 0; i < maxClusters && instanceIndex < maxInstances; i++) { - const x = 10 + Math.random() * (mapData.width - 20); - const y = 10 + Math.random() * (mapData.height - 20); - - const cellX = Math.floor(x); - const cellY = Math.floor(y); - if (cellX >= 0 && cellX < mapData.width && cellY >= 0 && cellY < mapData.height) { - const cell = mapData.terrain[cellY][cellX]; - // Crystals appear on unwalkable terrain or random ground - if (cell.terrain === 'unwalkable' || (cell.terrain === 'ground' && Math.random() < 0.2)) { - const height = getHeightAt(x, y); - const clusterCrystalCount = 1 + Math.floor(Math.random() * 3); - - for (let c = 0; c < clusterCrystalCount && instanceIndex < maxInstances; c++) { - // Random size variation - const crystalHeight = 0.5 + Math.random() * 1.5; - const crystalWidth = (0.1 + Math.random() * 0.2) / 0.15; // Scale relative to base geometry - - // Position within cluster + world position - tempPosition.set( - x + (Math.random() - 0.5) * 0.5, - height, - y + (Math.random() - 0.5) * 0.5 - ); - - // Random rotation - tempRotation.set( - (Math.random() - 0.5) * 0.3, - Math.random() * Math.PI * 2, - (Math.random() - 0.5) * 0.3 - ); - tempQuaternion.setFromEuler(tempRotation); - - // Scale based on random size - tempScale.set(crystalWidth, crystalHeight, crystalWidth); - - // Compose transformation matrix - tempMatrix.compose(tempPosition, tempQuaternion, tempScale); - this.instancedMesh.setMatrixAt(instanceIndex, tempMatrix); - - instanceIndex++; - } - } - } - } - - // Update instance count to actual number used - this.instancedMesh.count = instanceIndex; - this.instancedMesh.instanceMatrix.needsUpdate = true; - - // Compute bounding sphere for frustum culling - this.instancedMesh.computeBoundingSphere(); - - this.group.add(this.instancedMesh); - } - - /** - * Get the instanced mesh for registration with EmissiveDecorationManager. - * Returns null if no crystals were created (crystalDensity <= 0). - */ - public getInstancedMesh(): THREE.InstancedMesh | null { - return this.instancedMesh; - } - - public dispose(): void { - if (this.instancedMesh) { - this.instancedMesh.geometry.dispose(); - if (this.instancedMesh.material instanceof THREE.Material) { - this.instancedMesh.material.dispose(); - } - this.instancedMesh = null; - } - } -} - -/** - * Ground fog/mist layer for atmospheric effect - * Creates a low-lying fog effect using an animated shader - */ -export class GroundFog { - public mesh: THREE.Mesh; - private material: THREE.ShaderMaterial; - - constructor( - mapData: MapData, - biome: BiomeConfig - ) { - // Determine fog properties based on biome - let fogColor: THREE.Color; - let fogDensity: number; - let fogHeight: number; - let enabled = true; - - switch (biome.name) { - case 'Jungle': - fogColor = new THREE.Color(0x90b090); // Green-tinted mist - fogDensity = 0.15; // Very subtle - fogHeight = 0.3; - break; - case 'Frozen Wastes': - fogColor = new THREE.Color(0xd0e8ff); // Cold blue-white - fogDensity = 0.12; - fogHeight = 0.2; - break; - case 'Volcanic': - fogColor = new THREE.Color(0x302020); // Dark smoke - fogDensity = 0.1; - fogHeight = 0.4; - break; - case 'Void': - fogColor = new THREE.Color(0x301050); // Purple ethereal - fogDensity = 0.15; - fogHeight = 0.5; - break; - case 'Grassland': - fogColor = new THREE.Color(0xc8d8e8); // Light morning mist - fogDensity = 0.08; - fogHeight = 0.15; - break; - case 'Desert': - // No ground fog in desert - enabled = false; - fogColor = new THREE.Color(0xffffff); - fogDensity = 0; - fogHeight = 0; - break; - default: - fogColor = new THREE.Color(0xc0c8d0); - fogDensity = 0.1; - fogHeight = 0.2; - } - - const geometry = new THREE.PlaneGeometry(mapData.width, mapData.height, 1, 1); - - this.material = new THREE.ShaderMaterial({ - uniforms: { - time: { value: 0 }, - fogColor: { value: fogColor }, - fogDensity: { value: fogDensity }, - mapSize: { value: new THREE.Vector2(mapData.width, mapData.height) }, - }, - vertexShader: ` - varying vec2 vUv; - varying vec3 vWorldPosition; - - void main() { - vUv = uv; - vec4 worldPos = modelMatrix * vec4(position, 1.0); - vWorldPosition = worldPos.xyz; - gl_Position = projectionMatrix * viewMatrix * worldPos; - } - `, - fragmentShader: ` - uniform float time; - uniform vec3 fogColor; - uniform float fogDensity; - uniform vec2 mapSize; - varying vec2 vUv; - varying vec3 vWorldPosition; - - // Simplex noise functions - vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } - vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } - vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); } - - float snoise(vec2 v) { - const vec4 C = vec4(0.211324865405187, 0.366025403784439, - -0.577350269189626, 0.024390243902439); - vec2 i = floor(v + dot(v, C.yy)); - vec2 x0 = v - i + dot(i, C.xx); - vec2 i1; - i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); - vec4 x12 = x0.xyxy + C.xxzz; - x12.xy -= i1; - i = mod289(i); - vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) - + i.x + vec3(0.0, i1.x, 1.0)); - vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), - dot(x12.zw,x12.zw)), 0.0); - m = m*m; - m = m*m; - vec3 x = 2.0 * fract(p * C.www) - 1.0; - vec3 h = abs(x) - 0.5; - vec3 ox = floor(x + 0.5); - vec3 a0 = x - ox; - m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h); - vec3 g; - g.x = a0.x * x0.x + h.x * x0.y; - g.yz = a0.yz * x12.xz + h.yz * x12.yw; - return 130.0 * dot(m, g); - } - - void main() { - // Multi-octave noise for organic fog movement - vec2 uv = vUv * 4.0; - float n1 = snoise(uv + time * 0.05); - float n2 = snoise(uv * 2.0 - time * 0.03) * 0.5; - float n3 = snoise(uv * 4.0 + time * 0.08) * 0.25; - - float noise = (n1 + n2 + n3) * 0.5 + 0.5; - - // Fade at edges - float edgeFade = smoothstep(0.0, 0.15, vUv.x) * smoothstep(1.0, 0.85, vUv.x); - edgeFade *= smoothstep(0.0, 0.15, vUv.y) * smoothstep(1.0, 0.85, vUv.y); - - // Final alpha with noise and density - float alpha = noise * fogDensity * edgeFade; - alpha = clamp(alpha, 0.0, 0.2); // Cap max opacity - keep very subtle - - gl_FragColor = vec4(fogColor, alpha); - } - `, - transparent: true, - side: THREE.DoubleSide, - depthWrite: false, // Important for proper blending - }); - - this.mesh = new THREE.Mesh(geometry, this.material); - this.mesh.rotation.x = -Math.PI / 2; - this.mesh.position.set(mapData.width / 2, fogHeight, mapData.height / 2); - this.mesh.visible = enabled; - } - - public update(time: number): void { - if (this.mesh.visible) { - this.material.uniforms.time.value = time; - } - } - - public dispose(): void { - this.mesh.geometry.dispose(); - this.material.dispose(); - } -} diff --git a/src/store/multiplayerStore.ts b/src/store/multiplayerStore.ts index 0b1ad144..306fd936 100644 --- a/src/store/multiplayerStore.ts +++ b/src/store/multiplayerStore.ts @@ -48,6 +48,15 @@ export interface BufferedCommand { timestamp: number; } +// Peer connection info for multi-player support +export interface PeerConnection { + peerId: string; + dataChannel: RTCDataChannel; + latencyStats: LatencyStats; + connectionQuality: ConnectionQuality; + pendingPings: Map; +} + export interface MultiplayerState { // Connection state isMultiplayer: boolean; @@ -73,12 +82,14 @@ export interface MultiplayerState { desyncState: DesyncState; desyncTick: number | null; - // Peer info + // Peer info - supports up to 8 players localPeerId: string | null; - remotePeerId: string | null; + remotePeerId: string | null; // Legacy: first peer for backwards compatibility + remotePeerIds: string[]; // All remote peer IDs - // WebRTC objects - dataChannel: RTCDataChannel | null; + // WebRTC objects - supports multiple peers + dataChannel: RTCDataChannel | null; // Legacy: first channel for backwards compatibility + peerChannels: Map; // All peer connections by ID // Reconnection callback (set by lobby hook) reconnectCallback: (() => Promise) | null; @@ -86,7 +97,7 @@ export interface MultiplayerState { // Message handlers messageHandlers: ((data: unknown) => void)[]; - // Latency measurement + // Latency measurement (aggregate across all peers) latencyStats: LatencyStats; connectionQuality: ConnectionQuality; pendingPings: Map; // pingId -> timestamp @@ -103,6 +114,13 @@ export interface MultiplayerState { setDataChannel: (channel: RTCDataChannel | null) => void; setReconnectCallback: (callback: (() => Promise) | null) => void; + // Multi-peer actions + addPeer: (peerId: string, dataChannel: RTCDataChannel) => void; + removePeer: (peerId: string) => void; + getPeerChannel: (peerId: string) => RTCDataChannel | null; + getAllPeerIds: () => string[]; + getConnectedPeerCount: () => number; + // Network pause setNetworkPaused: (paused: boolean, reason?: string) => void; @@ -114,8 +132,10 @@ export interface MultiplayerState { flushCommandBuffer: () => BufferedCommand[]; clearCommandBuffer: () => void; - // Message handling + // Message handling - now supports broadcast sendMessage: (data: unknown) => boolean; + broadcastMessage: (data: unknown, excludePeerId?: string) => number; + sendToPeer: (peerId: string, data: unknown) => boolean; addMessageHandler: (handler: (data: unknown) => void) => void; removeMessageHandler: (handler: (data: unknown) => void) => void; @@ -164,7 +184,9 @@ const initialState = { desyncTick: null, localPeerId: null, remotePeerId: null, + remotePeerIds: [] as string[], dataChannel: null, + peerChannels: new Map(), reconnectCallback: null, messageHandlers: [] as ((data: unknown) => void)[], // Latency measurement @@ -193,9 +215,174 @@ export const useMultiplayerStore = create((set, get) => ({ setHost: (isHost) => set({ isHost }), setConnectionStatus: (status) => set({ connectionStatus: status }), setLocalPeerId: (id) => set({ localPeerId: id }), - setRemotePeerId: (id) => set({ remotePeerId: id }), + setRemotePeerId: (id) => { + set({ remotePeerId: id }); + // Also add to remotePeerIds if not already there + if (id) { + const current = get().remotePeerIds; + if (!current.includes(id)) { + set({ remotePeerIds: [...current, id] }); + } + } + }, setReconnectCallback: (callback) => set({ reconnectCallback: callback }), + // Multi-peer management for 8-player support + addPeer: (peerId: string, dataChannel: RTCDataChannel) => { + const state = get(); + const newPeerChannels = new Map(state.peerChannels); + + // Create peer connection info + const peerConnection: PeerConnection = { + peerId, + dataChannel, + latencyStats: { ...defaultLatencyStats }, + connectionQuality: 'excellent', + pendingPings: new Map(), + }; + + // Set up message handler for this peer's channel + const messageHandler = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + + // Handle ping/pong internally + if (data.type === 'ping') { + try { + dataChannel.send(JSON.stringify({ + type: 'pong', + pingId: data.pingId, + timestamp: data.timestamp, + })); + } catch (e) { + debugNetworking.warn(`[Multiplayer] Failed to send pong to ${peerId}:`, e); + } + return; + } + + if (data.type === 'pong') { + get().handlePong(data.pingId, data.timestamp); + return; + } + + // For host: relay game commands to other peers + if (get().isHost && data.type === 'command') { + // Add source peer info for relay tracking + const relayData = { ...data, _sourcePeer: peerId }; + // Broadcast to all OTHER peers (not back to sender) + get().broadcastMessage(relayData, peerId); + } + + // Pass to registered handlers + const handlers = get().messageHandlers; + for (const handler of handlers) { + handler(data); + } + } catch (e) { + debugNetworking.error(`[Multiplayer] Failed to parse message from ${peerId}:`, e); + } + }; + + const closeHandler = () => { + debugNetworking.log(`[Multiplayer] Peer ${peerId} disconnected`); + get().removePeer(peerId); + }; + + const errorHandler = (e: Event) => { + debugNetworking.error(`[Multiplayer] Error with peer ${peerId}:`, e); + }; + + dataChannel.addEventListener('message', messageHandler); + dataChannel.addEventListener('close', closeHandler); + dataChannel.addEventListener('error', errorHandler); + + newPeerChannels.set(peerId, peerConnection); + + // Update state + const newPeerIds = state.remotePeerIds.includes(peerId) + ? state.remotePeerIds + : [...state.remotePeerIds, peerId]; + + set({ + peerChannels: newPeerChannels, + remotePeerIds: newPeerIds, + // Set legacy fields to first peer for backwards compatibility + remotePeerId: state.remotePeerId || peerId, + dataChannel: state.dataChannel || dataChannel, + isConnected: true, + connectionStatus: 'connected', + }); + + debugNetworking.log(`[Multiplayer] Added peer ${peerId}. Total peers: ${newPeerChannels.size}`); + + // Start ping interval if not running + if (!get().pingInterval) { + get().startPingInterval(); + } + }, + + removePeer: (peerId: string) => { + const state = get(); + const newPeerChannels = new Map(state.peerChannels); + const peer = newPeerChannels.get(peerId); + + if (peer) { + try { + peer.dataChannel.close(); + } catch { /* ignore */ } + newPeerChannels.delete(peerId); + } + + const newPeerIds = state.remotePeerIds.filter(id => id !== peerId); + + // Update legacy fields + const firstPeer = newPeerChannels.values().next().value; + const newRemotePeerId = firstPeer?.peerId || null; + const newDataChannel = firstPeer?.dataChannel || null; + + const isStillConnected = newPeerChannels.size > 0; + + set({ + peerChannels: newPeerChannels, + remotePeerIds: newPeerIds, + remotePeerId: newRemotePeerId, + dataChannel: newDataChannel, + isConnected: isStillConnected, + connectionStatus: isStillConnected ? 'connected' : 'disconnected', + }); + + debugNetworking.log(`[Multiplayer] Removed peer ${peerId}. Remaining peers: ${newPeerChannels.size}`); + + // If no peers left and we were connected, attempt reconnection (for guests) + if (!isStillConnected && state.isConnected && state.isMultiplayer && !state.isHost) { + set({ + isNetworkPaused: true, + networkPauseReason: 'Connection lost. Attempting to reconnect...', + networkPauseStartTime: Date.now(), + }); + get().attemptReconnect(); + } + }, + + getPeerChannel: (peerId: string) => { + return get().peerChannels.get(peerId)?.dataChannel || null; + }, + + getAllPeerIds: () => { + return get().remotePeerIds; + }, + + getConnectedPeerCount: () => { + const channels = get().peerChannels; + let count = 0; + for (const peer of channels.values()) { + if (peer.dataChannel.readyState === 'open') { + count++; + } + } + return count; + }, + setDataChannel: (channel) => { // Set up message handler on the channel // IMPORTANT: Use addEventListener instead of onmessage assignment to avoid @@ -371,8 +558,10 @@ export const useMultiplayerStore = create((set, get) => ({ set({ commandBuffer: [] }); }, + // Send message to all connected peers (broadcast for hosts, single peer for guests) sendMessage: (data) => { - const { dataChannel, isNetworkPaused, connectionStatus } = get(); + const state = get(); + const { connectionStatus, isHost, peerChannels } = state; // If disconnected/reconnecting, buffer the command if (connectionStatus === 'reconnecting' || connectionStatus === 'waiting') { @@ -381,24 +570,84 @@ export const useMultiplayerStore = create((set, get) => ({ return false; } + // For multi-peer mode, broadcast to all peers + if (peerChannels.size > 0) { + const sent = get().broadcastMessage(data); + return sent > 0; + } + + // Legacy single-channel fallback + const { dataChannel } = state; if (dataChannel && dataChannel.readyState === 'open') { try { dataChannel.send(JSON.stringify(data)); return true; } catch (e) { debugNetworking.error('[Multiplayer] Failed to send message:', e); - // Buffer on send failure get().bufferCommand(data); return false; } } else { - debugNetworking.warn('[Multiplayer] Cannot send message: channel not open'); - // Buffer the command + debugNetworking.warn('[Multiplayer] Cannot send message: no open channels'); get().bufferCommand(data); return false; } }, + // Broadcast message to all peers, optionally excluding one (for relay) + broadcastMessage: (data, excludePeerId) => { + const state = get(); + const { peerChannels, connectionStatus } = state; + + if (connectionStatus === 'reconnecting' || connectionStatus === 'waiting') { + get().bufferCommand(data); + return 0; + } + + let sentCount = 0; + const jsonData = JSON.stringify(data); + + for (const [peerId, peer] of peerChannels) { + if (excludePeerId && peerId === excludePeerId) { + continue; // Skip excluded peer (for relay) + } + + if (peer.dataChannel.readyState === 'open') { + try { + peer.dataChannel.send(jsonData); + sentCount++; + } catch (e) { + debugNetworking.error(`[Multiplayer] Failed to send to peer ${peerId}:`, e); + } + } + } + + if (sentCount === 0 && peerChannels.size > 0) { + debugNetworking.warn('[Multiplayer] Failed to send to any peer, buffering'); + get().bufferCommand(data); + } + + return sentCount; + }, + + // Send message to a specific peer + sendToPeer: (peerId, data) => { + const peer = get().peerChannels.get(peerId); + + if (!peer || peer.dataChannel.readyState !== 'open') { + debugNetworking.warn(`[Multiplayer] Cannot send to peer ${peerId}: not connected`); + return false; + } + + try { + peer.dataChannel.send(JSON.stringify(data)); + return true; + } catch (e) { + debugNetworking.error(`[Multiplayer] Failed to send to peer ${peerId}:`, e); + return false; + } + }, + addMessageHandler: (handler) => { set((state) => ({ messageHandlers: [...state.messageHandlers, handler], @@ -639,18 +888,33 @@ export const useMultiplayerStore = create((set, get) => ({ }, reset: () => { - const { dataChannel, pingInterval } = get(); + const { dataChannel, pingInterval, peerChannels } = get(); + + // Close all peer channels + for (const peer of peerChannels.values()) { + try { + peer.dataChannel.close(); + } catch { /* ignore */ } + } + + // Close legacy data channel if (dataChannel) { - dataChannel.close(); + try { + dataChannel.close(); + } catch { /* ignore */ } } + if (pingInterval) { clearInterval(pingInterval); } + set({ ...initialState, messageHandlers: [], latencyStats: { ...defaultLatencyStats }, pendingPings: new Map(), + peerChannels: new Map(), + remotePeerIds: [], }); }, })); @@ -723,3 +987,28 @@ export function startLatencyMeasurement(): void { export function stopLatencyMeasurement(): void { useMultiplayerStore.getState().stopPingInterval(); } + +// Multi-peer utilities for 8-player support +export function getAllPeerIds(): string[] { + return useMultiplayerStore.getState().getAllPeerIds(); +} + +export function getConnectedPeerCount(): number { + return useMultiplayerStore.getState().getConnectedPeerCount(); +} + +export function addPeer(peerId: string, dataChannel: RTCDataChannel): void { + useMultiplayerStore.getState().addPeer(peerId, dataChannel); +} + +export function removePeer(peerId: string): void { + useMultiplayerStore.getState().removePeer(peerId); +} + +export function broadcastMessage(data: unknown, excludePeerId?: string): number { + return useMultiplayerStore.getState().broadcastMessage(data, excludePeerId); +} + +export function sendToPeer(peerId: string, data: unknown): boolean { + return useMultiplayerStore.getState().sendToPeer(peerId, data); +} diff --git a/tests/engine/network/connectionCode.test.ts b/tests/engine/network/connectionCode.test.ts deleted file mode 100644 index 925aeb4f..00000000 --- a/tests/engine/network/connectionCode.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - parseConnectionCode, - ConnectionCodeError, -} from '@/engine/network/p2p/ConnectionCode'; - -describe('ConnectionCode - parseConnectionCode error handling', () => { - it('throws on code that is too short', () => { - expect(() => parseConnectionCode('VOID-ABC')).toThrow(ConnectionCodeError); - }); - - it('throws on empty code', () => { - expect(() => parseConnectionCode('')).toThrow(ConnectionCodeError); - }); - - it('throws on just prefix', () => { - expect(() => parseConnectionCode('VOID-')).toThrow(ConnectionCodeError); - }); - - it('throws on random garbage', () => { - expect(() => parseConnectionCode('NOT-A-VALID-CODE-AT-ALL-AAAA-BBBB-CCCC')).toThrow(ConnectionCodeError); - }); - - it('throws ConnectionCodeError specifically', () => { - expect(() => parseConnectionCode('VOID-INVALID')).toThrow(ConnectionCodeError); - }); -}); - -describe('ConnectionCodeError', () => { - it('is an instance of Error', () => { - const error = new ConnectionCodeError('test message'); - expect(error instanceof Error).toBe(true); - }); - - it('has correct name', () => { - const error = new ConnectionCodeError('test message'); - expect(error.name).toBe('ConnectionCodeError'); - }); - - it('preserves message', () => { - const error = new ConnectionCodeError('custom error message'); - expect(error.message).toBe('custom error message'); - }); - - it('has a stack trace', () => { - const error = new ConnectionCodeError('test'); - expect(error.stack).toBeTruthy(); - expect(error.stack).toContain('ConnectionCodeError'); - }); -}); - -describe('ConnectionCode - input normalization', () => { - it('handles lowercase input by throwing ConnectionCodeError', () => { - // Even with lowercase normalization, invalid content should throw - expect(() => parseConnectionCode('void-aaaa-bbbb')).toThrow(ConnectionCodeError); - }); - - it('handles extra whitespace by throwing for invalid content', () => { - expect(() => parseConnectionCode(' VOID-XXXX ')).toThrow(ConnectionCodeError); - }); -});