From c4bc69d93fe98db88b95156bdd5163ecb1b7d62c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 14:58:00 +0000 Subject: [PATCH] chore: remove 8 orphaned files (~2900 lines of dead code) Delete unused files that were either never integrated or prepared for future features that were never wired in: - src/editor/tools/AngleConstraint.ts (283 lines) - src/engine/network/p2p/PeerRelay.ts (493 lines) - src/engine/network/p2p/NostrMatchmaking.ts (433 lines) - src/data/maps/core/MapScaffolder.ts (523 lines) - src/data/abilities/abilityMapper.ts (115 lines) - src/editor/core/EditorExportModal.tsx (200 lines) - src/editor/core/EditorFloatingToolbar.tsx (307 lines) - src/engine/ai/AbilityAI.ts (525 lines) Also removes re-exports from index files and updates documentation. https://claude.ai/code/session_016e3WTcVjZhh3vMprKRbBqA --- docs/TESTING.md | 3 - docs/architecture/OVERVIEW.md | 19 +- docs/architecture/networking.md | 66 ++- docs/security/CODE_AUDIT_REPORT.md | 14 +- src/data/abilities/abilityMapper.ts | 115 ----- src/data/index.ts | 10 - src/data/maps/core/MapScaffolder.ts | 495 ------------------- src/data/maps/core/index.ts | 15 - src/data/maps/index.ts | 8 - src/editor/core/EditorExportModal.tsx | 200 -------- src/editor/core/EditorFloatingToolbar.tsx | 307 ------------ src/editor/tools/AngleConstraint.ts | 283 ----------- src/engine/ai/AbilityAI.ts | 525 --------------------- src/engine/ai/index.ts | 1 - src/engine/network/p2p/NostrMatchmaking.ts | 433 ----------------- src/engine/network/p2p/PeerRelay.ts | 493 ------------------- src/engine/network/p2p/index.ts | 12 - 17 files changed, 41 insertions(+), 2958 deletions(-) delete mode 100644 src/data/abilities/abilityMapper.ts delete mode 100644 src/data/maps/core/MapScaffolder.ts delete mode 100644 src/editor/core/EditorExportModal.tsx delete mode 100644 src/editor/core/EditorFloatingToolbar.tsx delete mode 100644 src/editor/tools/AngleConstraint.ts delete mode 100644 src/engine/ai/AbilityAI.ts delete mode 100644 src/engine/network/p2p/NostrMatchmaking.ts delete mode 100644 src/engine/network/p2p/PeerRelay.ts diff --git a/docs/TESTING.md b/docs/TESTING.md index 8bb39644..bfcf1d15 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -146,7 +146,6 @@ npm run test:coverage # With coverage report ### Priority 4: AI Systems - [ ] **AIWorkerManager.ts** - Worker task assignment -- [ ] **AbilityAI.ts** - AI ability usage decisions - [ ] **FormationControl.ts** - Formation positioning - [ ] **InfluenceMap.ts** - Influence calculations - [ ] **PositionalAnalysis.ts** - Map analysis @@ -176,8 +175,6 @@ npm run test:coverage # With coverage report ### Priority 7: Network & Multiplayer - [ ] **ConnectionCode.ts** - Full connection code generation/parsing -- [ ] **NostrMatchmaking.ts** - Matchmaking via Nostr -- [ ] **PeerRelay.ts** - P2P relay communication - [ ] **ChecksumSystem.ts** - State checksumming for desync detection - [ ] **DesyncDetectionManager** - Full desync handling (partial coverage) diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index ac2a5f5a..8f1874be 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -114,8 +114,7 @@ voidstrike/ │ │ └── consoleCommands.ts │ ├── data/ # Game data definitions │ │ ├── abilities/ -│ │ │ ├── abilities.ts -│ │ │ └── abilityMapper.ts +│ │ │ └── abilities.ts │ │ ├── ai/ # AI subsystems │ │ │ ├── factions/ │ │ │ │ └── dominion.ts @@ -137,8 +136,7 @@ voidstrike/ │ │ │ │ ├── ConnectivityValidator.ts │ │ │ │ ├── ElevationMap.ts │ │ │ │ ├── ElevationMapGenerator.ts -│ │ │ │ ├── index.ts -│ │ │ │ └── MapScaffolder.ts +│ │ │ │ └── index.ts │ │ │ ├── json/ │ │ │ │ ├── battle_arena.json │ │ │ │ ├── contested_frontier.json @@ -197,8 +195,6 @@ voidstrike/ │ │ │ ├── EditorCanvas.tsx │ │ │ ├── EditorContextMenu.tsx │ │ │ ├── EditorCore.tsx -│ │ │ ├── EditorExportModal.tsx -│ │ │ ├── EditorFloatingToolbar.tsx │ │ │ ├── EditorHeader.tsx │ │ │ ├── EditorMiniMap.tsx │ │ │ ├── EditorStatusBar.tsx @@ -227,7 +223,6 @@ voidstrike/ │ │ ├── terrain/ │ │ │ └── EdgeDetector.ts │ │ ├── tools/ # Development tools -│ │ │ ├── AngleConstraint.ts │ │ │ ├── index.ts │ │ │ ├── ObjectPlacer.ts │ │ │ └── TerrainBrush.ts @@ -237,7 +232,6 @@ voidstrike/ │ │ └── index.ts │ ├── engine/ # Game engine core │ │ ├── ai/ # AI subsystems -│ │ │ ├── AbilityAI.ts │ │ │ ├── AIWorkerManager.ts │ │ │ ├── BehaviorTree.ts │ │ │ ├── FormationControl.ts @@ -327,9 +321,7 @@ voidstrike/ │ │ ├── network/ │ │ │ ├── p2p/ │ │ │ │ ├── index.ts -│ │ │ │ ├── NostrMatchmaking.ts -│ │ │ │ ├── NostrRelays.ts -│ │ │ │ └── PeerRelay.ts +│ │ │ │ └── NostrRelays.ts │ │ │ ├── CommandSigning.ts │ │ │ ├── DesyncDetection.ts │ │ │ ├── index.ts @@ -983,7 +975,7 @@ VOIDSTRIKE uses a **fully serverless peer-to-peer architecture** with Nostr-base | Layer | Technology | Location | | ---------------- | ------------------- | -------------------------------------------- | | Transport | WebRTC DataChannels | `src/hooks/useMultiplayer.ts` | -| Signaling | Nostr Protocol | `src/engine/network/p2p/NostrMatchmaking.ts` | +| Signaling | Nostr Protocol | `src/hooks/useMultiplayer.ts` (lobby codes) | | NAT Traversal | STUN (Google) | ICE servers in useMultiplayer | | Synchronization | Lockstep | `src/engine/network/types.ts` | | Desync Detection | Checksums | `src/engine/network/DesyncDetection.ts` | @@ -1071,7 +1063,7 @@ const ICE_SERVERS: RTCIceServer[] = [ ]; ``` -For symmetric NATs, the peer relay system (`PeerRelay.ts`) routes through other players with E2E encryption (ECDH + AES-GCM). +For symmetric NATs, STUN servers handle most traversal cases. A peer relay system for routing through other players was planned but not implemented. #### Alternative: Connection Codes (`src/engine/network/p2p/ConnectionCode.ts`) @@ -1889,7 +1881,6 @@ src/data/maps/ ├── ConnectivityAnalyzer.ts # Analyzes maps for connectivity ├── ConnectivityValidator.ts # Reports connectivity issues ├── ConnectivityFixer.ts # Auto-fixes ramps and connections - ├── MapScaffolder.ts # Auto-generate maps from base positions └── index.ts # Public API exports ``` diff --git a/docs/architecture/networking.md b/docs/architecture/networking.md index 119e83ef..f601d7fa 100644 --- a/docs/architecture/networking.md +++ b/docs/architecture/networking.md @@ -17,9 +17,12 @@ A groundbreaking multiplayer architecture that requires **zero servers** to oper | Phase | Component | Reliability | Why | |-------|-----------|-------------|-----| | **1** | Connection Codes | ✅ **100%** | Pure WebRTC encoding. Zero external dependencies. Cannot fail. | -| **2** | LAN Discovery (mDNS) | ✅ **100%** | Standard protocol, works offline | -| **3** | Nostr Discovery | ✅ **99%** | Hundreds of relays, instant WebSocket, battle-tested by millions | -| **4** | Peer Relay | ✅ **95%** | Standard WebRTC relay pattern, adds ~50-100ms latency | +| **2** | LAN Discovery (mDNS) | ⏳ Not implemented | Requires Electron/Tauri for desktop build | +| **3** | Nostr Discovery | ❌ Removed | Infrastructure removed - lobby codes used instead | +| **4** | Peer Relay | ❌ Removed | Infrastructure removed - STUN servers handle most NAT cases | + +> **Note**: Phase 3 (NostrMatchmaking.ts) and Phase 4 (PeerRelay.ts) infrastructure files were removed. +> The architecture diagrams below show the original design vision; current implementation uses lobby codes via Nostr relays in useMultiplayer.ts. --- @@ -30,14 +33,19 @@ A groundbreaking multiplayer architecture that requires **zero servers** to oper | Component | File | Status | |-----------|------|--------| | **Connection Codes** | `ConnectionCode.ts` | ✅ SDP encoding/decoding | -| **Nostr Matchmaking** | `NostrMatchmaking.ts` | ✅ Relay-based discovery | | **Nostr Relays** | `NostrRelays.ts` | ✅ Health-checked relay list | -| **Peer Relay** | `PeerRelay.ts` | ✅ NAT traversal via peers | | **Game Message Protocol** | `types.ts` | ✅ 16 message types | | **Checksum System** | `ChecksumSystem.ts` | ✅ State verification + Merkle tree | | **Merkle Tree** | `MerkleTree.ts` | ✅ O(log n) divergence detection | | **Desync Detection** | `DesyncDetection.ts` | ✅ Debugging tools | +### ❌ What's REMOVED (Phase 3/4 Infrastructure) + +| Component | Former File | Notes | +|-----------|-------------|-------| +| **Nostr Matchmaking** | `NostrMatchmaking.ts` | Removed - lobby codes via useMultiplayer.ts used instead | +| **Peer Relay** | `PeerRelay.ts` | Removed - STUN servers handle NAT traversal | + ### ✅ What's COMPLETE (Game Integration) | Component | File | Status | @@ -532,8 +540,10 @@ export async function checkRelayHealth( ### Technical Implementation +> **Note**: The `NostrMatchmaking.ts` file was removed. The code below is preserved as design reference documentation. Current matchmaking uses lobby codes in `useMultiplayer.ts`. + ```typescript -// src/engine/network/p2p/NostrMatchmaking.ts +// REMOVED: src/engine/network/p2p/NostrMatchmaking.ts (design reference only) import { SimplePool, @@ -986,8 +996,10 @@ When both players are behind **symmetric NAT** (strict corporate firewalls, some ### Technical Implementation +> **Note**: The `PeerRelay.ts` file was removed. The code below is preserved as design reference documentation for the peer relay concept that was planned but not implemented. + ```typescript -// src/engine/network/p2p/PeerRelay.ts +// REMOVED: src/engine/network/p2p/PeerRelay.ts (design reference only) /** * Message types for relay protocol @@ -1462,38 +1474,16 @@ Tasks: ``` ### Phase 3: Nostr Discovery -**Status**: ✅ Complete -**Reliability**: 99% -**Dependencies**: `nostr-tools` (~30KB) -**Implementation**: `src/engine/network/p2p/NostrMatchmaking.ts`, `src/hooks/useMultiplayer.ts` +**Status**: ❌ Removed +**Former Implementation**: `src/engine/network/p2p/NostrMatchmaking.ts` (deleted) -``` -Tasks: -- [x] NostrMatchmaking class implementation -- [x] Ephemeral keypair generation -- [x] Game seek event publishing -- [x] Game seek subscription and filtering -- [x] WebRTC offer/answer exchange via Nostr -- [x] Skill-based matchmaking (300 point bracket) -- [x] Lobby system with 4-char codes -- [x] Relay health monitoring -- [x] Public lobby listing -``` +The standalone NostrMatchmaking class was removed. Lobby-based matchmaking is now handled directly in `src/hooks/useMultiplayer.ts` using 4-character codes published to Nostr relays. ### Phase 4: Peer Relay -**Status**: Ready to implement -**Reliability**: 95% -**Dependencies**: None (uses Web Crypto API) +**Status**: ❌ Removed +**Former Implementation**: `src/engine/network/p2p/PeerRelay.ts` (deleted) -``` -Tasks: -- [ ] PeerRelayNetwork class implementation -- [ ] ECDH key exchange between peers -- [ ] AES-GCM encryption for relay data -- [ ] Relay route discovery (BFS) -- [ ] Message forwarding protocol -- [ ] Automatic fallback when direct fails -``` +The peer relay system for routing through other players was planned but removed. STUN servers handle most NAT traversal cases. For symmetric NATs where direct connection fails, players must use connection codes or find a different network configuration. --- @@ -1635,11 +1625,13 @@ const groups = checksumSystem.getDivergentGroups(remoteMerkleTree, 'units'); src/engine/network/ ├── p2p/ │ ├── ConnectionCode.ts # Phase 1: Code generation/parsing -│ ├── NostrMatchmaking.ts # Phase 3: Nostr-based discovery -│ ├── PeerRelay.ts # Phase 4: Relay network +│ ├── NostrRelays.ts # Health-checked relay list │ └── index.ts # Public exports ├── MerkleTree.ts # Merkle tree for O(log n) desync detection ├── DesyncDetection.ts # Desync debugging tools ├── index.ts # Module exports └── types.ts # Network types ``` + +> **Note**: `NostrMatchmaking.ts` (Phase 3) and `PeerRelay.ts` (Phase 4) were removed. +> Matchmaking functionality is now in `src/hooks/useMultiplayer.ts`. diff --git a/docs/security/CODE_AUDIT_REPORT.md b/docs/security/CODE_AUDIT_REPORT.md index 7f3272fb..6f34780f 100644 --- a/docs/security/CODE_AUDIT_REPORT.md +++ b/docs/security/CODE_AUDIT_REPORT.md @@ -274,21 +274,21 @@ this.queueCommand(command); // NO validation of playerId! 1. **Input Spoofing (playerId)** - `Game.ts:229-247` 2. **Stale Command Handling** - `Game.ts:890-910` -3. **No Command Authentication** - `ConnectionCode.ts`, `NostrMatchmaking.ts` +3. **No Command Authentication** - `ConnectionCode.ts` ### High 4. **Desync Detection Silent Failures** - `ChecksumSystem.ts:239-257` 5. **No Determinism Enforcement** - Various (Math.random usage) -6. **Peer Relay Encryption Fallback** - `PeerRelay.ts:379-416` -7. **Command Queue Memory Leak** - `Game.ts:836-848` +6. **Command Queue Memory Leak** - `Game.ts:836-848` ### Medium -8. **Network Delays & Clock Skew** - `Game.ts:393-407` -9. **Incomplete Lockstep Barrier** - `Game.ts:163-167` -10. **Merkle Tree Limited Resolution** - `ChecksumSystem.ts:286-292` -11. **Cleartext Nostr Transmission** - `NostrMatchmaking.ts` +7. **Network Delays & Clock Skew** - `Game.ts:393-407` +8. **Incomplete Lockstep Barrier** - `Game.ts:163-167` +9. **Merkle Tree Limited Resolution** - `ChecksumSystem.ts:286-292` + +> **Note**: Issues related to `NostrMatchmaking.ts` and `PeerRelay.ts` removed - those files have been deleted. --- diff --git a/src/data/abilities/abilityMapper.ts b/src/data/abilities/abilityMapper.ts deleted file mode 100644 index 0b49e506..00000000 --- a/src/data/abilities/abilityMapper.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Ability Mapper - * - * Maps between data layer ability definitions (AbilityDataDefinition) and - * engine layer ability definitions (AbilityDefinition from ECS components). - * - * The data layer uses AbilityActivationMode to describe HOW abilities are activated: - * - 'instant': Immediate effect, no targeting - * - 'targeted': Requires unit target selection - * - 'ground': Targets a ground location - * - 'passive': Always active, no activation - * - 'toggle': Can be turned on/off - * - 'autocast': Can be set to auto-use - * - * The engine layer uses AbilityTargetType to describe WHAT can be targeted: - * - 'none': No targeting required (passives) - * - 'point': Ground/location targeting - * - 'unit': Any unit targeting - * - 'ally': Allied unit targeting only - * - 'self': Self-targeting only - */ - -import type { - AbilityDefinition as EngineAbilityDefinition, - AbilityTargetType, -} from '@/engine/components/Ability'; -import type { AbilityDataDefinition, AbilityActivationMode } from './abilities'; - -/** - * Maps an activation mode to the appropriate target type for the engine. - * This determines what kind of target selection UI/behavior to use. - */ -export function mapActivationModeToTargetType(mode: AbilityActivationMode): AbilityTargetType { - switch (mode) { - case 'instant': - return 'self'; - case 'passive': - return 'none'; - case 'targeted': - return 'unit'; - case 'ground': - return 'point'; - case 'toggle': - return 'self'; - case 'autocast': - return 'unit'; - } -} - -/** - * Converts a data layer ability definition to an engine layer ability definition. - * This is used when loading abilities from data files into the ECS runtime. - */ -export function toEngineAbilityDefinition(data: AbilityDataDefinition): EngineAbilityDefinition { - // Find effect values - const damageEffect = data.effects?.find(e => e.type === 'damage'); - const healEffect = data.effects?.find(e => e.type === 'heal'); - - return { - id: data.id, - name: data.name, - description: data.description, - cooldown: data.cooldown ?? 0, - energyCost: data.energyCost ?? 0, - range: data.range ?? 0, - targetType: mapActivationModeToTargetType(data.targetType), - hotkey: '', // Data layer doesn't specify hotkeys - assigned by UI - iconId: data.icon, - damage: damageEffect?.value, - healing: healEffect?.value, - duration: data.duration, - aoeRadius: data.radius, - }; -} - -/** - * Converts multiple data definitions to engine definitions. - */ -export function toEngineAbilityDefinitions( - data: Record -): Record { - const result: Record = {}; - for (const [id, def] of Object.entries(data)) { - result[id] = toEngineAbilityDefinition(def); - } - return result; -} - -/** - * Checks if an activation mode requires a target to be selected. - */ -export function requiresTargetSelection(mode: AbilityActivationMode): boolean { - return mode === 'targeted' || mode === 'ground'; -} - -/** - * Checks if an activation mode is a toggle-style ability. - */ -export function isToggleAbility(mode: AbilityActivationMode): boolean { - return mode === 'toggle'; -} - -/** - * Checks if an activation mode is passive (no activation needed). - */ -export function isPassiveAbility(mode: AbilityActivationMode): boolean { - return mode === 'passive'; -} - -/** - * Checks if an activation mode can be set to autocast. - */ -export function canAutocast(mode: AbilityActivationMode): boolean { - return mode === 'autocast'; -} diff --git a/src/data/index.ts b/src/data/index.ts index b31cf71b..2e380657 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -97,16 +97,6 @@ export { type AbilityTargetFilter, } from './abilities/abilities'; -// Ability mapper for converting between data and engine layer types -export { - mapActivationModeToTargetType, - toEngineAbilityDefinition, - toEngineAbilityDefinitions, - requiresTargetSelection, - isToggleAbility, - isPassiveAbility, - canAutocast, -} from './abilities/abilityMapper'; // ==================== FORMATIONS ==================== export { diff --git a/src/data/maps/core/MapScaffolder.ts b/src/data/maps/core/MapScaffolder.ts deleted file mode 100644 index d14bd2be..00000000 --- a/src/data/maps/core/MapScaffolder.ts +++ /dev/null @@ -1,495 +0,0 @@ -/** - * MapScaffolder - Auto-generates connected maps from base positions - * - * Given just base locations, this module generates: - * 1. Appropriate elevation plateaus for each base type - * 2. Ramps connecting adjacent bases - * 3. Complete MapBlueprint with guaranteed connectivity - * - * This is the foundation for visual map editors and quick prototyping. - */ - -import type { - MapBlueprint, - MapCanvas, - MapMeta, - BaseLocation, - BaseType, - PaintCommand, - BiomeType, - DecorationRules, -} from './ElevationMap'; -import { - ELEVATION, - fill, - plateau, - ramp, - border, -} from './ElevationMap'; -import { distance } from '@/utils/math'; - -// ============================================================================= -// TYPES -// ============================================================================= - -/** Input for scaffolding a map */ -export interface MapScaffold { - /** Canvas definition (size, biome) */ - canvas: MapCanvas; - - /** Base locations - the only required input */ - bases: BaseLocation[]; - - /** Optional metadata */ - meta?: Partial; - - /** Optional explicit connections (overrides auto-detection) */ - connections?: DesiredConnection[]; - - /** Optional decoration rules */ - decorationRules?: DecorationRules; -} - -/** Explicit connection between bases */ -export interface DesiredConnection { - /** ID or index of first base */ - from: string | number; - - /** ID or index of second base */ - to: string | number; - - /** Ramp width (default 10) */ - width?: number; -} - -/** Configuration for scaffold generation */ -export interface ScaffoldConfig { - /** Elevation for main bases (default HIGH) */ - mainElevation: number; - - /** Elevation for natural expansions (default MID) */ - naturalElevation: number; - - /** Elevation for third/fourth/gold expansions (default LOW) */ - expansionElevation: number; - - /** Radius multiplier for base plateaus (default 1.0) */ - plateauScale: number; - - /** Default ramp width (default 10) */ - defaultRampWidth: number; - - /** Border width (default 12) */ - borderWidth: number; - - /** Auto-connect mains to naturals (default true) */ - autoConnectMainNatural: boolean; - - /** Auto-connect naturals to thirds (default true) */ - autoConnectNaturalThird: boolean; - - /** Auto-connect all mains together (default true) */ - autoConnectMains: boolean; -} - -const DEFAULT_CONFIG: ScaffoldConfig = { - mainElevation: ELEVATION.HIGH, - naturalElevation: ELEVATION.MID, - expansionElevation: ELEVATION.LOW, - plateauScale: 1.0, - defaultRampWidth: 10, - borderWidth: 12, - autoConnectMainNatural: true, - autoConnectNaturalThird: true, - autoConnectMains: true, -}; - -// ============================================================================= -// BASE ANALYSIS -// ============================================================================= - -/** Get default elevation for a base type */ -function getBaseElevation(type: BaseType, config: ScaffoldConfig): number { - switch (type) { - case 'main': - return config.mainElevation; - case 'natural': - return config.naturalElevation; - case 'third': - case 'fourth': - case 'gold': - return config.expansionElevation; - default: - return config.expansionElevation; - } -} - -/** Get default plateau radius for a base type */ -function getBaseRadius(type: BaseType, config: ScaffoldConfig): number { - const baseRadius: Record = { - main: 24, - natural: 18, - third: 16, - fourth: 14, - fifth: 14, - gold: 14, - pocket: 12, - }; - return Math.round(baseRadius[type] * config.plateauScale); -} - -/** Find the closest base of a specific type to a given base */ -function findClosestOfType( - base: BaseLocation, - bases: BaseLocation[], - targetType: BaseType -): BaseLocation | null { - let closest: BaseLocation | null = null; - let closestDist = Infinity; - - for (const other of bases) { - if (other === base) continue; - if (other.type !== targetType) continue; - - const dist = distance(base.x, base.y, other.x, other.y); - - if (dist < closestDist) { - closestDist = dist; - closest = other; - } - } - - return closest; -} - -// ============================================================================= -// CONNECTION DETECTION -// ============================================================================= - -interface Connection { - from: BaseLocation; - to: BaseLocation; - width: number; -} - -/** - * Auto-detect which bases should be connected based on type and proximity. - */ -function detectConnections( - bases: BaseLocation[], - config: ScaffoldConfig, - explicitConnections?: DesiredConnection[] -): Connection[] { - const connections: Connection[] = []; - const connectedPairs = new Set(); - - // Helper to add a connection - const addConnection = (from: BaseLocation, to: BaseLocation, width: number) => { - const key1 = `${from.x},${from.y}-${to.x},${to.y}`; - const key2 = `${to.x},${to.y}-${from.x},${from.y}`; - - if (!connectedPairs.has(key1) && !connectedPairs.has(key2)) { - connectedPairs.add(key1); - connections.push({ from, to, width }); - } - }; - - // Process explicit connections first - if (explicitConnections) { - for (const conn of explicitConnections) { - const from = typeof conn.from === 'number' ? bases[conn.from] : bases.find(b => - `${b.type}_${b.playerSlot}` === conn.from || `${b.x},${b.y}` === conn.from - ); - const to = typeof conn.to === 'number' ? bases[conn.to] : bases.find(b => - `${b.type}_${b.playerSlot}` === conn.to || `${b.x},${b.y}` === conn.to - ); - - if (from && to) { - addConnection(from, to, conn.width ?? config.defaultRampWidth); - } - } - } - - // Get mains - const mains = bases.filter(b => b.type === 'main'); - const naturals = bases.filter(b => b.type === 'natural'); - - // Auto-connect mains to their closest natural - if (config.autoConnectMainNatural) { - for (const main of mains) { - const closestNatural = findClosestOfType(main, bases, 'natural'); - if (closestNatural) { - addConnection(main, closestNatural, config.defaultRampWidth); - } - } - } - - // Auto-connect naturals to closest third - if (config.autoConnectNaturalThird) { - for (const natural of naturals) { - const closestThird = findClosestOfType(natural, bases, 'third'); - if (closestThird) { - // Smaller ramp for natural→third - addConnection(natural, closestThird, Math.round(config.defaultRampWidth * 0.8)); - } - } - } - - // Auto-connect all mains together (through low ground) - if (config.autoConnectMains && mains.length > 1) { - // For 2 players, we rely on natural→third→center connections - // For 4+ players, we might need to ensure center connectivity - // This is handled by the fact that thirds are at low elevation - // and will be connected through ground paths - } - - return connections; -} - -// ============================================================================= -// PAINT COMMAND GENERATION -// ============================================================================= - -/** - * Generate paint commands for a scaffold. - */ -function generatePaintCommands( - scaffold: MapScaffold, - config: ScaffoldConfig -): PaintCommand[] { - const commands: PaintCommand[] = []; - - // 1. Fill with low ground - commands.push(fill(ELEVATION.LOW)); - - // 2. Create plateaus for each base - for (const base of scaffold.bases) { - const elevation = getBaseElevation(base.type, config); - const radius = getBaseRadius(base.type, config); - - // Only create plateaus for elevated bases - if (elevation > ELEVATION.LOW) { - commands.push(plateau(base.x, base.y, radius, elevation)); - } - } - - // 3. Detect and create ramps - const connections = detectConnections(scaffold.bases, config, scaffold.connections); - - for (const conn of connections) { - const fromElev = getBaseElevation(conn.from.type, config); - const toElev = getBaseElevation(conn.to.type, config); - - // Only create ramps if there's an elevation difference - if (Math.abs(fromElev - toElev) >= 40) { - commands.push(ramp( - [conn.from.x, conn.from.y], - [conn.to.x, conn.to.y], - conn.width - )); - } - } - - // 4. Add border - commands.push(border(config.borderWidth)); - - return commands; -} - -// ============================================================================= -// MAIN SCAFFOLDING FUNCTION -// ============================================================================= - -/** - * Generate a complete MapBlueprint from base positions. - * - * This is the main function for quick map prototyping. Given just base - * locations, it generates a playable map with proper elevations and ramps. - * - * @example - * ```typescript - * const blueprint = scaffoldMap({ - * canvas: { width: 200, height: 200, biome: 'void' }, - * bases: [ - * mainBase(30, 170, 1, 'down_left'), - * mainBase(170, 30, 2, 'up_right'), - * naturalBase(60, 140, 'down_left'), - * naturalBase(140, 60, 'up_right'), - * thirdBase(30, 30, 'up_left'), - * thirdBase(170, 170, 'down_right'), - * ], - * }); - * - * // Add custom terrain - * blueprint.paint.push( - * water(100, 100, 15), - * forest(80, 80, 10, 'dense'), - * ); - * - * const mapData = generateMap(blueprint); - * ``` - */ -export function scaffoldMap( - scaffold: MapScaffold, - configOverrides?: Partial -): MapBlueprint { - const config = { ...DEFAULT_CONFIG, ...configOverrides }; - - // Count players from main bases - const mains = scaffold.bases.filter(b => b.type === 'main'); - const playerCount = mains.length as 2 | 4 | 6 | 8; - - // Generate paint commands - const paint = generatePaintCommands(scaffold, config); - - // Build blueprint - const blueprint: MapBlueprint = { - meta: { - id: scaffold.meta?.id ?? 'scaffolded_map', - name: scaffold.meta?.name ?? 'Scaffolded Map', - author: scaffold.meta?.author ?? 'Map Scaffolder', - description: scaffold.meta?.description ?? 'Auto-generated map from base positions', - players: playerCount, - }, - canvas: scaffold.canvas, - paint, - bases: scaffold.bases, - decorationRules: scaffold.decorationRules ?? { - border: { - style: 'rocks', - density: 0.7, - scale: [1.5, 3.0], - innerOffset: 15, - outerOffset: 5, - }, - baseRings: { - rocks: 16, - trees: 10, - }, - scatter: { - rocks: 0.2, - debris: 0.1, - }, - seed: Math.floor(Math.random() * 1000), - }, - }; - - return blueprint; -} - -/** - * Quick scaffold for a 1v1 map with diagonal spawns. - */ -export function scaffold1v1Diagonal( - width: number, - height: number, - biome: BiomeType = 'void' -): MapBlueprint { - const margin = 35; - const naturalOffset = 40; - - return scaffoldMap({ - canvas: { width, height, biome }, - meta: { - id: '1v1_diagonal', - name: '1v1 Diagonal', - players: 2, - }, - bases: [ - // Mains in opposite corners - { type: 'main', x: margin, y: height - margin, playerSlot: 1, mineralDirection: 'down_left' }, - { type: 'main', x: width - margin, y: margin, playerSlot: 2, mineralDirection: 'up_right' }, - // Naturals - { type: 'natural', x: margin + naturalOffset, y: height - margin - naturalOffset, mineralDirection: 'down_left' }, - { type: 'natural', x: width - margin - naturalOffset, y: margin + naturalOffset, mineralDirection: 'up_right' }, - // Thirds (opposite corners) - { type: 'third', x: margin, y: margin, mineralDirection: 'up_left' }, - { type: 'third', x: width - margin, y: height - margin, mineralDirection: 'down_right' }, - ], - }); -} - -/** - * Quick scaffold for a 1v1 map with horizontal spawns. - */ -export function scaffold1v1Horizontal( - width: number, - height: number, - biome: BiomeType = 'frozen' -): MapBlueprint { - const marginX = 35; - const centerY = height / 2; - const naturalOffset = 30; - - return scaffoldMap({ - canvas: { width, height, biome }, - meta: { - id: '1v1_horizontal', - name: '1v1 Horizontal', - players: 2, - }, - bases: [ - // Mains on left and right - { type: 'main', x: marginX, y: centerY, playerSlot: 1, mineralDirection: 'left' }, - { type: 'main', x: width - marginX, y: centerY, playerSlot: 2, mineralDirection: 'right' }, - // Naturals (diagonal from mains) - { type: 'natural', x: marginX + naturalOffset, y: centerY - naturalOffset, mineralDirection: 'up_left' }, - { type: 'natural', x: width - marginX - naturalOffset, y: centerY + naturalOffset, mineralDirection: 'down_right' }, - // Thirds - { type: 'third', x: marginX, y: marginX, mineralDirection: 'up_left' }, - { type: 'third', x: width - marginX, y: height - marginX, mineralDirection: 'down_right' }, - ], - }); -} - -/** - * Quick scaffold for a 4-player map with corner spawns. - */ -export function scaffold4Player( - width: number, - height: number, - biome: BiomeType = 'desert' -): MapBlueprint { - const margin = 40; - const naturalOffset = 35; - - return scaffoldMap({ - canvas: { width, height, biome }, - meta: { - id: '4player_corners', - name: '4 Player Corners', - players: 4, - }, - bases: [ - // Mains in corners - { type: 'main', x: margin, y: margin, playerSlot: 1, mineralDirection: 'up_left' }, - { type: 'main', x: width - margin, y: margin, playerSlot: 2, mineralDirection: 'up_right' }, - { type: 'main', x: margin, y: height - margin, playerSlot: 3, mineralDirection: 'down_left' }, - { type: 'main', x: width - margin, y: height - margin, playerSlot: 4, mineralDirection: 'down_right' }, - // Naturals (diagonal inward) - { type: 'natural', x: margin + naturalOffset, y: margin + naturalOffset, mineralDirection: 'down_right' }, - { type: 'natural', x: width - margin - naturalOffset, y: margin + naturalOffset, mineralDirection: 'down_left' }, - { type: 'natural', x: margin + naturalOffset, y: height - margin - naturalOffset, mineralDirection: 'up_right' }, - { type: 'natural', x: width - margin - naturalOffset, y: height - margin - naturalOffset, mineralDirection: 'up_left' }, - // Shared thirds (edges) - { type: 'third', x: margin, y: height / 2, mineralDirection: 'left' }, - { type: 'third', x: width - margin, y: height / 2, mineralDirection: 'right' }, - { type: 'third', x: width / 2, y: margin, mineralDirection: 'up' }, - { type: 'third', x: width / 2, y: height - margin, mineralDirection: 'down' }, - ], - }); -} - -/** - * Add custom terrain to a scaffolded blueprint. - * Helper for chaining customizations. - */ -export function addTerrain( - blueprint: MapBlueprint, - ...commands: PaintCommand[] -): MapBlueprint { - return { - ...blueprint, - paint: [...blueprint.paint, ...commands], - }; -} diff --git a/src/data/maps/core/index.ts b/src/data/maps/core/index.ts index d3fe127e..954d91f5 100644 --- a/src/data/maps/core/index.ts +++ b/src/data/maps/core/index.ts @@ -11,7 +11,6 @@ * - MapBlueprint - The complete map definition type * - generateMap() - Convert blueprint to MapData * - Paint command helpers (plateau, ramp, water, etc.) - * - scaffoldMap() - Auto-generate maps from base positions * - Connectivity analysis and validation */ @@ -155,17 +154,3 @@ export { formatFixResult, } from './ConnectivityFixer'; -// ============================================================================ -// MAP SCAFFOLDER -// ============================================================================ - -export { - type MapScaffold, - type DesiredConnection, - type ScaffoldConfig, - scaffoldMap, - scaffold1v1Diagonal, - scaffold1v1Horizontal, - scaffold4Player, - addTerrain, -} from './MapScaffolder'; diff --git a/src/data/maps/index.ts b/src/data/maps/index.ts index 04b1cf92..456e9401 100644 --- a/src/data/maps/index.ts +++ b/src/data/maps/index.ts @@ -99,14 +99,6 @@ export { getConnectivitySummary, formatValidationResult, - // Map scaffolder - type MapScaffold, - type ScaffoldConfig, - scaffoldMap, - scaffold1v1Diagonal, - scaffold1v1Horizontal, - scaffold4Player, - addTerrain, } from './core'; // JSON-based map exports (primary source of truth) diff --git a/src/editor/core/EditorExportModal.tsx b/src/editor/core/EditorExportModal.tsx deleted file mode 100644 index 73fdd1fd..00000000 --- a/src/editor/core/EditorExportModal.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/** - * EditorExportModal - Modal for exporting map data - */ - -'use client'; - -import { useState, useCallback, useEffect } from 'react'; -import type { MapData } from '@/data/maps/MapTypes'; -import { - exportMapToJson, - copyToClipboard, - downloadMapAsJson, - estimateJsonSize, -} from '../utils/mapExport'; - -export interface EditorExportModalProps { - map: MapData; - isOpen: boolean; - onClose: () => void; -} - -export function EditorExportModal({ map, isOpen, onClose }: EditorExportModalProps) { - const [copied, setCopied] = useState(false); - const [sizeInfo, setSizeInfo] = useState<{ bytes: number; formatted: string } | null>(null); - - // Calculate size on open - useEffect(() => { - if (isOpen && map) { - // Use requestAnimationFrame to avoid cascading renders - requestAnimationFrame(() => { - setSizeInfo(estimateJsonSize(map)); - }); - } - }, [isOpen, map]); - - // Reset copied state when modal opens - useEffect(() => { - if (isOpen) { - // Use requestAnimationFrame to avoid cascading renders - requestAnimationFrame(() => { - setCopied(false); - }); - } - }, [isOpen]); - - const handleCopy = useCallback(async () => { - const json = exportMapToJson(map, true); - const success = await copyToClipboard(json); - if (success) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }, [map]); - - const handleDownload = useCallback(() => { - downloadMapAsJson(map); - }, [map]); - - const handleClose = useCallback(() => { - onClose(); - }, [onClose]); - - // Handle escape key - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - handleClose(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, handleClose]); - - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - > - {/* Header */} -
-

- Export Map -

-

- Export "{map.name}" as JSON -

-
- - {/* Content */} -
- {/* Map info */} -
-
-
- ID:{' '} - {map.id} -
-
- Size:{' '} - - {map.width} x {map.height} - -
-
- Players:{' '} - {map.playerCount} -
-
- File Size:{' '} - - {sizeInfo?.formatted || 'Calculating...'} - -
-
-
- - {/* Instructions */} -
-

To update a map in the codebase:

-
    -
  1. Copy the JSON to clipboard
  2. -
  3. - Paste into{' '} - - public/data/maps/{map.id}.json - -
  4. -
  5. Commit and push
  6. -
-
- - {/* Buttons */} -
- - - -
-
- - {/* Footer */} -
- -
-
-
- ); -} - -export default EditorExportModal; diff --git a/src/editor/core/EditorFloatingToolbar.tsx b/src/editor/core/EditorFloatingToolbar.tsx deleted file mode 100644 index 83a23ff9..00000000 --- a/src/editor/core/EditorFloatingToolbar.tsx +++ /dev/null @@ -1,307 +0,0 @@ -/** - * EditorFloatingToolbar - Draggable floating toolbar for quick tool access - * - * A sleek, minimal toolbar that floats over the canvas for quick access to - * painting tools. Can be dragged to reposition. - */ - -'use client'; - -import { useState, useRef, useCallback, useEffect } from 'react'; -import type { EditorConfig, ToolConfig } from '../config/EditorConfig'; -import { clamp } from '@/utils/math'; - -export interface EditorFloatingToolbarProps { - config: EditorConfig; - activeTool: string; - selectedElevation: number; - brushSize: number; - onToolSelect: (toolId: string) => void; - onBrushSizeChange: (size: number) => void; - onElevationSelect: (elevation: number) => void; -} - -// Tool icon component with hover effects -function ToolButton({ - tool, - active, - onClick, - theme, -}: { - tool: ToolConfig; - active: boolean; - onClick: () => void; - theme: EditorConfig['theme']; -}) { - return ( - - ); -} - -// Elevation quick selector -function ElevationSelector({ - elevations, - selected, - onSelect, - theme, -}: { - elevations: EditorConfig['terrain']['elevations']; - selected: number; - onSelect: (id: number) => void; - theme: EditorConfig['theme']; -}) { - const [expanded, setExpanded] = useState(false); - const selectedElev = elevations.find((e) => e.id === selected); - - return ( -
- - - {expanded && ( - <> -
setExpanded(false)} /> -
- {elevations.map((elev) => ( - - ))} -
- - )} -
- ); -} - -// Brush size slider -function BrushSizeControl({ - value, - min, - max, - onChange, - theme, -}: { - value: number; - min: number; - max: number; - onChange: (size: number) => void; - theme: EditorConfig['theme']; -}) { - return ( -
-
- {value} -
- onChange(Number(e.target.value))} - className="w-20 h-1 rounded-full appearance-none cursor-pointer" - style={{ backgroundColor: theme.border }} - title={`Brush Size: ${value}`} - /> -
- ); -} - -export function EditorFloatingToolbar({ - config, - activeTool, - selectedElevation, - brushSize, - onToolSelect, - onBrushSizeChange, - onElevationSelect, -}: EditorFloatingToolbarProps) { - const toolbarRef = useRef(null); - const [position, setPosition] = useState({ x: 16, y: 80 }); - const [isDragging, setIsDragging] = useState(false); - const dragOffset = useRef({ x: 0, y: 0 }); - - const activeToolConfig = config.tools.find((t) => t.id === activeTool); - const showBrushSize = activeToolConfig?.hasBrushSize; - - // Handle drag start - const handleMouseDown = useCallback((e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest('button, input')) return; - setIsDragging(true); - dragOffset.current = { - x: e.clientX - position.x, - y: e.clientY - position.y, - }; - }, [position]); - - // Handle drag move - useEffect(() => { - if (!isDragging) return; - - const handleMouseMove = (e: MouseEvent) => { - setPosition({ - x: e.clientX - dragOffset.current.x, - y: e.clientY - dragOffset.current.y, - }); - }; - - const handleMouseUp = () => setIsDragging(false); - - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, [isDragging]); - - // Separate tools by type - const paintTools = config.tools.filter((t) => ['brush', 'eraser', 'fill', 'plateau'].includes(t.type)); - const selectTools = config.tools.filter((t) => t.type === 'select'); - - return ( -
-
- {/* Drag handle */} -
-
-
- - {/* Select tool */} - {selectTools.map((tool) => ( - onToolSelect(tool.id)} - theme={config.theme} - /> - ))} - - {/* Divider */} -
- - {/* Paint tools */} - {paintTools.map((tool) => ( - onToolSelect(tool.id)} - theme={config.theme} - /> - ))} - - {/* Divider */} -
- - {/* Elevation selector */} - - - {/* Brush size (when applicable) */} - {showBrushSize && ( - <> -
- - - )} -
-
- ); -} - -export default EditorFloatingToolbar; diff --git a/src/editor/tools/AngleConstraint.ts b/src/editor/tools/AngleConstraint.ts deleted file mode 100644 index 0292b5c0..00000000 --- a/src/editor/tools/AngleConstraint.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * AngleConstraint - Snap and angle constraint utilities for platform tools - * - * Provides functions to constrain drawing operations to: - * - Grid alignment (snap to integer coordinates) - * - Orthogonal angles (0°, 90°, 180°, 270°) - * - 45-degree angles (adds 45°, 135°, 225°, 315°) - * - * Used by platform polygon and line tools for clean geometric shapes. - */ - -import type { SnapMode } from '../config/EditorConfig'; -import { distance, type Point } from '@/utils/math'; - -/** Angles for orthogonal snapping (in radians) */ -const ORTHOGONAL_ANGLES = [ - 0, // East (right) - Math.PI / 2, // South (down) - Math.PI, // West (left) - -Math.PI / 2, // North (up) - 3 * Math.PI / 2 // Also north (for wraparound) -]; - -/** Angles for 45-degree snapping (in radians) */ -const DIAGONAL_ANGLES = [ - Math.PI / 4, // SE - 3 * Math.PI / 4, // SW - -3 * Math.PI / 4, // NW - -Math.PI / 4, // NE - 5 * Math.PI / 4, // Also SW (wraparound) - 7 * Math.PI / 4, // Also NE (wraparound) -]; - -/** Combined orthogonal + diagonal angles */ -const ALL_45_ANGLES = [...ORTHOGONAL_ANGLES, ...DIAGONAL_ANGLES]; - -/** - * Snap a point to the nearest grid coordinate - */ -export function snapToGrid(point: Point, gridSize: number = 1): Point { - return { - x: Math.round(point.x / gridSize) * gridSize, - y: Math.round(point.y / gridSize) * gridSize, - }; -} - -/** - * Constrain an endpoint based on snap mode - * - * @param from - Starting point (anchor) - * @param to - Target endpoint (will be adjusted) - * @param mode - Snap mode - * @param gridSize - Grid cell size for grid snapping - * @returns Constrained endpoint - */ -export function constrainEndpoint( - from: Point, - to: Point, - mode: SnapMode, - gridSize: number = 1 -): Point { - switch (mode) { - case 'none': - return { ...to }; - - case 'grid': - return snapToGrid(to, gridSize); - - case 'orthogonal': - return snapToAngle(from, to, ORTHOGONAL_ANGLES); - - case '45deg': - return snapToAngle(from, to, ALL_45_ANGLES); - - default: - return { ...to }; - } -} - -/** - * Snap endpoint to the nearest allowed angle from the start point - */ -export function snapToAngle(from: Point, to: Point, allowedAngles: number[]): Point { - const dist = distance(from.x, from.y, to.x, to.y); - - if (dist < 0.001) { - return { ...from }; - } - - // Calculate current angle - const currentAngle = Math.atan2(to.y - from.y, to.x - from.x); - - // Find the nearest allowed angle - let nearestAngle = allowedAngles[0]; - let minDiff = Math.abs(normalizeAngle(currentAngle - nearestAngle)); - - for (const angle of allowedAngles) { - const diff = Math.abs(normalizeAngle(currentAngle - angle)); - if (diff < minDiff) { - minDiff = diff; - nearestAngle = angle; - } - } - - // Calculate snapped endpoint at the same distance - return { - x: from.x + Math.cos(nearestAngle) * dist, - y: from.y + Math.sin(nearestAngle) * dist, - }; -} - -/** - * Normalize an angle to the range [-PI, PI] - */ -function normalizeAngle(angle: number): number { - while (angle > Math.PI) angle -= 2 * Math.PI; - while (angle < -Math.PI) angle += 2 * Math.PI; - return angle; -} - -/** - * Get visual guides for snap mode (for drawing helper lines) - * Returns angles in radians that should be highlighted - */ -export function getSnapGuideAngles(mode: SnapMode): number[] { - switch (mode) { - case 'orthogonal': - return [0, Math.PI / 2, Math.PI, -Math.PI / 2]; - case '45deg': - return [0, Math.PI / 4, Math.PI / 2, 3 * Math.PI / 4, Math.PI, -3 * Math.PI / 4, -Math.PI / 2, -Math.PI / 4]; - default: - return []; - } -} - -/** - * Constrain a polygon's vertices to maintain angle constraints - * Useful for adjusting vertices after placement - * - * @param vertices - Array of polygon vertices - * @param mode - Snap mode to apply - * @returns Adjusted vertices with angle constraints - */ -export function constrainPolygon(vertices: Point[], mode: SnapMode): Point[] { - if (vertices.length < 2 || mode === 'none') { - return vertices.map(v => ({ ...v })); - } - - const result: Point[] = [{ ...vertices[0] }]; - - for (let i = 1; i < vertices.length; i++) { - const constrained = constrainEndpoint(result[i - 1], vertices[i], mode); - result.push(constrained); - } - - return result; -} - -/** - * Get the constrained preview line from a start point to cursor - * Returns both the constrained endpoint and whether it's currently snapped - */ -export function getConstrainedPreview( - from: Point, - cursor: Point, - mode: SnapMode, - gridSize: number = 1 -): { endpoint: Point; isSnapped: boolean; snapAngle: number | null } { - if (mode === 'none') { - return { endpoint: cursor, isSnapped: false, snapAngle: null }; - } - - const endpoint = constrainEndpoint(from, cursor, mode, gridSize); - - // Check if we actually snapped (endpoint differs from cursor significantly) - const snapDist = distance(cursor.x, cursor.y, endpoint.x, endpoint.y); - const isSnapped = snapDist > 0.01; - - // Calculate snap angle if snapped - let snapAngle: number | null = null; - if (isSnapped) { - snapAngle = Math.atan2(endpoint.y - from.y, endpoint.x - from.x); - } - - return { endpoint, isSnapped, snapAngle }; -} - -/** - * Generate a rectangle from two corner points with angle constraints - * Returns 4 vertices forming a rectangle aligned to the grid/angles - */ -export function constrainRectangle( - corner1: Point, - corner2: Point, - mode: SnapMode, - rotation: number = 0 // 0 = axis-aligned, PI/4 = 45° rotated -): Point[] { - // Snap corners to grid if in grid mode - const c1 = mode === 'grid' ? snapToGrid(corner1) : corner1; - let c2 = mode === 'grid' ? snapToGrid(corner2) : corner2; - - // For axis-aligned rectangles (rotation = 0) - if (Math.abs(rotation) < 0.01) { - return [ - { x: c1.x, y: c1.y }, - { x: c2.x, y: c1.y }, - { x: c2.x, y: c2.y }, - { x: c1.x, y: c2.y }, - ]; - } - - // For rotated rectangles, constrain the diagonal - const constrained = constrainEndpoint(c1, c2, mode === '45deg' ? '45deg' : 'orthogonal'); - c2 = constrained; - - // Calculate rectangle from rotated diagonal - const midX = (c1.x + c2.x) / 2; - const midY = (c1.y + c2.y) / 2; - const halfDx = (c2.x - c1.x) / 2; - const halfDy = (c2.y - c1.y) / 2; - - // Perpendicular direction (rotate 90°) - const perpX = -halfDy; - const perpY = halfDx; - - return [ - { x: midX - halfDx - perpX, y: midY - halfDy - perpY }, - { x: midX + halfDx - perpX, y: midY + halfDy - perpY }, - { x: midX + halfDx + perpX, y: midY + halfDy + perpY }, - { x: midX - halfDx + perpX, y: midY - halfDy + perpY }, - ]; -} - -/** - * Check if an angle is close to a snap angle - */ -export function isNearSnapAngle(angle: number, mode: SnapMode, tolerance: number = 0.1): boolean { - const angles = getSnapGuideAngles(mode); - const normalized = normalizeAngle(angle); - - for (const snapAngle of angles) { - if (Math.abs(normalizeAngle(normalized - snapAngle)) < tolerance) { - return true; - } - } - return false; -} - -/** - * Get the nearest snap angle to a given angle - */ -export function getNearestSnapAngle(angle: number, mode: SnapMode): number | null { - const angles = getSnapGuideAngles(mode); - if (angles.length === 0) return null; - - const normalized = normalizeAngle(angle); - let nearest = angles[0]; - let minDiff = Math.abs(normalizeAngle(normalized - nearest)); - - for (const snapAngle of angles) { - const diff = Math.abs(normalizeAngle(normalized - snapAngle)); - if (diff < minDiff) { - minDiff = diff; - nearest = snapAngle; - } - } - - return nearest; -} - -const AngleConstraint = { - snapToGrid, - constrainEndpoint, - snapToAngle, - getSnapGuideAngles, - constrainPolygon, - getConstrainedPreview, - constrainRectangle, - isNearSnapAngle, - getNearestSnapAngle, -}; - -export default AngleConstraint; diff --git a/src/engine/ai/AbilityAI.ts b/src/engine/ai/AbilityAI.ts deleted file mode 100644 index cd8c33e1..00000000 --- a/src/engine/ai/AbilityAI.ts +++ /dev/null @@ -1,525 +0,0 @@ -/** - * Ability AI System for RTS AI - * - * Manages AI ability usage decisions: - * - Stim when engaging - * - Siege mode at appropriate ranges - * - EMP on energy units - * - AOE abilities on clumped enemies - * - Healing/support abilities on low-health allies - * - * Integrates with behavior trees for per-unit decisions. - */ - -import { Entity } from '../ecs/Entity'; -import { World } from '../ecs/World'; -import { Transform } from '../components/Transform'; -import { Unit } from '../components/Unit'; -import { Health } from '../components/Health'; -import { Selectable } from '../components/Selectable'; -import { Ability, AbilityState, DOMINION_ABILITIES } from '../components/Ability'; -import { distance } from '@/utils/math'; - -/** - * Ability usage decision - */ -export interface AbilityDecision { - abilityId: string; - targetType: 'self' | 'unit' | 'position' | 'none'; - targetEntityId?: number; - targetPosition?: { x: number; y: number }; - priority: number; // Higher = more urgent -} - -/** - * Cluster of units (for AOE targeting) - */ -export interface UnitCluster { - center: { x: number; y: number }; - units: number[]; - totalValue: number; - radius: number; -} - -/** - * Configuration for ability AI - */ -export interface AbilityAIConfig { - /** Health threshold to use stim (0-1) */ - stimHealthThreshold: number; - /** Minimum enemies nearby to use stim */ - stimMinEnemies: number; - /** Minimum cluster value to use AOE */ - minAoeValue: number; - /** Energy threshold to use EMP */ - empEnergyThreshold: number; - /** Health threshold to heal ally */ - healHealthThreshold: number; - /** Cooldown between ability checks (ticks) */ - checkCooldown: number; -} - -const DEFAULT_CONFIG: AbilityAIConfig = { - stimHealthThreshold: 0.7, - stimMinEnemies: 2, - minAoeValue: 200, - empEnergyThreshold: 50, - healHealthThreshold: 0.5, - checkCooldown: 10, -}; - -/** - * Ability evaluation function type - */ -type AbilityEvaluator = ( - world: World, - caster: Entity, - ability: AbilityState, - context: AbilityContext -) => AbilityDecision | null; - -/** - * Context for ability evaluation - */ -interface AbilityContext { - enemies: Entity[]; - allies: Entity[]; - casterPos: { x: number; y: number }; - casterHealth: number; - casterMaxHealth: number; -} - -/** - * Ability AI - Manages AI ability usage - */ -export class AbilityAI { - private config: AbilityAIConfig; - - // Last check tick per unit - private lastCheckTick: Map = new Map(); - - // Ability evaluators by ability ID - private evaluators: Map = new Map(); - - constructor(config?: Partial) { - this.config = { ...DEFAULT_CONFIG, ...config }; - this.registerDefaultEvaluators(); - } - - /** - * Register default ability evaluators - */ - private registerDefaultEvaluators(): void { - // Stim Pack - Use when engaging multiple enemies with decent health - this.evaluators.set('stim_pack', (world, caster, ability, ctx) => { - // Don't stim if low health (it costs HP) - if (ctx.casterHealth / ctx.casterMaxHealth < this.config.stimHealthThreshold) { - return null; - } - - // Need enemies nearby - const nearbyEnemies = ctx.enemies.filter(e => { - const t = e.get('Transform')!; - return distance(ctx.casterPos.x, ctx.casterPos.y, t.x, t.y) < 8; - }); - - if (nearbyEnemies.length < this.config.stimMinEnemies) { - return null; - } - - // Check if already stimmed - const unit = caster.get('Unit')!; - if (unit.hasBuff('stim')) { - return null; - } - - return { - abilityId: 'stim_pack', - targetType: 'self', - priority: 5, - }; - }); - - // Siege Mode - Transform at appropriate range - this.evaluators.set('siege_mode', (world, caster, ability, ctx) => { - const unit = caster.get('Unit')!; - - // Find closest enemy - let closestDist = Infinity; - for (const enemy of ctx.enemies) { - const t = enemy.get('Transform')!; - const dist = distance(ctx.casterPos.x, ctx.casterPos.y, t.x, t.y); - if (dist < closestDist) closestDist = dist; - } - - // Siege up when enemies are at medium range - const shouldSiege = closestDist > 5 && closestDist < 12; - - // Check current mode - if (unit.currentMode === 'siege' && !shouldSiege) { - // Unsiege - return { - abilityId: 'siege_mode', - targetType: 'self', - priority: 3, - }; - } else if (unit.currentMode !== 'siege' && shouldSiege) { - // Siege up - return { - abilityId: 'siege_mode', - targetType: 'self', - priority: 3, - }; - } - - return null; - }); - - // EMP Round - Target high-energy enemies or clumps - this.evaluators.set('emp_round', (world, caster, ability, ctx) => { - const empDef = DOMINION_ABILITIES.emp_round; - if (!empDef) return null; - - // Find best target cluster - const bestTarget = this.findBestAoeTarget(ctx.enemies, ctx.casterPos, empDef.aoeRadius || 2.5, empDef.range); - if (!bestTarget) return null; - - // Calculate cluster value (prioritize energy units) - let clusterValue = 0; - for (const entityId of bestTarget.units) { - const entity = world.getEntity(entityId); - if (!entity) continue; - - const abilityComp = entity.get('Ability'); - if (abilityComp && abilityComp.energy > this.config.empEnergyThreshold) { - clusterValue += abilityComp.energy * 2; // Energy worth double - } - - const health = entity.get('Health'); - if (health) { - clusterValue += health.current * 0.5; - } - } - - if (clusterValue < this.config.minAoeValue) { - return null; - } - - return { - abilityId: 'emp_round', - targetType: 'position', - targetPosition: bestTarget.center, - priority: 7, - }; - }); - - // Snipe - Target high-value biological units - this.evaluators.set('snipe', (world, caster, ability, ctx) => { - const snipeDef = DOMINION_ABILITIES.snipe; - if (!snipeDef) return null; - - // Find best snipe target - let bestTarget: Entity | null = null; - let bestValue = 0; - - for (const enemy of ctx.enemies) { - const transform = enemy.get('Transform')!; - const health = enemy.get('Health')!; - const unit = enemy.get('Unit'); - - // Range check - const dist = distance(ctx.casterPos.x, ctx.casterPos.y, transform.x, transform.y); - if (dist > snipeDef.range) continue; - - // Must be biological - if (!unit?.isBiological) continue; - - // Value based on how close to death snipe would bring them - const snipeDamage = snipeDef.damage || 150; - const wouldKill = health.current <= snipeDamage; - const value = wouldKill ? 1000 : (snipeDamage / health.current) * 100; - - if (value > bestValue) { - bestValue = value; - bestTarget = enemy; - } - } - - if (!bestTarget || bestValue < 50) return null; - - return { - abilityId: 'snipe', - targetType: 'unit', - targetEntityId: bestTarget.id, - priority: 6, - }; - }); - - // Power Cannon - Target high-HP enemies - this.evaluators.set('power_cannon', this.createPowerCannonEvaluator()); - - // Scanner Sweep - Reveal cloaked enemies - this.evaluators.set('scanner_sweep', (_world, _caster, _ability, _ctx) => { - // Only use if there are suspected cloaked enemies nearby - // (for now, use if enemies were recently attacking but are now invisible) - // This is a simplified version - real implementation would track cloaked units - - return null; // Disabled for now - needs cloak detection system - }); - } - - /** - * Create evaluator for Power Cannon ability - */ - private createPowerCannonEvaluator(): AbilityEvaluator { - return (world, caster, ability, ctx) => { - const def = DOMINION_ABILITIES['power_cannon']; - if (!def) return null; - - let bestTarget: Entity | null = null; - let bestValue = 0; - - for (const enemy of ctx.enemies) { - const transform = enemy.get('Transform')!; - const health = enemy.get('Health')!; - const unit = enemy.get('Unit'); - - // Range check - const dist = distance(ctx.casterPos.x, ctx.casterPos.y, transform.x, transform.y); - if (dist > def.range) continue; - - // Prioritize high-value targets we can kill or heavily damage - const damage = def.damage || 300; - const wouldKill = health.current <= damage; - - // Value calculation - let value = 0; - if (wouldKill) { - // Can kill it - very valuable - value = health.max * 3; - } else if (health.current < damage * 2) { - // Will bring to low health - value = damage * 2; - } else { - // Chip damage on high HP target - value = damage; - } - - // Bonus for high-priority targets (capital ships, siege units) - if (unit) { - const highPriority = ['colossus', 'dreadnought', 'devastator', 'carrier']; - if (highPriority.includes(unit.unitId)) { - value *= 1.5; - } - } - - if (value > bestValue) { - bestValue = value; - bestTarget = enemy; - } - } - - // Only use on valuable targets - if (!bestTarget || bestValue < 200) return null; - - return { - abilityId: 'power_cannon', - targetType: 'unit', - targetEntityId: bestTarget.id, - priority: 8, // High priority - }; - }; - } - - /** - * Find best target position for AOE ability - */ - private findBestAoeTarget( - enemies: Entity[], - casterPos: { x: number; y: number }, - aoeRadius: number, - maxRange: number - ): UnitCluster | null { - if (enemies.length === 0) return null; - - // Simple clustering: find center of mass of enemies in range - let totalX = 0; - let totalY = 0; - let count = 0; - const inRangeEnemies: number[] = []; - - for (const enemy of enemies) { - const transform = enemy.get('Transform')!; - const dist = distance(casterPos.x, casterPos.y, transform.x, transform.y); - - if (dist <= maxRange) { - totalX += transform.x; - totalY += transform.y; - count++; - inRangeEnemies.push(enemy.id); - } - } - - if (count < 2) return null; - - const center = { x: totalX / count, y: totalY / count }; - - // Count units that would be hit - const hitUnits: number[] = []; - let totalValue = 0; - - for (const enemy of enemies) { - const transform = enemy.get('Transform')!; - const health = enemy.get('Health')!; - const dist = distance(center.x, center.y, transform.x, transform.y); - - if (dist <= aoeRadius) { - hitUnits.push(enemy.id); - totalValue += health.current; - } - } - - if (hitUnits.length < 2) return null; - - return { - center, - units: hitUnits, - totalValue, - radius: aoeRadius, - }; - } - - /** - * Evaluate abilities for a unit and return best decision - */ - public evaluateUnit( - world: World, - entity: Entity, - currentTick: number - ): AbilityDecision | null { - // Check cooldown - const lastCheck = this.lastCheckTick.get(entity.id) || 0; - if (currentTick - lastCheck < this.config.checkCooldown) { - return null; - } - this.lastCheckTick.set(entity.id, currentTick); - - // Get required components - const abilityComp = entity.get('Ability'); - const transform = entity.get('Transform'); - const health = entity.get('Health'); - const selectable = entity.get('Selectable'); - - if (!abilityComp || !transform || !health || !selectable) return null; - if (abilityComp.abilities.size === 0) return null; - - // Build context - const ctx = this.buildContext(world, entity, transform, health, selectable); - - // Evaluate each ability - const decisions: AbilityDecision[] = []; - - for (const [abilityId, abilityState] of abilityComp.abilities) { - // Skip if can't use - if (!abilityComp.canUseAbility(abilityId)) continue; - - // Get evaluator - const evaluator = this.evaluators.get(abilityId); - if (!evaluator) continue; - - const decision = evaluator(world, entity, abilityState, ctx); - if (decision) { - decisions.push(decision); - } - } - - // Return highest priority decision - if (decisions.length === 0) return null; - - decisions.sort((a, b) => b.priority - a.priority); - return decisions[0]; - } - - /** - * Build evaluation context - */ - private buildContext( - world: World, - entity: Entity, - transform: Transform, - health: Health, - selectable: Selectable - ): AbilityContext { - const enemies: Entity[] = []; - const allies: Entity[] = []; - - const units = world.getEntitiesWith('Unit', 'Transform', 'Selectable', 'Health'); - - for (const other of units) { - if (other.id === entity.id) continue; - - const otherSelectable = other.get('Selectable')!; - const otherHealth = other.get('Health')!; - - if (otherHealth.isDead()) continue; - - if (otherSelectable.playerId === selectable.playerId) { - allies.push(other); - } else { - enemies.push(other); - } - } - - return { - enemies, - allies, - casterPos: { x: transform.x, y: transform.y }, - casterHealth: health.current, - casterMaxHealth: health.max, - }; - } - - /** - * Register custom ability evaluator - */ - public registerEvaluator(abilityId: string, evaluator: AbilityEvaluator): void { - this.evaluators.set(abilityId, evaluator); - } - - /** - * Clear all state - */ - public clear(): void { - this.lastCheckTick.clear(); - } -} - -/** - * Execute ability decision on a unit - */ -export function executeAbilityDecision( - world: World, - casterEntity: Entity, - decision: AbilityDecision, - eventBus: { emit: (event: string, data: unknown) => void } -): boolean { - const abilityComp = casterEntity.get('Ability'); - if (!abilityComp) return false; - - if (!abilityComp.canUseAbility(decision.abilityId)) return false; - - // Use the ability (updates cooldown and energy) - const success = abilityComp.useAbility(decision.abilityId); - if (!success) return false; - - // Emit ability used event - eventBus.emit('ability:used', { - casterId: casterEntity.id, - abilityId: decision.abilityId, - targetType: decision.targetType, - targetEntityId: decision.targetEntityId, - targetPosition: decision.targetPosition, - }); - - return true; -} diff --git a/src/engine/ai/index.ts b/src/engine/ai/index.ts index 56a4ff86..4aa87144 100644 --- a/src/engine/ai/index.ts +++ b/src/engine/ai/index.ts @@ -19,7 +19,6 @@ export { ScoutingMemory, type EnemyIntel, type ScoutedBuilding, type StrategicIn // Tactical AI systems export { FormationControl, type FormationType, type FormationSlot, type ArmyGroup, type UnitRole } from './FormationControl'; export { RetreatCoordination, type RetreatOrder, type RetreatState, type GroupRetreatStatus } from './RetreatCoordination'; -export { AbilityAI, type AbilityDecision, executeAbilityDecision } from './AbilityAI'; // Economic AI systems export { WorkerDistribution, type BaseSaturation, type WorkerTransfer } from './WorkerDistribution'; diff --git a/src/engine/network/p2p/NostrMatchmaking.ts b/src/engine/network/p2p/NostrMatchmaking.ts deleted file mode 100644 index 957d8f39..00000000 --- a/src/engine/network/p2p/NostrMatchmaking.ts +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Nostr-based matchmaking for finding games - * Uses Nostr relays for real-time game discovery - */ - -import { - SimplePool, - generateSecretKey, - getPublicKey, - finalizeEvent, - type Event, - type Filter, -} from 'nostr-tools'; -import pako from 'pako'; -import { getRelays, NostrRelayError } from './NostrRelays'; -import { debugNetworking } from '@/utils/debugLogger'; - -// Nostr event kinds for VOIDSTRIKE (ephemeral range 20000-29999) -const EVENT_KINDS = { - GAME_SEEK: 20420, // "I'm looking for a game" - GAME_OFFER: 20421, // "Here's my WebRTC offer" - GAME_ANSWER: 20422, // "Here's my WebRTC answer" - GAME_CANCEL: 20423, // "I'm no longer looking" -}; - -const GAME_VERSION = '0.1.0'; -const GAME_TAG = 'voidstrike'; - -/** - * Game seek event content - */ -interface GameSeekContent { - version: string; - mode: '1v1' | '2v2' | 'ffa'; - skill?: number; -} - -/** - * WebRTC signal content (offer/answer) - */ -interface RTCSignalContent { - sdp: string; - ice: string[]; - mode: string; - map?: string; -} - -/** - * Matched opponent info - */ -export interface MatchedOpponent { - pubkey: string; - mode: '1v1' | '2v2' | 'ffa'; - skill?: number; - timestamp: number; -} - -/** - * Received offer/answer - */ -export interface ReceivedSignal { - sdp: string; - ice: string[]; - mode: string; - map?: string; - fromPubkey: string; -} - -export class NostrMatchmakingError extends Error { - constructor(message: string) { - super(message); - this.name = 'NostrMatchmakingError'; - } -} - -/** - * Compress SDP for transmission - */ -function compressSDP(sdp: string): string { - // Remove verbose lines - const cleaned = sdp - .split('\r\n') - .filter(line => !line.startsWith('a=extmap')) - .filter(line => !line.startsWith('a=rtcp-fb')) - .join('\r\n'); - - const compressed = pako.deflate(cleaned); - return btoa(String.fromCharCode(...compressed)); -} - -/** - * Decompress SDP - */ -function decompressSDP(compressed: string): string { - const bytes = Uint8Array.from(atob(compressed), c => c.charCodeAt(0)); - return pako.inflate(bytes, { to: 'string' }); -} - -/** - * Nostr-based matchmaking service - */ -export class NostrMatchmaking { - private pool: SimplePool; - private secretKey: Uint8Array; - private publicKey: string; - private relays: string[] = []; - private subscriptions: Map void }> = new Map(); - private connected = false; - - // Callbacks - public onMatchFound?: (opponent: MatchedOpponent) => void; - public onOfferReceived?: (signal: ReceivedSignal) => void; - public onAnswerReceived?: (signal: ReceivedSignal) => void; - public onError?: (error: Error) => void; - public onStatusChange?: (status: string) => void; - - constructor() { - this.pool = new SimplePool(); - - // Generate ephemeral keypair - this.secretKey = generateSecretKey(); - this.publicKey = getPublicKey(this.secretKey); - - debugNetworking.log('[Nostr] Generated ephemeral pubkey:', this.publicKey.slice(0, 16) + '...'); - } - - /** - * Connect to Nostr relays - */ - async connect(): Promise { - if (this.connected) return; - - this.onStatusChange?.('Fetching Nostr relays...'); - - try { - this.relays = await getRelays(8); - this.connected = true; - this.onStatusChange?.(`Connected to ${this.relays.length} relays`); - debugNetworking.log('[Nostr] Connected to relays:', this.relays); - } catch (error) { - this.connected = false; - if (error instanceof NostrRelayError) { - throw new NostrMatchmakingError(error.message); - } - throw new NostrMatchmakingError('Failed to connect to Nostr relays'); - } - } - - /** - * Start looking for a game - */ - async seekGame(options: { - mode: '1v1' | '2v2' | 'ffa'; - skill?: number; - }): Promise { - if (!this.connected) { - await this.connect(); - } - - this.onStatusChange?.('Publishing game seek...'); - - // Publish "seeking game" event - const content: GameSeekContent = { - version: GAME_VERSION, - mode: options.mode, - skill: options.skill, - }; - - const event = finalizeEvent({ - kind: EVENT_KINDS.GAME_SEEK, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ['d', GAME_TAG], - ['mode', options.mode], - ['version', GAME_VERSION], - ...(options.skill ? [['skill', String(options.skill)]] : []), - ], - content: JSON.stringify(content), - }, this.secretKey); - - await Promise.any(this.relays.map(relay => - this.pool.publish([relay], event) - )); - - debugNetworking.log('[Nostr] Published game seek event'); - this.onStatusChange?.('Searching for opponents...'); - - // Subscribe to other seekers - const seekFilter: Filter = { - kinds: [EVENT_KINDS.GAME_SEEK], - '#d': [GAME_TAG], - '#mode': [options.mode], - since: Math.floor(Date.now() / 1000) - 300, // Last 5 minutes - }; - - const sub = this.pool.subscribeMany(this.relays, seekFilter, { - onevent: (event) => this.handleSeekEvent(event, options), - oneose: () => { - debugNetworking.log('[Nostr] Initial seek scan complete'); - }, - }); - - this.subscriptions.set('seek', sub); - - // Subscribe to direct offers - const offerFilter: Filter = { - kinds: [EVENT_KINDS.GAME_OFFER], - '#p': [this.publicKey], - since: Math.floor(Date.now() / 1000) - 60, - }; - - const offerSub = this.pool.subscribeMany(this.relays, offerFilter, { - onevent: (event) => this.handleOfferEvent(event), - }); - - this.subscriptions.set('offers', offerSub); - } - - /** - * Send WebRTC offer to a specific player - */ - async sendOffer( - targetPubkey: string, - sdp: string, - iceCandidates: string[], - options?: { mode?: string; map?: string } - ): Promise { - if (!this.connected) { - throw new NostrMatchmakingError('Not connected to Nostr relays'); - } - - const content: RTCSignalContent = { - sdp: compressSDP(sdp), - ice: iceCandidates, - mode: options?.mode || '1v1', - map: options?.map, - }; - - const event = finalizeEvent({ - kind: EVENT_KINDS.GAME_OFFER, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ['p', targetPubkey], - ], - content: JSON.stringify(content), - }, this.secretKey); - - await Promise.any(this.relays.map(relay => - this.pool.publish([relay], event) - )); - - debugNetworking.log('[Nostr] Sent offer to', targetPubkey.slice(0, 8) + '...'); - - // Subscribe to their answer - const answerFilter: Filter = { - kinds: [EVENT_KINDS.GAME_ANSWER], - '#p': [this.publicKey], - authors: [targetPubkey], - since: Math.floor(Date.now() / 1000) - 60, - }; - - const answerSub = this.pool.subscribeMany(this.relays, answerFilter, { - onevent: (event) => this.handleAnswerEvent(event), - }); - - this.subscriptions.set(`answer-${targetPubkey}`, answerSub); - } - - /** - * Send WebRTC answer to complete handshake - */ - async sendAnswer( - targetPubkey: string, - sdp: string, - iceCandidates: string[] - ): Promise { - if (!this.connected) { - throw new NostrMatchmakingError('Not connected to Nostr relays'); - } - - const content: RTCSignalContent = { - sdp: compressSDP(sdp), - ice: iceCandidates, - mode: '1v1', - }; - - const event = finalizeEvent({ - kind: EVENT_KINDS.GAME_ANSWER, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ['p', targetPubkey], - ], - content: JSON.stringify(content), - }, this.secretKey); - - await Promise.any(this.relays.map(relay => - this.pool.publish([relay], event) - )); - - debugNetworking.log('[Nostr] Sent answer to', targetPubkey.slice(0, 8) + '...'); - } - - /** - * Cancel matchmaking - */ - async cancelSeek(): Promise { - // Close all subscriptions - for (const sub of this.subscriptions.values()) { - sub.close(); - } - this.subscriptions.clear(); - - if (!this.connected) return; - - // Publish cancel event - const event = finalizeEvent({ - kind: EVENT_KINDS.GAME_CANCEL, - created_at: Math.floor(Date.now() / 1000), - tags: [['d', GAME_TAG]], - content: '', - }, this.secretKey); - - try { - await Promise.any(this.relays.map(relay => - this.pool.publish([relay], event) - )); - } catch { - // Ignore errors during cancel - } - - debugNetworking.log('[Nostr] Cancelled matchmaking'); - } - - /** - * Clean up - */ - destroy(): void { - for (const sub of this.subscriptions.values()) { - sub.close(); - } - this.subscriptions.clear(); - this.pool.close(this.relays); - this.connected = false; - } - - /** - * Get our public key - */ - get myPublicKey(): string { - return this.publicKey; - } - - /** - * Check if we should initiate (lower pubkey sends offer) - */ - shouldInitiate(theirPubkey: string): boolean { - return this.publicKey < theirPubkey; - } - - // Private handlers - - private handleSeekEvent(event: Event, ourOptions: { mode: string; skill?: number }): void { - // Ignore our own events - if (event.pubkey === this.publicKey) return; - - try { - const content = JSON.parse(event.content) as GameSeekContent; - - // Version compatibility check - if (content.version !== GAME_VERSION) { - debugNetworking.log('[Nostr] Ignoring seeker with different version:', content.version); - return; - } - - // Skill bracket check (optional, within 300 points) - if (ourOptions.skill && content.skill) { - const skillDiff = Math.abs(ourOptions.skill - content.skill); - if (skillDiff > 300) { - debugNetworking.log('[Nostr] Ignoring seeker outside skill range:', skillDiff); - return; - } - } - - debugNetworking.log('[Nostr] Found potential match:', event.pubkey.slice(0, 8) + '...'); - - this.onMatchFound?.({ - pubkey: event.pubkey, - mode: content.mode, - skill: content.skill, - timestamp: event.created_at, - }); - } catch (e) { - debugNetworking.error('[Nostr] Failed to parse seek event:', e); - } - } - - private handleOfferEvent(event: Event): void { - if (event.pubkey === this.publicKey) return; - - try { - const content = JSON.parse(event.content) as RTCSignalContent; - debugNetworking.log('[Nostr] Received offer from', event.pubkey.slice(0, 8) + '...'); - - this.onOfferReceived?.({ - sdp: decompressSDP(content.sdp), - ice: content.ice, - mode: content.mode, - map: content.map, - fromPubkey: event.pubkey, - }); - } catch (e) { - debugNetworking.error('[Nostr] Failed to parse offer:', e); - } - } - - private handleAnswerEvent(event: Event): void { - if (event.pubkey === this.publicKey) return; - - try { - const content = JSON.parse(event.content) as RTCSignalContent; - debugNetworking.log('[Nostr] Received answer from', event.pubkey.slice(0, 8) + '...'); - - this.onAnswerReceived?.({ - sdp: decompressSDP(content.sdp), - ice: content.ice, - mode: content.mode, - fromPubkey: event.pubkey, - }); - } catch (e) { - debugNetworking.error('[Nostr] Failed to parse answer:', e); - } - } -} diff --git a/src/engine/network/p2p/PeerRelay.ts b/src/engine/network/p2p/PeerRelay.ts deleted file mode 100644 index f903502c..00000000 --- a/src/engine/network/p2p/PeerRelay.ts +++ /dev/null @@ -1,493 +0,0 @@ -/** - * Peer Relay Network - * Routes data through other players when direct connection fails (NAT traversal fallback) - * Uses end-to-end encryption so relay nodes cannot read the data - */ - -import { debugNetworking } from '@/utils/debugLogger'; - -type EventHandler = (data: { from: string; data: unknown; relayed: boolean; via?: string[] }) => void; - -/** - * Message types for relay protocol - */ -interface RelayMessage { - type: 'relay-data' | 'peer-list-request' | 'peer-list-response' | 'relay-ping'; - from: string; - to: string; - via?: string[]; - payload?: string; - peers?: string[]; - nonce?: string; -} - -/** - * Peer Relay Network for NAT traversal fallback - */ -export class PeerRelayNetwork { - private localId: string; - private directPeers: Map = new Map(); - private relayRoutes: Map = new Map(); - private knownPeers: Set = new Set(); - private keyPair: CryptoKeyPair | null = null; - private peerPublicKeys: Map = new Map(); - private eventHandlers: Map = new Map(); - - constructor(localId: string) { - this.localId = localId; - } - - /** - * Initialize encryption keys - */ - async initialize(): Promise { - this.keyPair = await crypto.subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, - true, - ['deriveBits'] - ); - debugNetworking.log('[PeerRelay] Initialized with local ID:', this.localId.slice(0, 8) + '...'); - } - - /** - * Get our public key for sharing with peers - */ - async getPublicKeyJwk(): Promise { - if (!this.keyPair) return null; - return crypto.subtle.exportKey('jwk', this.keyPair.publicKey); - } - - /** - * Import a peer's public key - */ - async importPeerPublicKey(peerId: string, jwk: JsonWebKey): Promise { - const key = await crypto.subtle.importKey( - 'jwk', - jwk, - { name: 'ECDH', namedCurve: 'P-256' }, - true, - [] - ); - this.peerPublicKeys.set(peerId, key); - debugNetworking.log('[PeerRelay] Imported public key for peer:', peerId.slice(0, 8) + '...'); - } - - /** - * Register a direct peer connection - */ - addDirectPeer(peerId: string, channel: RTCDataChannel): void { - this.directPeers.set(peerId, channel); - this.knownPeers.add(peerId); - - channel.onmessage = (event) => { - try { - const message = JSON.parse(event.data) as RelayMessage; - this.handleMessage(peerId, message); - } catch { - // Not a relay message, pass through as direct message - this.emit('message', { - from: peerId, - data: JSON.parse(event.data), - relayed: false, - }); - } - }; - - // Ask them who they know - this.requestPeerList(peerId); - - debugNetworking.log('[PeerRelay] Added direct peer:', peerId.slice(0, 8) + '...'); - } - - /** - * Remove a peer connection - */ - removePeer(peerId: string): void { - this.directPeers.delete(peerId); - this.knownPeers.delete(peerId); - this.peerPublicKeys.delete(peerId); - - // Clear routes that went through this peer - for (const [target, route] of this.relayRoutes) { - if (route.includes(peerId)) { - this.relayRoutes.delete(target); - } - } - - debugNetworking.log('[PeerRelay] Removed peer:', peerId.slice(0, 8) + '...'); - } - - /** - * Check if we can reach a peer (direct or relayed) - */ - async canReach(targetId: string): Promise { - if (this.directPeers.has(targetId)) return true; - - const route = await this.findRelayRoute(targetId); - return route.length > 0; - } - - /** - * Send data to a peer (direct or relayed) with retry logic - */ - async sendTo(targetId: string, data: unknown): Promise { - const MAX_RETRIES = 3; - let lastError: Error | null = null; - - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - try { - await this.sendToInternal(targetId, data); - return; - } catch (e) { - lastError = e instanceof Error ? e : new Error(String(e)); - // Invalidate route on failure to force fresh lookup - this.relayRoutes.delete(targetId); - debugNetworking.warn( - `[PeerRelay] Send attempt ${attempt + 1}/${MAX_RETRIES} failed:`, - lastError.message - ); - } - } - - throw lastError ?? new Error(`Failed to send to peer ${targetId.slice(0, 8)}...`); - } - - /** - * Internal send implementation - throws on failure - */ - private async sendToInternal(targetId: string, data: unknown): Promise { - const payload = JSON.stringify(data); - - // Try direct connection first, but only if channel is open - const directChannel = this.directPeers.get(targetId); - if (directChannel) { - if (directChannel.readyState === 'open') { - this.sendViaChannel(directChannel, payload, targetId); - return; - } - // Channel exists but is closed/closing - remove stale entry and try relay - this.directPeers.delete(targetId); - debugNetworking.log( - `[PeerRelay] Direct channel to ${targetId.slice(0, 8)}... is ${directChannel.readyState}, falling back to relay` - ); - } - - // Find or validate relay route - let route = this.relayRoutes.get(targetId); - if (!route || route.length === 0) { - route = await this.findRelayRoute(targetId); - if (route.length === 0) { - throw new Error(`No route to peer ${targetId.slice(0, 8)}...`); - } - this.relayRoutes.set(targetId, route); - } - - // Encrypt payload for target (end-to-end encryption through relay) - const encrypted = await this.encryptForPeer(targetId, payload); - - const message: RelayMessage = { - type: 'relay-data', - from: this.localId, - to: targetId, - via: [this.localId], - payload: encrypted, - }; - - // Send to first hop - const firstHop = route[0]; - const channel = this.directPeers.get(firstHop); - if (!channel) { - throw new Error(`First hop ${firstHop.slice(0, 8)}... not connected`); - } - - this.sendViaChannel(channel, JSON.stringify(message), firstHop); - } - - /** - * Atomically check state and send via channel - * Throws InvalidStateError if channel is not open - */ - private sendViaChannel(channel: RTCDataChannel, payload: string, peerId: string): void { - // Atomic check-and-send: if readyState changes between check and send, - // the send() call will throw InvalidStateError which we propagate - if (channel.readyState !== 'open') { - throw new Error(`Channel to ${peerId.slice(0, 8)}... is ${channel.readyState}, not open`); - } - // Note: There's still a theoretical TOCTOU window here, but send() will throw - // InvalidStateError if the channel closes, which we catch in sendTo's retry loop - channel.send(payload); - } - - /** - * Broadcast to all connected peers - */ - broadcast(data: unknown): void { - const payload = JSON.stringify(data); - for (const channel of this.directPeers.values()) { - if (channel.readyState === 'open') { - channel.send(payload); - } - } - } - - /** - * Event listener - */ - on(event: string, handler: EventHandler): void { - if (!this.eventHandlers.has(event)) { - this.eventHandlers.set(event, []); - } - this.eventHandlers.get(event)!.push(handler); - } - - /** - * Remove event listener - */ - off(event: string, handler: EventHandler): void { - const handlers = this.eventHandlers.get(event); - if (handlers) { - const index = handlers.indexOf(handler); - if (index !== -1) { - handlers.splice(index, 1); - } - } - } - - /** - * Emit event - */ - private emit(event: string, data: { from: string; data: unknown; relayed: boolean; via?: string[] }): void { - const handlers = this.eventHandlers.get(event); - if (handlers) { - for (const handler of handlers) { - handler(data); - } - } - } - - /** - * Get list of direct peer IDs - */ - getDirectPeerIds(): string[] { - return Array.from(this.directPeers.keys()); - } - - /** - * Get list of all known peer IDs - */ - getAllKnownPeerIds(): string[] { - return Array.from(this.knownPeers); - } - - // Private methods - - private async findRelayRoute(targetId: string): Promise { - // BFS through known peers - const visited = new Set([this.localId]); - const queue: Array<{ peer: string; path: string[] }> = []; - - // Start with direct peers - for (const peerId of this.directPeers.keys()) { - queue.push({ peer: peerId, path: [peerId] }); - } - - while (queue.length > 0) { - const { peer, path } = queue.shift()!; - - if (peer === targetId) { - return path; - } - - if (visited.has(peer)) continue; - visited.add(peer); - - // Ask this peer who they know - const theirPeers = await this.requestPeerList(peer); - for (const nextPeer of theirPeers) { - if (!visited.has(nextPeer)) { - queue.push({ peer: nextPeer, path: [...path, nextPeer] }); - } - } - } - - return []; // No route found - } - - private handleMessage(fromPeer: string, message: RelayMessage): void { - switch (message.type) { - case 'relay-data': - this.handleRelayData(message); - break; - case 'peer-list-request': - this.handlePeerListRequest(fromPeer); - break; - case 'peer-list-response': - // Handled in requestPeerList promise - break; - } - } - - private async handleRelayData(message: RelayMessage): Promise { - if (message.to === this.localId) { - // We're the destination - decrypt and emit - try { - const decrypted = await this.decryptFromPeer(message.from, message.payload!); - this.emit('message', { - from: message.from, - data: JSON.parse(decrypted), - relayed: true, - via: message.via, - }); - } catch (e) { - debugNetworking.error('[PeerRelay] Failed to decrypt relay data:', e); - } - } else { - // We're a relay - forward to next hop - const route = this.relayRoutes.get(message.to); - if (route && route.length > 0) { - const nextHop = route[0]; - const channel = this.directPeers.get(nextHop); - if (channel && channel.readyState === 'open') { - message.via = [...(message.via || []), this.localId]; - channel.send(JSON.stringify(message)); - } - } - } - } - - private handlePeerListRequest(fromPeer: string): void { - const channel = this.directPeers.get(fromPeer); - if (!channel) return; - - const response: RelayMessage = { - type: 'peer-list-response', - from: this.localId, - to: fromPeer, - peers: Array.from(this.knownPeers), - }; - - channel.send(JSON.stringify(response)); - } - - private requestPeerList(peerId: string): Promise { - return new Promise((resolve) => { - const channel = this.directPeers.get(peerId); - if (!channel || channel.readyState !== 'open') { - resolve([]); - return; - } - - const timeout = setTimeout(() => { - channel.removeEventListener('message', messageHandler); - resolve([]); - }, 3000); - - // Use addEventListener to avoid overwriting existing handlers - const messageHandler = (event: MessageEvent) => { - try { - const msg = JSON.parse(event.data) as RelayMessage; - if (msg.type === 'peer-list-response' && msg.from === peerId) { - clearTimeout(timeout); - channel.removeEventListener('message', messageHandler); - - // Add their peers to our known peers - for (const peer of msg.peers || []) { - this.knownPeers.add(peer); - } - - resolve(msg.peers || []); - } - } catch { - // Not a relay message, let other handlers process it - } - }; - - channel.addEventListener('message', messageHandler); - - const request: RelayMessage = { - type: 'peer-list-request', - from: this.localId, - to: peerId, - }; - - channel.send(JSON.stringify(request)); - }); - } - - private async encryptForPeer(peerId: string, data: string): Promise { - const peerPublicKey = this.peerPublicKeys.get(peerId); - if (!peerPublicKey || !this.keyPair) { - // Encryption is required for relay messages - relay nodes must not read game commands - throw new Error(`Cannot encrypt for peer ${peerId.slice(0, 8)}...: missing encryption keys`); - } - - // Derive shared secret - const sharedBits = await crypto.subtle.deriveBits( - { name: 'ECDH', public: peerPublicKey }, - this.keyPair.privateKey, - 256 - ); - - // Import as AES key - const aesKey = await crypto.subtle.importKey( - 'raw', - sharedBits, - { name: 'AES-GCM' }, - false, - ['encrypt'] - ); - - // Encrypt - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - aesKey, - new TextEncoder().encode(data) - ); - - // Return IV + ciphertext as base64 - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); - - return btoa(String.fromCharCode(...combined)); - } - - private async decryptFromPeer(peerId: string, encrypted: string): Promise { - const peerPublicKey = this.peerPublicKeys.get(peerId); - if (!peerPublicKey || !this.keyPair) { - // Fall back to unencrypted - return atob(encrypted); - } - - // Derive shared secret - const sharedBits = await crypto.subtle.deriveBits( - { name: 'ECDH', public: peerPublicKey }, - this.keyPair.privateKey, - 256 - ); - - // Import as AES key - const aesKey = await crypto.subtle.importKey( - 'raw', - sharedBits, - { name: 'AES-GCM' }, - false, - ['decrypt'] - ); - - // Decode - const combined = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0)); - const iv = combined.slice(0, 12); - const ciphertext = combined.slice(12); - - // Decrypt - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - aesKey, - ciphertext - ); - - return new TextDecoder().decode(decrypted); - } -} diff --git a/src/engine/network/p2p/index.ts b/src/engine/network/p2p/index.ts index 8d2a2d12..ea01b458 100644 --- a/src/engine/network/p2p/index.ts +++ b/src/engine/network/p2p/index.ts @@ -11,15 +11,3 @@ export { NostrRelayError, } from './NostrRelays'; -// Nostr Matchmaking (Phase 3) - for future "Find Match" feature -export { - NostrMatchmaking, - NostrMatchmakingError, - type MatchedOpponent, - type ReceivedSignal, -} from './NostrMatchmaking'; - -// Peer Relay (Phase 4) - for NAT fallback when direct connections fail -export { - PeerRelayNetwork, -} from './PeerRelay';