From a5861d1bbd84d945cec8e732a8d234c25190eab6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 01:03:44 +0000 Subject: [PATCH 1/2] fix: Add missing getTerrainHeightAt to WorkerGame SpawnSystem.ts line 83 calls game.getTerrainHeightAt() but this method only existed in Game.ts, not WorkerGame. When SpawnSystem ran in the worker context, the call failed silently, preventing component additions to newly spawned entities. This caused AI-produced units to be created as empty entities without Unit components. https://claude.ai/code/session_01V4RhCiDvDJ7FmwSPYT74Y7 --- src/engine/workers/GameWorker.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/engine/workers/GameWorker.ts b/src/engine/workers/GameWorker.ts index fce0aca6..8fd94cf2 100644 --- a/src/engine/workers/GameWorker.ts +++ b/src/engine/workers/GameWorker.ts @@ -614,6 +614,20 @@ export class WorkerGame { return this.terrainGrid[gridY][gridX]; } + /** + * Get terrain height at a specific world position. + * Converts elevation (0-255) to world height units. + * Returns 0 if terrain data is not available. + */ + public getTerrainHeightAt(worldX: number, worldY: number): number { + const cell = this.getTerrainAt(worldX, worldY); + if (!cell) return 0; + + // Convert elevation to height using same formula as Terrain.ts + // elevation * 0.04 gives range 0 to ~10.2 units + return cell.elevation * 0.04; + } + /** * Check if a building position overlaps with decorations (rocks, trees). */ From c282e9417a10fae7c206007e3379debc047bc4ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 01:17:53 +0000 Subject: [PATCH 2/2] refactor: Extract GameCore base class to eliminate Game/WorkerGame duplication This refactor creates a proper inheritance hierarchy to prevent bugs like the missing getTerrainHeightAt method that caused AI units not to spawn. Architecture: - GameCore (abstract base): Shared terrain, placement validation, commands - Game (extends GameCore): Main thread - Selection, Audio, multiplayer - WorkerGame (extends GameCore): Worker - simulation, AI, anti-throttling Benefits: - TypeScript catches missing methods at compile time - Single source of truth for terrain/placement logic - No more copy-paste between Game.ts and GameWorker.ts - Clearer separation of thread-specific vs shared code Also: - Removed hardcoded DEBUG flag, use debugLogger system - Cleaned up temporary debug logging from Entity.ts and World.ts https://claude.ai/code/session_01V4RhCiDvDJ7FmwSPYT74Y7 --- src/engine/core/Game.ts | 1065 +++++++----------------------- src/engine/core/GameCore.ts | 536 +++++++++++++++ src/engine/ecs/Entity.ts | 11 - src/engine/ecs/World.ts | 41 +- src/engine/workers/GameWorker.ts | 922 ++++++++------------------ 5 files changed, 1058 insertions(+), 1517 deletions(-) create mode 100644 src/engine/core/GameCore.ts diff --git a/src/engine/core/Game.ts b/src/engine/core/Game.ts index c34d3ba5..be3d7ea1 100644 --- a/src/engine/core/Game.ts +++ b/src/engine/core/Game.ts @@ -1,31 +1,16 @@ -import { World } from '../ecs/World'; +import { GameCore, GameConfig, GameState, TerrainCell } from './GameCore'; +import type { SystemDefinition } from './SystemRegistry'; import { GameLoop } from './GameLoop'; -import { EventBus } from './EventBus'; -import { SystemRegistry } from './SystemRegistry'; import { getSystemDefinitions } from '../systems/systemDependencies'; // Systems that need direct references in Game class import { SelectionSystem } from '../systems/SelectionSystem'; -import { ProjectileSystem } from '../systems/ProjectileSystem'; -import { AIDifficulty } from '../systems/EnhancedAISystem'; -import { VisionSystem } from '../systems/VisionSystem'; import { AudioSystem } from '../systems/AudioSystem'; -import { GameStateSystem } from '../systems/GameStateSystem'; -import { SaveLoadSystem } from '../systems/SaveLoadSystem'; -import { PathfindingSystem } from '../systems/PathfindingSystem'; -import { AIMicroSystem } from '../systems/AIMicroSystem'; -import { ChecksumSystem } from '../systems/ChecksumSystem'; import { debugInitialization, debugPerformance, debugNetworking } from '@/utils/debugLogger'; -import { validateEntity } from '@/utils/EntityValidator'; import { dispatchCommand, type GameCommand } from './GameCommand'; -import { Transform } from '../components/Transform'; -import { Building } from '../components/Building'; -import { Unit } from '../components/Unit'; -import { Resource } from '../components/Resource'; import { Selectable } from '../components/Selectable'; import { RecastNavigation } from '../pathfinding/RecastNavigation'; -import { getLocalPlayerId } from '@/store/gameSetupStore'; import { PerformanceMonitor } from './PerformanceMonitor'; import { isMultiplayerMode, @@ -38,83 +23,54 @@ import { useMultiplayerStore, getAdaptiveCommandDelay, getLatencyStats, - type LatencyStats, } from '@/store/multiplayerStore'; // Multiplayer message types -// Supports two formats for backwards compatibility: -// 1. { type: 'command', payload: GameCommand } - Game.issueCommand format -// 2. { type: 'command', commandType: string, data: any } - WebGPUGameCanvas format interface MultiplayerMessage { type: 'command' | 'quit' | 'checksum' | 'sync-request' | 'sync-response'; payload?: unknown; - // Alternative format used by WebGPUGameCanvas commandType?: string; data?: unknown; } -// Sync request payload - sent when reconnecting interface SyncRequestPayload { lastKnownTick: number; playerId: string; } -// Sync response payload - contains commands to replay interface SyncResponsePayload { currentTick: number; commands: Array<{ tick: number; commands: GameCommand[] }>; playerId: string; } -import { DesyncDetectionManager, DesyncDetectionConfig } from '../network/DesyncDetection'; -import { bootstrapDefinitions } from '../definitions'; + +import { DesyncDetectionManager } from '../network/DesyncDetection'; import type { GameStatePort } from './GameStatePort'; import { ZustandStateAdapter } from '@/adapters/ZustandStateAdapter'; -export type GameState = 'initializing' | 'running' | 'paused' | 'ended'; - -// Terrain cell for building placement validation -export interface TerrainCell { - terrain: 'ground' | 'platform' | 'unwalkable' | 'ramp' | 'unbuildable' | 'creep'; - elevation: number; // 0-255 for new terrain system - feature?: 'none' | 'water_shallow' | 'water_deep' | 'forest_light' | 'forest_dense' | 'mud' | 'road' | 'void' | 'cliff'; -} - -export interface GameConfig { - mapWidth: number; - mapHeight: number; - tickRate: number; - isMultiplayer: boolean; - playerId: string; - aiEnabled: boolean; - aiDifficulty: AIDifficulty; -} - -const DEFAULT_CONFIG: GameConfig = { - mapWidth: 128, - mapHeight: 128, - tickRate: 20, - isMultiplayer: false, - playerId: 'player1', - aiEnabled: true, - aiDifficulty: 'medium', -}; - -export class Game { +// Re-export types for backwards compatibility +export type { GameState, TerrainCell, GameConfig } from './GameCore'; + +/** + * Game - Main thread game instance + * + * Extends GameCore with main-thread-specific features: + * - SelectionSystem (requires Three.js raycasting) + * - AudioSystem (requires Web Audio API) + * - Multiplayer message handling + * - Performance monitoring + * + * The actual game simulation runs in the worker (WorkerGame). + * This class handles UI interaction and audio playback. + */ +export class Game extends GameCore { private static instance: Game | null = null; public statePort: GameStatePort; - public world: World; - public eventBus: EventBus; - public config: GameConfig; - public visionSystem: VisionSystem; public audioSystem: AudioSystem; - public gameStateSystem: GameStateSystem; - public saveLoadSystem: SaveLoadSystem; - public pathfindingSystem: PathfindingSystem; - public aiMicroSystem: AIMicroSystem; + // Systems assigned during initializeSystems() - null until then private _selectionSystem: SelectionSystem | null = null; - private _projectileSystem: ProjectileSystem | null = null; /** * Get the SelectionSystem instance. @@ -127,177 +83,95 @@ export class Game { return this._selectionSystem; } - /** - * Get the ProjectileSystem instance. - * @throws Error if accessed before system initialization - */ - public get projectileSystem(): ProjectileSystem { - if (!this._projectileSystem) { - throw new Error('[Game] ProjectileSystem accessed before initialization'); - } - return this._projectileSystem; - } - // Determinism and multiplayer sync systems (only active in multiplayer) - public checksumSystem: ChecksumSystem | null = null; public desyncDetection: DesyncDetectionManager | null = null; - // Terrain grid for building placement validation and terrain features - private terrainGrid: TerrainCell[][] | null = null; - - /** - * Get the terrain grid (read-only access for systems) - */ - public getTerrainGrid(): TerrainCell[][] | null { - return this.terrainGrid; - } - - /** - * Get terrain cell at a specific world position - */ - public getTerrainAt(worldX: number, worldY: number): TerrainCell | null { - if (!this.terrainGrid || this.terrainGrid.length === 0) return null; - - const gridX = Math.floor(worldX); - const gridY = Math.floor(worldY); - - const firstRow = this.terrainGrid[0]; - if (!firstRow || gridY < 0 || gridY >= this.terrainGrid.length || - gridX < 0 || gridX >= firstRow.length) { - return null; - } - - return this.terrainGrid[gridY][gridX]; - } - - /** - * Get terrain height at a specific world position. - * Converts elevation (0-255) to world height units. - * Returns 0 if terrain data is not available. - */ - public getTerrainHeightAt(worldX: number, worldY: number): number { - const cell = this.getTerrainAt(worldX, worldY); - if (!cell) return 0; - - // Convert elevation to height using same formula as Terrain.ts - // elevation * 0.04 gives range 0 to ~10.2 units - return cell.elevation * 0.04; - } - - // Decoration collision data for building placement (rocks, trees) - private decorationCollisions: Array<{ x: number; z: number; radius: number }> = []; - private gameLoop: GameLoop; - private state: GameState = 'initializing'; - private currentTick = 0; // Mutex flag to prevent double game start race condition private startMutex = false; - // Command queue for lockstep multiplayer - commands are scheduled for future ticks - private commandQueue: Map = new Map(); - // Number of ticks to delay command execution (allows time for network sync) - // Now dynamically calculated based on measured RTT via getAdaptiveCommandDelay() - // Minimum: 2 ticks (100ms), Maximum: 10 ticks (500ms) at 20 TPS + // Command delay for lockstep multiplayer private readonly DEFAULT_COMMAND_DELAY_TICKS = 4; - // Track current adaptive delay for smooth transitions private currentCommandDelay = 4; - // How often to recalculate adaptive delay (every N ticks) - private readonly DELAY_RECALC_INTERVAL = 20; // 1 second at 20 TPS + private readonly DELAY_RECALC_INTERVAL = 20; // LOCKSTEP BARRIER: Track which players have sent commands for each tick - // Format: Map> private tickCommandReceipts: Map> = new Map(); - // Maximum ticks to wait for remote commands before triggering desync (10 ticks = 500ms at 20 tick/sec) private readonly LOCKSTEP_TIMEOUT_TICKS = 10; - // Command history for sync responses (keeps last N ticks of executed commands) + // Command history for sync responses private executedCommandHistory: Map = new Map(); - private readonly COMMAND_HISTORY_SIZE = 200; // Keep ~10 seconds at 20 TPS + private readonly COMMAND_HISTORY_SIZE = 200; - // Flag to indicate we're waiting for sync response + // Sync state private awaitingSyncResponse = false; private syncRequestTick = 0; - private constructor(config: Partial = {}, statePort?: GameStatePort) { - this.config = { ...DEFAULT_CONFIG, ...config }; - this.statePort = statePort ?? new ZustandStateAdapter(); - this.eventBus = new EventBus(); - - // Bootstrap definition registry from TypeScript data - // This must happen before systems initialize as they depend on definitions - bootstrapDefinitions(); - - // Pass map dimensions for spatial grid initialization - this.world = new World(this.config.mapWidth, this.config.mapHeight); - this.gameLoop = new GameLoop(this.config.tickRate, this.update.bind(this)); - - // Initialize vision system (needs to be created before other systems) - // Note: passing `this` is safe here since VisionSystem doesn't use game in constructor - this.visionSystem = new VisionSystem(this, this.config.mapWidth, this.config.mapHeight); - - // Initialize audio system (needs camera later for spatial audio) - this.audioSystem = new AudioSystem(this); + // Multiplayer message handler reference (for cleanup) + private multiplayerMessageHandler: ((data: unknown) => void) | null = null; - // Initialize game state system for victory/defeat tracking - this.gameStateSystem = new GameStateSystem(this); + // LOCKSTEP BARRIER: Track when we first started waiting for a tick + private tickWaitStart: Map = new Map(); - // Initialize save/load system - this.saveLoadSystem = new SaveLoadSystem(this); + private constructor(config: Partial = {}, statePort?: GameStatePort) { + super(config); - // Initialize pathfinding system - this.pathfindingSystem = new PathfindingSystem(this, this.config.mapWidth, this.config.mapHeight); + this.statePort = statePort ?? new ZustandStateAdapter(); + this.gameLoop = new GameLoop(this.config.tickRate, this.update.bind(this)); - // Initialize AI micro system - this.aiMicroSystem = new AIMicroSystem(this); + // Initialize audio system (main-thread only) + this.audioSystem = new AudioSystem(this as any); - // Initialize checksum system ONLY for multiplayer - no overhead in single-player + // Initialize desync detection for multiplayer if (this.config.isMultiplayer) { - this.checksumSystem = new ChecksumSystem(this, { - checksumInterval: 5, - emitNetworkChecksums: true, - logChecksums: false, - autoDumpOnDesync: true, - }); - - this.desyncDetection = new DesyncDetectionManager(this, { + this.desyncDetection = new DesyncDetectionManager(this as any, { enabled: true, pauseOnDesync: false, showDesyncIndicator: true, }); - this.desyncDetection.setChecksumSystem(this.checksumSystem); + this.desyncDetection.setChecksumSystem(this.checksumSystem!); - // Set up multiplayer message handler for receiving remote commands this.setupMultiplayerMessageHandler(); - - // Set up desync handler - end game on desync this.setupDesyncHandler(); } this.initializeSystems(); } - // Multiplayer message handler reference (for cleanup) - private multiplayerMessageHandler: ((data: unknown) => void) | null = null; + // ============================================================================ + // SYSTEM DEFINITIONS (main thread includes Selection + Audio) + // ============================================================================ + + protected getSystemDefinitions(): SystemDefinition[] { + return getSystemDefinitions(); + } + + protected override onSystemCreated(system: any): void { + super.onSystemCreated(system); + + if (system.name === 'SelectionSystem') { + this._selectionSystem = system as SelectionSystem; + } + } + + // ============================================================================ + // MULTIPLAYER MESSAGE HANDLING + // ============================================================================ private setupMultiplayerMessageHandler(): void { this.multiplayerMessageHandler = (data: unknown) => { const message = data as MultiplayerMessage; if (message.type === 'command') { - // Handle two message formats: - // Format 1: { type: 'command', payload: GameCommand } - from Game.issueCommand - // Format 2: { type: 'command', commandType: string, data: any } - from WebGPUGameCanvas if (message.payload) { - // Format 1: GameCommand in payload - LOCKSTEP: queue for scheduled tick const command = message.payload as GameCommand; - // SECURITY FIX: Validate that the command's playerId matches the remote peer - // This prevents a malicious player from spoofing commands as another player + // SECURITY: Validate playerId matches remote peer const remotePeerId = useMultiplayerStore.getState().remotePeerId; if (command.playerId !== remotePeerId) { console.error( `[Game] SECURITY: Rejected command with spoofed playerId. ` + - `Expected: ${remotePeerId}, Got: ${command.playerId}. Command type: ${command.type}` + `Expected: ${remotePeerId}, Got: ${command.playerId}` ); this.eventBus.emit('security:spoofedPlayerId', { expectedPlayerId: remotePeerId, @@ -305,19 +179,17 @@ export class Game { commandType: command.type, tick: command.tick, }); - return; // Reject the command + return; } - // SECURITY FIX: Validate command tick is within acceptable range - // Prevents commands scheduled for absurdly far future or past - // Use max of current adaptive delay and default for validation (be permissive) + // SECURITY: Validate command tick is within acceptable range const maxDelay = Math.max(this.currentCommandDelay, this.DEFAULT_COMMAND_DELAY_TICKS, 10); const minTick = this.currentTick - maxDelay; - const maxTick = this.currentTick + 100; // Allow up to 100 ticks (~5 seconds) in future + const maxTick = this.currentTick + 100; if (command.tick < minTick || command.tick > maxTick) { console.error( `[Game] SECURITY: Rejected command with invalid tick. ` + - `Current: ${this.currentTick}, Command tick: ${command.tick}, Valid range: [${minTick}, ${maxTick}]` + `Current: ${this.currentTick}, Command tick: ${command.tick}` ); this.eventBus.emit('security:invalidCommandTick', { playerId: command.playerId, @@ -325,15 +197,12 @@ export class Game { commandTick: command.tick, currentTick: this.currentTick, }); - return; // Reject the command + return; } - debugNetworking.log('[Game] Received remote command for tick', command.tick, ':', command.type, 'from', command.playerId); - // Queue for execution at the scheduled tick (lockstep) - this.queueCommand(command); + debugNetworking.log('[Game] Received remote command for tick', command.tick); + this.queueCommandWithReceipt(command); } else if (message.commandType && message.data) { - // Format 2: Event-based command from WebGPUGameCanvas - // Emit directly to event bus for systems to process debugNetworking.log('[Game] Received remote command (event format):', message.commandType); this.eventBus.emit(message.commandType, message.data); } @@ -341,7 +210,6 @@ export class Game { debugNetworking.log('[Game] Remote player quit'); this.eventBus.emit('multiplayer:playerQuit', message.payload); } else if (message.type === 'checksum') { - // CRITICAL FIX: Receive remote checksums and forward to ChecksumSystem const checksumData = message.payload as { tick: number; checksum: number; @@ -352,21 +220,18 @@ export class Game { }; this.eventBus.emit('network:checksum', checksumData); } else if (message.type === 'sync-request') { - // Handle sync request from reconnecting player const syncRequest = message.payload as SyncRequestPayload; - debugNetworking.log('[Game] Received sync request from', syncRequest.playerId, 'for tick', syncRequest.lastKnownTick); + debugNetworking.log('[Game] Received sync request from', syncRequest.playerId); this.handleSyncRequest(syncRequest); } else if (message.type === 'sync-response') { - // Handle sync response when we're reconnecting const syncResponse = message.payload as SyncResponsePayload; - debugNetworking.log('[Game] Received sync response with', syncResponse.commands.length, 'tick entries'); + debugNetworking.log('[Game] Received sync response'); this.handleSyncResponse(syncResponse); } }; addMultiplayerMessageHandler(this.multiplayerMessageHandler); - // CRITICAL FIX: Wire local checksum events to network transmission - // This was missing - checksums were computed but never sent to peers + // Wire local checksum events to network transmission this.eventBus.on('checksum:computed', (data: { tick: number; checksum: number; @@ -374,22 +239,13 @@ export class Game { buildingCount: number; resourceSum: number; }) => { - const message = { + sendMultiplayerMessage({ type: 'checksum' as const, - payload: { - ...data, - peerId: this.config.playerId, - }, - }; - sendMultiplayerMessage(message); + payload: { ...data, peerId: this.config.playerId }, + }); }); } - /** - * Set up desync handler - end game on desync - * When a desync is detected, we cannot recover (deterministic simulation diverged) - * so we end the game and notify players. - */ private setupDesyncHandler(): void { this.eventBus.on('desync:detected', (data: { tick: number; @@ -397,39 +253,34 @@ export class Game { remoteChecksum: number; remotePeerId: string; }) => { - console.error(`[Game] DESYNC at tick ${data.tick}! Game state has diverged.`); - - // Update multiplayer store with desync state + console.error(`[Game] DESYNC at tick ${data.tick}!`); reportDesync(data.tick); - - // Emit game-ending desync event for UI this.eventBus.emit('multiplayer:desync', { tick: data.tick, localChecksum: data.localChecksum, remoteChecksum: data.remoteChecksum, - message: `Game desynchronized at tick ${data.tick}. The game cannot continue.`, + message: `Game desynchronized at tick ${data.tick}.`, }); - - // End the game state (but don't stop() - let UI handle that) this.state = 'ended'; }); } + // ============================================================================ + // SINGLETON MANAGEMENT + // ============================================================================ + public static getInstance(config?: Partial, statePort?: GameStatePort): Game { if (!Game.instance) { - debugInitialization.log(`[Game] CREATING NEW INSTANCE with config:`, config ? `${config.mapWidth}x${config.mapHeight}` : 'DEFAULT 128x128'); + debugInitialization.log(`[Game] CREATING NEW INSTANCE`); Game.instance = new Game(config, statePort); } else if (config && (config.mapWidth || config.mapHeight)) { - // Update map dimensions if a new config is provided with map settings - // This handles cases where components access Game before GameCanvas initializes it const oldWidth = Game.instance.config.mapWidth; const oldHeight = Game.instance.config.mapHeight; if (config.mapWidth) Game.instance.config.mapWidth = config.mapWidth; if (config.mapHeight) Game.instance.config.mapHeight = config.mapHeight; - // Reinitialize systems if dimensions changed (keep same instance to preserve event listeners) if (Game.instance.config.mapWidth !== oldWidth || Game.instance.config.mapHeight !== oldHeight) { - debugInitialization.log(`[Game] DIMENSION CHANGE: ${oldWidth}x${oldHeight} -> ${Game.instance.config.mapWidth}x${Game.instance.config.mapHeight}, calling reinitialize`); + debugInitialization.log(`[Game] DIMENSION CHANGE: ${oldWidth}x${oldHeight} -> ${Game.instance.config.mapWidth}x${Game.instance.config.mapHeight}`); Game.instance.pathfindingSystem.reinitialize( Game.instance.config.mapWidth, Game.instance.config.mapHeight @@ -448,108 +299,43 @@ export class Game { Game.instance.stop(); Game.instance = null; } - // Reset navmesh singleton so new game gets fresh navmesh for its map RecastNavigation.resetInstance(); } - private initializeSystems(): void { - // Use SystemRegistry for dependency-based ordering - // This replaces the old priority-based system which had conflicts - const registry = new SystemRegistry(); - registry.registerAll(getSystemDefinitions()); - - // Validate dependencies at startup - const errors = registry.validate(); - if (errors.length > 0) { - console.error('[Game] System dependency errors:', errors); - throw new Error(`Invalid system dependencies:\n${errors.join('\n')}`); - } - - // Log execution order for debugging - const order = registry.getExecutionOrder(); - debugInitialization.log('[Game] System execution order:', order.join(' → ')); - - // Create systems in dependency order - const systems = registry.createSystems(this); - - // Add all systems to world - for (const system of systems) { - this.world.addSystem(system); - - // Capture references to systems that are accessed elsewhere - if (system.name === 'SelectionSystem') { - this._selectionSystem = system as SelectionSystem; - } else if (system.name === 'ProjectileSystem') { - this._projectileSystem = system as ProjectileSystem; - } - } - - // NOTE: AI player registration with AIMicroSystem happens in spawnInitialEntities() - // This ensures the store has the correct player configuration when registration occurs - } + // ============================================================================ + // GAME LIFECYCLE + // ============================================================================ - /** - * Start the game with a countdown. - * - * MULTIPLAYER ARCHITECTURE: - * The game start is based on wall-clock time, NOT on the countdown visual. - * This ensures all clients start at exactly the same time, even if: - * - A client's browser tab is in the background - * - The countdown visual is lagging or skipped - * - Network latency varies between clients - * - * For multiplayer, the server will provide a `gameStartTime` that all clients use. - * For single player, we calculate it locally as now + countdown duration. - * - * @param gameStartTime Optional wall-clock time (Date.now()) when game should start. - * If not provided, starts after countdown duration (4 seconds). - */ - public start(gameStartTime?: number): void { + public override start(gameStartTime?: number): void { if (this.state === 'running') return; - // Set state to 'initializing' so we don't try to start twice this.state = 'initializing'; - - // Calculate when the game should actually start (wall-clock time) - const countdownDuration = 4000; // 3, 2, 1, GO = 4 seconds + const countdownDuration = 4000; const scheduledStartTime = gameStartTime ?? (Date.now() + countdownDuration); - // Atomic start function with mutex to prevent race condition const startGameLoop = () => { - // Use mutex to ensure only one path can start the game if (this.startMutex || this.state === 'running') return; this.startMutex = true; this.state = 'running'; this.gameLoop.start(); - PerformanceMonitor.start(); // Start performance monitoring + PerformanceMonitor.start(); this.eventBus.emit('game:started', { tick: this.currentTick }); }; - // Show countdown visual - this is purely cosmetic - // The countdown uses a Web Worker for timing (not throttled in background tabs) this.eventBus.emit('game:countdown', { startTime: scheduledStartTime }); - // Schedule the actual game start at the predetermined time - // This uses setTimeout which works independently of the visual countdown const delayUntilStart = scheduledStartTime - Date.now(); - if (delayUntilStart <= 0) { - // Start time is in the past (e.g., tab was backgrounded) - start immediately - debugInitialization.log('[Game] Start time already passed - starting immediately'); + debugInitialization.log('[Game] Start time passed - starting immediately'); startGameLoop(); } else { - // Schedule game start at exact wall-clock time debugInitialization.log(`[Game] Scheduling game start in ${delayUntilStart}ms`); - setTimeout(() => { - startGameLoop(); - }, delayUntilStart); + setTimeout(() => startGameLoop(), delayUntilStart); } - // Also listen for countdown complete as a backup (in case setTimeout drifts) - // This ensures the game starts even if there's minor timing discrepancy this.eventBus.once('game:countdownComplete', () => { - debugInitialization.log('[Game] Countdown complete - starting game'); + debugInitialization.log('[Game] Countdown complete'); startGameLoop(); }); } @@ -570,80 +356,62 @@ export class Game { this.eventBus.emit('game:resumed', { tick: this.currentTick }); } - public stop(): void { + public override stop(): void { this.state = 'ended'; this.gameLoop.stop(); - PerformanceMonitor.stop(); // Stop performance monitoring + PerformanceMonitor.stop(); - // Notify remote player in multiplayer this.notifyQuit(); - // Clean up multiplayer message handler if (this.multiplayerMessageHandler) { removeMultiplayerMessageHandler(this.multiplayerMessageHandler); this.multiplayerMessageHandler = null; } this.eventBus.emit('game:ended', { tick: this.currentTick }); - - // Clean up all event listeners to prevent memory leaks and duplicate handlers - // This must be done AFTER emitting game:ended so handlers can respond to shutdown this.eventBus.clear(); - - // Reset command queue for clean restart this.commandQueue.clear(); - - // Reset start mutex so game can be started again this.startMutex = false; } + // ============================================================================ + // GAME UPDATE LOOP + // ============================================================================ + private update(deltaTime: number): void { if (this.state !== 'running') return; - // In multiplayer, pause if network is paused (disconnection/reconnection) if (this.config.isMultiplayer && isNetworkPaused()) { - // Don't advance game state while waiting for connection return; } - // In multiplayer, check for desync state if (this.config.isMultiplayer && getDesyncState() === 'desynced') { - // Game should end on desync - don't process any more ticks return; } const tickStart = performance.now(); this.currentTick++; - - // Set current tick for query cache invalidation this.world.setCurrentTick(this.currentTick); - // LOCKSTEP: Process any commands scheduled for this tick (before systems update) if (this.config.isMultiplayer) { - this.processQueuedCommands(); + this.processQueuedCommandsWithCleanup(); } - // Update all systems this.world.update(deltaTime); - // Emit tick event this.eventBus.emit('game:tick', { tick: this.currentTick, deltaTime, }); const tickElapsed = performance.now() - tickStart; - - // Record tick time for performance monitoring PerformanceMonitor.recordTickTime(tickElapsed); - // Update entity counts every 20 ticks (1 second at 20 tick/sec) if (this.currentTick % 20 === 0) { this.updateEntityCounts(); } - // Update adaptive command delay periodically in multiplayer if (this.config.isMultiplayer && this.currentTick % this.DELAY_RECALC_INTERVAL === 0) { this.updateAdaptiveCommandDelay(); } @@ -653,9 +421,6 @@ export class Game { } } - /** - * Update entity counts for performance monitoring - */ private updateEntityCounts(): void { const units = this.world.getEntitiesWith('Unit', 'Transform'); const buildings = this.world.getEntitiesWith('Building', 'Transform'); @@ -668,262 +433,14 @@ export class Game { buildings: buildings.length, resources: resources.length, projectiles: projectiles.length, - effects: 0, // Could add effect entity tracking if needed + effects: 0, }); } - public getState(): GameState { - return this.state; - } - - public getCurrentTick(): number { - return this.currentTick; - } - - public getGameTime(): number { - return this.currentTick / this.config.tickRate; - } - - /** - * Set the terrain grid for building placement validation - * Should be called after map is loaded - */ - public setTerrainGrid(terrain: TerrainCell[][]): void { - debugInitialization.log(`[Game] SET_TERRAIN_GRID: ${terrain[0]?.length}x${terrain.length}, pathfinding dimensions: ${this.config.mapWidth}x${this.config.mapHeight}`); - this.terrainGrid = terrain; - // Legacy call - navmesh is now initialized separately via initializeNavMesh - this.pathfindingSystem.loadTerrainData(); - } - - /** - * Initialize the navmesh for pathfinding from terrain walkable geometry. - * Should be called after terrain is created. - */ - public async initializeNavMesh( - positions: Float32Array, - indices: Uint32Array - ): Promise { - debugInitialization.log(`[Game] INITIALIZING_NAVMESH: ${positions.length / 3} vertices, ${indices.length / 3} triangles`); - return this.pathfindingSystem.initializeNavMesh(positions, indices); - } - - /** - * Initialize the water navmesh for naval unit pathfinding. - * Should be called after terrain is created if the map has water. - */ - public async initializeWaterNavMesh( - positions: Float32Array, - indices: Uint32Array - ): Promise { - debugInitialization.log(`[Game] INITIALIZING_WATER_NAVMESH: ${positions.length / 3} vertices, ${indices.length / 3} triangles`); - return this.pathfindingSystem.initializeWaterNavMesh(positions, indices); - } - - /** - * Set decoration collision data for building placement validation and pathfinding. - * Should be called after environment is loaded. - * Large decorations (radius > 1) will also block pathfinding cells. - */ - public setDecorationCollisions(collisions: Array<{ x: number; z: number; radius: number }>): void { - this.decorationCollisions = collisions; - - // Register large decorations with pathfinding system - // This prevents units from trying to path through rock formations - this.pathfindingSystem.registerDecorationCollisions(collisions); - } - - /** - * Get decoration collision data for building placement validation - */ - public getDecorationCollisions(): Array<{ x: number; z: number; radius: number }> { - return this.decorationCollisions; - } - - /** - * Check if a building position overlaps with decorations (rocks, trees) - */ - public isPositionClearOfDecorations(centerX: number, centerY: number, width: number, height: number): boolean { - const halfW = width / 2 + 0.5; // Small buffer - const halfH = height / 2 + 0.5; + // ============================================================================ + // COMMAND HANDLING (extends base with multiplayer features) + // ============================================================================ - for (const deco of this.decorationCollisions) { - // Check if decoration is within the building footprint - const dx = Math.abs(centerX - deco.x); - const dz = Math.abs(centerY - deco.z); - - if (dx < halfW + deco.radius && dz < halfH + deco.radius) { - return false; - } - } - - return true; - } - - /** - * Check if a building can be placed at the given position - * Returns true if all tiles under the building are walkable ground at the same elevation - */ - public isValidTerrainForBuilding(centerX: number, centerY: number, width: number, height: number): boolean { - if (!this.terrainGrid) { - // No terrain data - allow placement (legacy behavior) - return true; - } - - const halfWidth = width / 2; - const halfHeight = height / 2; - let requiredElevation: number | null = null; - - // Check all tiles the building would occupy - const firstRow = this.terrainGrid[0]; - if (!firstRow) return false; // Empty terrain grid - - for (let dy = -Math.floor(halfHeight); dy < Math.ceil(halfHeight); dy++) { - for (let dx = -Math.floor(halfWidth); dx < Math.ceil(halfWidth); dx++) { - const tileX = Math.floor(centerX + dx); - const tileY = Math.floor(centerY + dy); - - // Check bounds - if (tileY < 0 || tileY >= this.terrainGrid.length || - tileX < 0 || tileX >= firstRow.length) { - return false; - } - - const cell = this.terrainGrid[tileY][tileX]; - - // Must be buildable ground or platform (not unwalkable, ramp, or unbuildable) - if (cell.terrain !== 'ground' && cell.terrain !== 'platform') { - return false; - } - - // All tiles must be at the same elevation - if (requiredElevation === null) { - requiredElevation = cell.elevation; - } else if (cell.elevation !== requiredElevation) { - return false; - } - } - } - - return true; - } - - /** - * Check if a building can be placed at the given position - * This performs all validation checks: terrain, buildings, resources, units, and decorations - * Buildings can be placed directly adjacent to each other (no spacing buffer) - * - * PERF: Uses spatial grid queries instead of O(n) loops for buildings and units - */ - public isValidBuildingPlacement(centerX: number, centerY: number, width: number, height: number, excludeEntityId?: number, skipUnitCheck: boolean = false): boolean { - const halfW = width / 2; - const halfH = height / 2; - - // Check map bounds - if (centerX - halfW < 0 || centerY - halfH < 0 || - centerX + halfW > this.config.mapWidth || centerY + halfH > this.config.mapHeight) { - return false; - } - - // Check terrain validity (must be on ground, same elevation, not on ramps/cliffs) - if (!this.isValidTerrainForBuilding(centerX, centerY, width, height)) { - return false; - } - - // PERF: Use spatial grid query instead of iterating all buildings - // Query area slightly larger than building footprint to catch nearby entities - const queryPadding = 10; // Max building half-size plus buffer - const nearbyBuildingIds = this.world.buildingGrid.queryRect( - centerX - halfW - queryPadding, - centerY - halfH - queryPadding, - centerX + halfW + queryPadding, - centerY + halfH + queryPadding - ); - - // Check for overlapping buildings (no buffer - buildings can touch but not overlap) - for (const buildingId of nearbyBuildingIds) { - const entity = this.world.getEntity(buildingId); - if (!validateEntity(entity, buildingId, 'Game.isValidBuildingPlacement:building', this.currentTick)) continue; - - const transform = entity.get('Transform'); - const building = entity.get('Building'); - if (!transform || !building) continue; - - // Skip flying buildings - they don't block ground placement - if (building.isFlying || building.state === 'lifting' || - building.state === 'flying' || building.state === 'landing') { - continue; - } - - const existingHalfW = building.width / 2; - const existingHalfH = building.height / 2; - const dx = Math.abs(centerX - transform.x); - const dy = Math.abs(centerY - transform.y); - - // Buildings can be placed directly adjacent (touching) but not overlapping - if (dx < halfW + existingHalfW && dy < halfH + existingHalfH) { - return false; - } - } - - // Check for overlapping resources (typically ~10-50 per map, so O(n) is acceptable) - const resources = this.world.getEntitiesWith('Resource', 'Transform'); - for (const entity of resources) { - const transform = entity.get('Transform'); - if (!transform) continue; - - const dx = Math.abs(centerX - transform.x); - const dy = Math.abs(centerY - transform.y); - - if (dx < halfW + 1.5 && dy < halfH + 1.5) { - return false; - } - } - - // PERF: Use spatial grid query instead of iterating all units - // Skip unit check if requested (units will be pushed away after placement) - if (!skipUnitCheck) { - const nearbyUnitIds = this.world.unitGrid.queryRect( - centerX - halfW - 2, - centerY - halfH - 2, - centerX + halfW + 2, - centerY + halfH + 2 - ); - - // Check for overlapping units (exclude the builder worker) - for (const unitId of nearbyUnitIds) { - // Skip the worker who will build this structure - if (excludeEntityId !== undefined && unitId === excludeEntityId) { - continue; - } - - const entity = this.world.getEntity(unitId); - if (!validateEntity(entity, unitId, 'Game.isValidBuildingPlacement:unit', this.currentTick)) continue; - - const transform = entity.get('Transform'); - if (!transform) continue; - - const dx = Math.abs(centerX - transform.x); - const dy = Math.abs(centerY - transform.y); - - if (dx < halfW + 0.5 && dy < halfH + 0.5) { - return false; - } - } - } - - // Check for overlapping decorations (rocks, trees, etc.) - if (!this.isPositionClearOfDecorations(centerX, centerY, width, height)) { - return false; - } - - return true; - } - - /** - * Get the current command delay in ticks. - * In multiplayer, this is dynamically calculated based on measured RTT. - * In single player, returns the default. - */ public getCommandDelayTicks(): number { if (!this.config.isMultiplayer) { return this.DEFAULT_COMMAND_DELAY_TICKS; @@ -931,81 +448,46 @@ export class Game { return this.currentCommandDelay; } - /** - * Update the adaptive command delay based on current latency measurements. - * Called periodically during multiplayer games. - */ private updateAdaptiveCommandDelay(): void { if (!this.config.isMultiplayer) return; const newDelay = getAdaptiveCommandDelay(this.config.tickRate); - // Smooth transitions - don't change by more than 1 tick at a time if (newDelay > this.currentCommandDelay) { this.currentCommandDelay = Math.min(this.currentCommandDelay + 1, newDelay); } else if (newDelay < this.currentCommandDelay) { this.currentCommandDelay = Math.max(this.currentCommandDelay - 1, newDelay); } - - // Log significant changes - if (this.currentCommandDelay !== newDelay) { - const stats = getLatencyStats(); - debugNetworking.log( - `[Game] Adaptive command delay: ${this.currentCommandDelay} ticks ` + - `(RTT: ${stats.averageRTT.toFixed(1)}ms, target: ${newDelay} ticks)` - ); - } } - /** - * Issue a command from the local player. - * In multiplayer, commands are scheduled for a future tick (lockstep) to ensure - * both players execute the command at the same game tick. - * In single player, commands are processed immediately. - */ public issueCommand(command: GameCommand): void { if (isMultiplayerMode()) { - // LOCKSTEP: Schedule command for future tick so both players execute at same tick - // Use adaptive delay based on measured latency const executionTick = this.currentTick + this.currentCommandDelay; command.tick = executionTick; - // Send to remote player with the scheduled execution tick - const message: MultiplayerMessage = { + sendMultiplayerMessage({ type: 'command', payload: command, - }; - sendMultiplayerMessage(message); - debugNetworking.log('[Game] Sent command to remote for tick', executionTick, ':', command.type, `(delay: ${this.currentCommandDelay})`); + }); + debugNetworking.log('[Game] Sent command for tick', executionTick); - // Queue locally for execution at the scheduled tick - this.queueCommand(command); + this.queueCommandWithReceipt(command); } else { - // Single player: process immediately this.processCommand(command); } } - /** - * Queue a command for execution at a specific tick (lockstep multiplayer) - */ - private queueCommand(command: GameCommand): void { - const tick = command.tick; - if (!this.commandQueue.has(tick)) { - this.commandQueue.set(tick, []); - } - this.commandQueue.get(tick)!.push(command); + private queueCommandWithReceipt(command: GameCommand): void { + this.queueCommand(command); - // LOCKSTEP BARRIER: Track that we received a command from this player for this tick + // Track receipt for lockstep barrier + const tick = command.tick; if (!this.tickCommandReceipts.has(tick)) { this.tickCommandReceipts.set(tick, new Set()); } this.tickCommandReceipts.get(tick)!.add(command.playerId); } - /** - * Store executed command in history for sync responses - */ private recordExecutedCommand(command: GameCommand): void { const tick = command.tick; if (!this.executedCommandHistory.has(tick)) { @@ -1022,145 +504,23 @@ export class Game { } } - /** - * Request sync from the other player after reconnection. - * Call this after reconnecting to get missed commands. - */ - public requestSync(): void { - if (!this.config.isMultiplayer) return; - - debugNetworking.log('[Game] Requesting sync from current tick:', this.currentTick); - - this.awaitingSyncResponse = true; - this.syncRequestTick = this.currentTick; - - const message: MultiplayerMessage = { - type: 'sync-request', - payload: { - lastKnownTick: this.currentTick, - playerId: this.config.playerId, - } as SyncRequestPayload, - }; - sendMultiplayerMessage(message); - - // Pause until we get the sync response - useMultiplayerStore.getState().setNetworkPaused(true, 'Synchronizing game state...'); - } - - /** - * Handle sync request from reconnecting player. - * Send them all commands since their last known tick. - */ - private handleSyncRequest(request: SyncRequestPayload): void { - const commandsToSend: Array<{ tick: number; commands: GameCommand[] }> = []; - - // Collect all commands from their last known tick to now - for (let tick = request.lastKnownTick + 1; tick <= this.currentTick; tick++) { - const commands = this.executedCommandHistory.get(tick); - if (commands && commands.length > 0) { - commandsToSend.push({ tick, commands }); - } - } - - debugNetworking.log( - '[Game] Sending sync response with', - commandsToSend.length, - 'tick entries from tick', - request.lastKnownTick + 1, - 'to', - this.currentTick - ); - - const response: MultiplayerMessage = { - type: 'sync-response', - payload: { - currentTick: this.currentTick, - commands: commandsToSend, - playerId: this.config.playerId, - } as SyncResponsePayload, - }; - sendMultiplayerMessage(response); - } - - /** - * Handle sync response when we're reconnecting. - * Replay all missed commands to catch up to current state. - */ - private handleSyncResponse(response: SyncResponsePayload): void { - if (!this.awaitingSyncResponse) { - debugNetworking.warn('[Game] Received unexpected sync response, ignoring'); + public override processCommand(command: GameCommand): void { + // Validate authorization in multiplayer + if (this.config.isMultiplayer && !this.validateCommandAuthorization(command)) { return; } - this.awaitingSyncResponse = false; - - debugNetworking.log( - '[Game] Processing sync response: fast-forwarding from tick', - this.currentTick, - 'to tick', - response.currentTick - ); - - // Queue all the missed commands - let totalCommands = 0; - for (const { tick, commands } of response.commands) { - for (const command of commands) { - // Queue for immediate execution if tick has passed, otherwise queue normally - if (tick <= this.currentTick) { - // Execute immediately for past ticks - this.processCommand(command); - totalCommands++; - } else { - // Queue for future execution - this.queueCommand(command); - totalCommands++; - } - } + if (this.config.isMultiplayer) { + this.recordExecutedCommand(command); } - debugNetworking.log('[Game] Sync complete: processed', totalCommands, 'commands'); - - // Resume game - useMultiplayerStore.getState().setNetworkPaused(false); - - // Emit sync complete event - this.eventBus.emit('multiplayer:syncComplete', { - fromTick: this.syncRequestTick, - toTick: response.currentTick, - commandsReplayed: totalCommands, - }); - } - - /** - * Get the list of all active players in the multiplayer game. - * In 2-player P2P mode, this is local player + remote player. - */ - private getActivePlayerIds(): string[] { - const playerIds: string[] = [this.config.playerId]; - const remotePeerId = useMultiplayerStore.getState().remotePeerId; - if (remotePeerId) { - playerIds.push(remotePeerId); - } - return playerIds; + super.processCommand(command); } - /** - * LOCKSTEP BARRIER: Track when we first started waiting for a tick - * Format: Map - */ - private tickWaitStart: Map = new Map(); - - /** - * Process all commands scheduled for the current tick - * LOCKSTEP SYNC: Processes commands from all players in deterministic order. - * Desync detection is handled via checksums (ChecksumSystem) rather than - * blocking on command arrival, since players may have no-op ticks. - */ - private processQueuedCommands(): void { - // Process commands for the current tick + private processQueuedCommandsWithCleanup(): void { + // Process commands for current tick const commands = this.commandQueue.get(this.currentTick); if (commands) { - // Sort by player ID for deterministic ordering across all clients commands.sort((a, b) => a.playerId.localeCompare(b.playerId)); for (const command of commands) { this.processCommand(command); @@ -1168,11 +528,9 @@ export class Game { this.commandQueue.delete(this.currentTick); } - // Clean up receipts for this tick this.tickCommandReceipts.delete(this.currentTick); - // CRITICAL FIX: Stale commands trigger desync instead of silent drop - // Stale commands indicate a timing/synchronization failure + // Handle stale commands const staleTicks: number[] = []; for (const tick of this.commandQueue.keys()) { if (tick < this.currentTick) { @@ -1181,15 +539,10 @@ export class Game { } for (const tick of staleTicks) { const staleCommands = this.commandQueue.get(tick); - // CRITICAL: Stale commands indicate a synchronization failure - // This should never happen if networking is working correctly console.error( - `[Game] CRITICAL: Stale commands detected for tick ${tick} ` + - `(current: ${this.currentTick}). Commands from: ${ - staleCommands?.map(c => c.playerId).join(', ') || 'none' - }. This indicates desync!` + `[Game] CRITICAL: Stale commands for tick ${tick} ` + + `(current: ${this.currentTick}). Commands from: ${staleCommands?.map(c => c.playerId).join(', ')}` ); - // Report desync instead of silently dropping reportDesync(tick); this.eventBus.emit('desync:detected', { tick, @@ -1198,12 +551,11 @@ export class Game { remotePeerId: useMultiplayerStore.getState().remotePeerId || 'unknown', reason: 'stale_commands', }); - // Clean up this.commandQueue.delete(tick); this.tickCommandReceipts.delete(tick); } - // Clean up old wait tracking entries + // Cleanup wait tracking for (const tick of this.tickWaitStart.keys()) { if (tick <= this.currentTick) { this.tickWaitStart.delete(tick); @@ -1211,62 +563,29 @@ export class Game { } } - /** - * Notify remote player that we're quitting - */ - public notifyQuit(): void { - if (isMultiplayerMode()) { - const message: MultiplayerMessage = { - type: 'quit', - payload: { playerId: this.config.playerId }, - }; - sendMultiplayerMessage(message); - debugNetworking.log('[Game] Sent quit notification'); - } - } - - /** - * Validate that the command's playerId owns all entities in entityIds. - * Returns true if valid, false if authorization fails. - * Commands without entityIds (like BUILD with targetPosition) are always valid. - */ private validateCommandAuthorization(command: GameCommand): boolean { - // Commands that don't require entity ownership validation - const noEntityValidationCommands: GameCommand['type'][] = [ - 'BUILD', 'BUILD_WALL', // Building placement uses targetPosition - ]; + const noEntityValidationCommands: GameCommand['type'][] = ['BUILD', 'BUILD_WALL']; if (noEntityValidationCommands.includes(command.type)) { return true; } - // If no entities specified, command is valid if (!command.entityIds || command.entityIds.length === 0) { return true; } - // Validate each entity is owned by the command's playerId for (const entityId of command.entityIds) { const entity = this.world.getEntity(entityId); - if (!entity) { - // Entity doesn't exist - could be destroyed, skip validation - continue; - } + if (!entity) continue; const selectable = entity.get('Selectable'); - if (!selectable) { - // Entity has no owner - skip validation (resources, etc.) - continue; - } + if (!selectable) continue; - // CRITICAL: Verify ownership if (selectable.playerId !== command.playerId) { console.error( - `[Game] COMMAND AUTHORIZATION FAILED: Player ${command.playerId} ` + - `attempted to control entity ${entityId} owned by ${selectable.playerId}. ` + - `Command type: ${command.type}` + `[Game] AUTHORIZATION FAILED: Player ${command.playerId} ` + + `tried to control entity ${entityId} owned by ${selectable.playerId}` ); - // Emit security event for monitoring this.eventBus.emit('security:unauthorizedCommand', { playerId: command.playerId, entityId, @@ -1278,15 +597,11 @@ export class Game { } } - // Also validate special fields that reference entities + // Validate special entity references if (command.transportId !== undefined) { const transport = this.world.getEntity(command.transportId); const selectable = transport?.get('Selectable'); if (selectable && selectable.playerId !== command.playerId) { - console.error( - `[Game] Transport ownership mismatch: entity ${command.transportId} ` + - `belongs to player ${selectable.playerId}, not ${command.playerId}` - ); return false; } } @@ -1295,10 +610,6 @@ export class Game { const bunker = this.world.getEntity(command.bunkerId); const selectable = bunker?.get('Selectable'); if (selectable && selectable.playerId !== command.playerId) { - console.error( - `[Game] Bunker ownership mismatch: entity ${command.bunkerId} ` + - `belongs to player ${selectable.playerId}, not ${command.playerId}` - ); return false; } } @@ -1307,10 +618,6 @@ export class Game { const building = this.world.getEntity(command.buildingId); const selectable = building?.get('Selectable'); if (selectable && selectable.playerId !== command.playerId) { - console.error( - `[Game] Building ownership mismatch: entity ${command.buildingId} ` + - `belongs to player ${selectable.playerId}, not ${command.playerId}` - ); return false; } } @@ -1318,21 +625,119 @@ export class Game { return true; } - // Command processing for multiplayer lockstep - public processCommand(command: GameCommand): void { - // SECURITY: Validate command authorization before processing - if (this.config.isMultiplayer && !this.validateCommandAuthorization(command)) { - // Reject unauthorized commands - do not process + // ============================================================================ + // SYNC HANDLING + // ============================================================================ + + public requestSync(): void { + if (!this.config.isMultiplayer) return; + + debugNetworking.log('[Game] Requesting sync from tick:', this.currentTick); + + this.awaitingSyncResponse = true; + this.syncRequestTick = this.currentTick; + + sendMultiplayerMessage({ + type: 'sync-request', + payload: { + lastKnownTick: this.currentTick, + playerId: this.config.playerId, + } as SyncRequestPayload, + }); + + useMultiplayerStore.getState().setNetworkPaused(true, 'Synchronizing game state...'); + } + + private handleSyncRequest(request: SyncRequestPayload): void { + const commandsToSend: Array<{ tick: number; commands: GameCommand[] }> = []; + + for (let tick = request.lastKnownTick + 1; tick <= this.currentTick; tick++) { + const commands = this.executedCommandHistory.get(tick); + if (commands && commands.length > 0) { + commandsToSend.push({ tick, commands }); + } + } + + debugNetworking.log('[Game] Sending sync response with', commandsToSend.length, 'tick entries'); + + sendMultiplayerMessage({ + type: 'sync-response', + payload: { + currentTick: this.currentTick, + commands: commandsToSend, + playerId: this.config.playerId, + } as SyncResponsePayload, + }); + } + + private handleSyncResponse(response: SyncResponsePayload): void { + if (!this.awaitingSyncResponse) { + debugNetworking.warn('[Game] Unexpected sync response'); return; } - // Record command in history for sync responses (multiplayer only) - if (this.config.isMultiplayer) { - this.recordExecutedCommand(command); + this.awaitingSyncResponse = false; + + let totalCommands = 0; + for (const { tick, commands } of response.commands) { + for (const command of commands) { + if (tick <= this.currentTick) { + this.processCommand(command); + } else { + this.queueCommand(command); + } + totalCommands++; + } + } + + debugNetworking.log('[Game] Sync complete:', totalCommands, 'commands'); + + useMultiplayerStore.getState().setNetworkPaused(false); + + this.eventBus.emit('multiplayer:syncComplete', { + fromTick: this.syncRequestTick, + toTick: response.currentTick, + commandsReplayed: totalCommands, + }); + } + + public notifyQuit(): void { + if (isMultiplayerMode()) { + sendMultiplayerMessage({ + type: 'quit', + payload: { playerId: this.config.playerId }, + }); + debugNetworking.log('[Game] Sent quit notification'); } + } - // Dispatch command to appropriate event handlers via shared dispatcher - dispatchCommand(this.eventBus, command); + // ============================================================================ + // MAIN-THREAD-ONLY: TERRAIN WITH LOGGING + // ============================================================================ + + public override setTerrainGrid(terrain: TerrainCell[][]): void { + debugInitialization.log(`[Game] SET_TERRAIN_GRID: ${terrain[0]?.length}x${terrain.length}`); + super.setTerrainGrid(terrain); + } + + // ============================================================================ + // MAIN-THREAD-ONLY: NAVMESH WITH LOGGING + // ============================================================================ + + public override async initializeNavMesh( + positions: Float32Array, + indices: Uint32Array + ): Promise { + debugInitialization.log(`[Game] INITIALIZING_NAVMESH: ${positions.length / 3} vertices`); + return super.initializeNavMesh(positions, indices); + } + + public override async initializeWaterNavMesh( + positions: Float32Array, + indices: Uint32Array + ): Promise { + debugInitialization.log(`[Game] INITIALIZING_WATER_NAVMESH: ${positions.length / 3} vertices`); + return super.initializeWaterNavMesh(positions, indices); } } diff --git a/src/engine/core/GameCore.ts b/src/engine/core/GameCore.ts new file mode 100644 index 00000000..22dbf8d1 --- /dev/null +++ b/src/engine/core/GameCore.ts @@ -0,0 +1,536 @@ +/** + * GameCore - Abstract base class for game instances + * + * Provides shared game logic used by both: + * - Game.ts (main thread: selection, audio, UI) + * - WorkerGame (web worker: simulation, AI, anti-throttling) + * + * This eliminates code duplication and ensures terrain validation, + * building placement, and command processing stay synchronized. + */ + +import { World } from '../ecs/World'; +import { EventBus } from '../core/EventBus'; +import { SystemRegistry } from '../core/SystemRegistry'; +import { bootstrapDefinitions } from '../definitions'; + +// Systems with direct references +import { VisionSystem } from '../systems/VisionSystem'; +import { GameStateSystem } from '../systems/GameStateSystem'; +import { SaveLoadSystem } from '../systems/SaveLoadSystem'; +import { PathfindingSystem } from '../systems/PathfindingSystem'; +import { AIMicroSystem } from '../systems/AIMicroSystem'; +import { ChecksumSystem } from '../systems/ChecksumSystem'; +import { ProjectileSystem } from '../systems/ProjectileSystem'; + +// Components for validation +import { Transform } from '../components/Transform'; +import { Building } from '../components/Building'; +import { Selectable } from '../components/Selectable'; + +import { validateEntity } from '@/utils/EntityValidator'; +import { dispatchCommand, type GameCommand } from './GameCommand'; + +export type GameState = 'initializing' | 'running' | 'paused' | 'ended'; + +export interface TerrainCell { + terrain: 'ground' | 'platform' | 'unwalkable' | 'ramp' | 'unbuildable' | 'creep'; + elevation: number; + feature?: 'none' | 'water_shallow' | 'water_deep' | 'forest_light' | 'forest_dense' | 'mud' | 'road' | 'void' | 'cliff'; +} + +export interface GameConfig { + mapWidth: number; + mapHeight: number; + tickRate: number; + isMultiplayer: boolean; + playerId: string; + aiEnabled: boolean; + aiDifficulty: 'easy' | 'medium' | 'hard' | 'insane'; +} + +export const DEFAULT_CONFIG: GameConfig = { + mapWidth: 128, + mapHeight: 128, + tickRate: 20, + isMultiplayer: false, + playerId: 'player1', + aiEnabled: true, + aiDifficulty: 'medium', +}; + +/** + * Abstract base class providing shared game logic. + * Subclasses implement thread-specific behavior (main thread vs worker). + */ +export abstract class GameCore { + // ============================================================================ + // CORE SYSTEMS (shared between main thread and worker) + // ============================================================================ + + public world: World; + public eventBus: EventBus; + public config: GameConfig; + + // Direct system references - initialized in constructor before initializeSystems() + public visionSystem: VisionSystem; + public gameStateSystem: GameStateSystem; + public saveLoadSystem: SaveLoadSystem; + public pathfindingSystem: PathfindingSystem; + public aiMicroSystem: AIMicroSystem; + public checksumSystem: ChecksumSystem | null = null; + + // System assigned during initializeSystems() + protected _projectileSystem: ProjectileSystem | null = null; + + /** + * Get the ProjectileSystem instance. + * @throws Error if accessed before system initialization + */ + public get projectileSystem(): ProjectileSystem { + if (!this._projectileSystem) { + throw new Error('[GameCore] ProjectileSystem accessed before initialization'); + } + return this._projectileSystem; + } + + // ============================================================================ + // TERRAIN DATA + // ============================================================================ + + protected terrainGrid: TerrainCell[][] | null = null; + protected decorationCollisions: Array<{ x: number; z: number; radius: number }> = []; + + // ============================================================================ + // GAME STATE + // ============================================================================ + + protected state: GameState = 'initializing'; + protected currentTick = 0; + + // ============================================================================ + // COMMAND QUEUE + // ============================================================================ + + protected commandQueue: Map = new Map(); + + // ============================================================================ + // CONSTRUCTOR + // ============================================================================ + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + + // Bootstrap definition registry from TypeScript data + bootstrapDefinitions(); + + // Initialize ECS World with map dimensions + this.world = new World(this.config.mapWidth, this.config.mapHeight); + this.eventBus = new EventBus(); + + // Pre-create systems that need direct references + // Using 'this as any' to satisfy Game type requirement (GameCore is compatible) + this.visionSystem = new VisionSystem(this as any, this.config.mapWidth, this.config.mapHeight); + this.gameStateSystem = new GameStateSystem(this as any); + this.saveLoadSystem = new SaveLoadSystem(this as any); + this.pathfindingSystem = new PathfindingSystem(this as any, this.config.mapWidth, this.config.mapHeight); + this.aiMicroSystem = new AIMicroSystem(this as any); + + // Initialize checksum system for multiplayer + if (this.config.isMultiplayer) { + this.checksumSystem = new ChecksumSystem(this as any, { + checksumInterval: 5, + emitNetworkChecksums: true, + logChecksums: false, + autoDumpOnDesync: true, + }); + } + } + + // ============================================================================ + // ABSTRACT METHODS (implemented by subclasses) + // ============================================================================ + + /** + * Get system definitions for this game instance. + * Main thread includes SelectionSystem + AudioSystem. + * Worker excludes them (not available in worker context). + * Returns any[] to allow different definition formats between threads. + */ + protected abstract getSystemDefinitions(): any[]; + + /** + * Start the game loop. + */ + public abstract start(gameStartTime?: number): void; + + /** + * Stop the game. + */ + public abstract stop(): void; + + // ============================================================================ + // SYSTEM INITIALIZATION (shared logic) + // ============================================================================ + + /** + * Initialize systems using dependency-based ordering. + * Subclasses can override to add thread-specific setup. + */ + protected initializeSystems(): void { + const registry = new SystemRegistry(); + registry.registerAll(this.getSystemDefinitions()); + + // Validate dependencies at startup + const errors = registry.validate(); + if (errors.length > 0) { + console.error('[GameCore] System dependency errors:', errors); + throw new Error(`Invalid system dependencies:\n${errors.join('\n')}`); + } + + // Create systems in dependency order + const systems = registry.createSystems(this as any); + + // Add all systems to world and capture references + for (const system of systems) { + this.world.addSystem(system); + this.onSystemCreated(system); + } + } + + /** + * Hook for subclasses to capture system references. + * Called for each system after creation. + */ + protected onSystemCreated(system: any): void { + if (system.name === 'ProjectileSystem') { + this._projectileSystem = system as ProjectileSystem; + } + } + + // ============================================================================ + // TERRAIN METHODS (shared implementation) + // ============================================================================ + + /** + * Get the terrain grid (read-only access for systems) + */ + public getTerrainGrid(): TerrainCell[][] | null { + return this.terrainGrid; + } + + /** + * Get terrain cell at a specific world position + */ + public getTerrainAt(worldX: number, worldY: number): TerrainCell | null { + if (!this.terrainGrid || this.terrainGrid.length === 0) return null; + + const gridX = Math.floor(worldX); + const gridY = Math.floor(worldY); + + const firstRow = this.terrainGrid[0]; + if (!firstRow || gridY < 0 || gridY >= this.terrainGrid.length || + gridX < 0 || gridX >= firstRow.length) { + return null; + } + + return this.terrainGrid[gridY][gridX]; + } + + /** + * Get terrain height at a specific world position. + * Converts elevation (0-255) to world height units. + * Returns 0 if terrain data is not available. + */ + public getTerrainHeightAt(worldX: number, worldY: number): number { + const cell = this.getTerrainAt(worldX, worldY); + if (!cell) return 0; + + // Convert elevation to height using same formula as Terrain.ts + // elevation * 0.04 gives range 0 to ~10.2 units + return cell.elevation * 0.04; + } + + /** + * Set the terrain grid for building placement validation. + * Should be called after map is loaded. + */ + public setTerrainGrid(terrain: TerrainCell[][]): void { + this.terrainGrid = terrain; + this.pathfindingSystem.loadTerrainData(); + } + + /** + * Set decoration collision data for building placement validation and pathfinding. + * Should be called after environment is loaded. + */ + public setDecorationCollisions(collisions: Array<{ x: number; z: number; radius: number }>): void { + this.decorationCollisions = collisions; + this.pathfindingSystem.registerDecorationCollisions(collisions); + } + + /** + * Get decoration collision data for building placement validation + */ + public getDecorationCollisions(): Array<{ x: number; z: number; radius: number }> { + return this.decorationCollisions; + } + + // ============================================================================ + // NAVMESH METHODS (shared implementation) + // ============================================================================ + + /** + * Initialize the navmesh for pathfinding from terrain walkable geometry. + */ + public async initializeNavMesh( + positions: Float32Array, + indices: Uint32Array + ): Promise { + return this.pathfindingSystem.initializeNavMesh(positions, indices); + } + + /** + * Initialize the water navmesh for naval unit pathfinding. + */ + public async initializeWaterNavMesh( + positions: Float32Array, + indices: Uint32Array + ): Promise { + return this.pathfindingSystem.initializeWaterNavMesh(positions, indices); + } + + // ============================================================================ + // BUILDING PLACEMENT VALIDATION (shared implementation) + // ============================================================================ + + /** + * Check if a building position overlaps with decorations (rocks, trees) + */ + public isPositionClearOfDecorations(centerX: number, centerY: number, width: number, height: number): boolean { + const halfW = width / 2 + 0.5; + const halfH = height / 2 + 0.5; + + for (const deco of this.decorationCollisions) { + const dx = Math.abs(centerX - deco.x); + const dz = Math.abs(centerY - deco.z); + + if (dx < halfW + deco.radius && dz < halfH + deco.radius) { + return false; + } + } + + return true; + } + + /** + * Check if terrain is valid for building placement. + * Returns true if all tiles are buildable ground at the same elevation. + */ + public isValidTerrainForBuilding(centerX: number, centerY: number, width: number, height: number): boolean { + if (!this.terrainGrid) { + // No terrain data - allow placement (legacy behavior) + return true; + } + + const halfWidth = width / 2; + const halfHeight = height / 2; + let requiredElevation: number | null = null; + + const firstRow = this.terrainGrid[0]; + if (!firstRow) return false; + + for (let dy = -Math.floor(halfHeight); dy < Math.ceil(halfHeight); dy++) { + for (let dx = -Math.floor(halfWidth); dx < Math.ceil(halfWidth); dx++) { + const tileX = Math.floor(centerX + dx); + const tileY = Math.floor(centerY + dy); + + if (tileY < 0 || tileY >= this.terrainGrid.length || + tileX < 0 || tileX >= firstRow.length) { + return false; + } + + const cell = this.terrainGrid[tileY][tileX]; + + if (cell.terrain !== 'ground' && cell.terrain !== 'platform') { + return false; + } + + if (requiredElevation === null) { + requiredElevation = cell.elevation; + } else if (cell.elevation !== requiredElevation) { + return false; + } + } + } + + return true; + } + + /** + * Check if a building can be placed at the given position. + * Performs all validation: terrain, buildings, resources, units, and decorations. + */ + public isValidBuildingPlacement( + centerX: number, + centerY: number, + width: number, + height: number, + excludeEntityId?: number, + skipUnitCheck: boolean = false + ): boolean { + const halfW = width / 2; + const halfH = height / 2; + + // Check map bounds + if (centerX - halfW < 0 || centerY - halfH < 0 || + centerX + halfW > this.config.mapWidth || centerY + halfH > this.config.mapHeight) { + return false; + } + + // Check terrain validity + if (!this.isValidTerrainForBuilding(centerX, centerY, width, height)) { + return false; + } + + // Check for overlapping buildings using spatial grid + const queryPadding = 10; + const nearbyBuildingIds = this.world.buildingGrid.queryRect( + centerX - halfW - queryPadding, + centerY - halfH - queryPadding, + centerX + halfW + queryPadding, + centerY + halfH + queryPadding + ); + + for (const buildingId of nearbyBuildingIds) { + const entity = this.world.getEntity(buildingId); + if (!validateEntity(entity, buildingId, 'GameCore.isValidBuildingPlacement:building', this.currentTick)) continue; + + const transform = entity.get('Transform'); + const building = entity.get('Building'); + if (!transform || !building) continue; + + // Skip flying buildings + if (building.isFlying || building.state === 'lifting' || + building.state === 'flying' || building.state === 'landing') { + continue; + } + + const existingHalfW = building.width / 2; + const existingHalfH = building.height / 2; + const dx = Math.abs(centerX - transform.x); + const dy = Math.abs(centerY - transform.y); + + if (dx < halfW + existingHalfW && dy < halfH + existingHalfH) { + return false; + } + } + + // Check for overlapping resources + const resources = this.world.getEntitiesWith('Resource', 'Transform'); + for (const entity of resources) { + const transform = entity.get('Transform'); + if (!transform) continue; + + const dx = Math.abs(centerX - transform.x); + const dy = Math.abs(centerY - transform.y); + + if (dx < halfW + 1.5 && dy < halfH + 1.5) { + return false; + } + } + + // Check for overlapping units + if (!skipUnitCheck) { + const nearbyUnitIds = this.world.unitGrid.queryRect( + centerX - halfW - 2, + centerY - halfH - 2, + centerX + halfW + 2, + centerY + halfH + 2 + ); + + for (const unitId of nearbyUnitIds) { + if (excludeEntityId !== undefined && unitId === excludeEntityId) { + continue; + } + + const entity = this.world.getEntity(unitId); + if (!validateEntity(entity, unitId, 'GameCore.isValidBuildingPlacement:unit', this.currentTick)) continue; + + const transform = entity.get('Transform'); + if (!transform) continue; + + const dx = Math.abs(centerX - transform.x); + const dy = Math.abs(centerY - transform.y); + + if (dx < halfW + 0.5 && dy < halfH + 0.5) { + return false; + } + } + } + + // Check for overlapping decorations + if (!this.isPositionClearOfDecorations(centerX, centerY, width, height)) { + return false; + } + + return true; + } + + // ============================================================================ + // GAME STATE METHODS (shared implementation) + // ============================================================================ + + public getState(): GameState { + return this.state; + } + + public getCurrentTick(): number { + return this.currentTick; + } + + public getGameTime(): number { + return this.currentTick / this.config.tickRate; + } + + // ============================================================================ + // COMMAND PROCESSING (shared implementation) + // ============================================================================ + + /** + * Queue a command for execution at a specific tick (lockstep multiplayer) + */ + protected queueCommand(command: GameCommand): void { + const tick = command.tick; + if (!this.commandQueue.has(tick)) { + this.commandQueue.set(tick, []); + } + this.commandQueue.get(tick)!.push(command); + } + + /** + * Process a command via the event bus dispatcher. + * Subclasses may override to add authorization checks. + */ + public processCommand(command: GameCommand): void { + dispatchCommand(this.eventBus, command); + } + + /** + * Process all commands scheduled for the current tick. + * Sorts by player ID for deterministic ordering. + */ + protected processQueuedCommands(): void { + const commands = this.commandQueue.get(this.currentTick); + if (!commands) return; + + // Sort by player ID for determinism + commands.sort((a, b) => a.playerId.localeCompare(b.playerId)); + + for (const command of commands) { + this.processCommand(command); + } + + this.commandQueue.delete(this.currentTick); + } +} diff --git a/src/engine/ecs/Entity.ts b/src/engine/ecs/Entity.ts index 179e55ff..58fd29e5 100644 --- a/src/engine/ecs/Entity.ts +++ b/src/engine/ecs/Entity.ts @@ -37,20 +37,9 @@ export class Entity { } const type = component.type; - - // TEMP: Debug component addition for units - if (type === 'Unit' && this.id > 200) { - console.log(`[Entity] add: entityId=${this.id}, componentType='${type}', componentsSize=${this.components.size}`); - } - this.components.set(type, component); this.world.onComponentAdded(this.id, type); - // TEMP: Verify component was added - if (type === 'Unit' && this.id > 200) { - console.log(`[Entity] add complete: entityId=${this.id}, has('Unit')=${this.components.has('Unit')}, componentsSize=${this.components.size}`); - } - return this; } diff --git a/src/engine/ecs/World.ts b/src/engine/ecs/World.ts index 83eb4ed3..7abd9719 100644 --- a/src/engine/ecs/World.ts +++ b/src/engine/ecs/World.ts @@ -143,7 +143,6 @@ export class World { */ private updateEntityArchetype(entityId: EntityId, entity: Entity): void { // Remove from old archetype - const oldSignature = this.entityArchetype.get(entityId); this.removeEntityFromArchetype(entityId); // Get entity's current component types @@ -152,7 +151,7 @@ export class World { if (this._componentBuffer.length === 0) { // Entity has no components - don't add to any archetype - console.warn(`[World] updateEntityArchetype: Entity ${entityId} has no components!`); + debugInitialization.warn(`[World] updateEntityArchetype: Entity ${entityId} has no components`); return; } @@ -160,11 +159,6 @@ export class World { this._componentBuffer.sort(); const signature = this._componentBuffer.join(','); - // TEMP: Debug for Unit component - if (signature.includes('Unit')) { - console.log(`[World] updateEntityArchetype: entityId=${entityId}, oldSig=${oldSignature || 'none'}, newSig=${signature}, components=${this._componentBuffer.length}`); - } - // Get or create archetype let archetype = this.archetypes.get(signature); if (!archetype) { @@ -174,22 +168,12 @@ export class World { entities: new Set(), }; this.archetypes.set(signature, archetype); - - // TEMP: Debug new archetype creation - if (signature.includes('Unit')) { - console.log(`[World] Created new archetype: ${signature}, componentSet has Unit=${archetype.componentSet.has('Unit')}`); - } } // Add entity to archetype archetype.entities.add(entityId); this.entityArchetype.set(entityId, signature); this.archetypeCacheVersion++; - - // TEMP: Verify entity was added - if (signature.includes('Unit')) { - console.log(`[World] Entity ${entityId} added to archetype ${signature}, archetype.entities.size=${archetype.entities.size}`); - } } /** @@ -306,12 +290,6 @@ export class World { const result: Entity[] = []; const requiredSet = new Set(componentTypes); - // TEMP: Debug Unit queries - const isUnitQuery = requiredSet.has('Unit'); - if (isUnitQuery) { - console.log(`[World] getEntitiesWith('Unit'...): Searching ${this.archetypes.size} archetypes, cacheVersion=${this.archetypeCacheVersion}`); - } - for (const archetype of this.archetypes.values()) { // Check if archetype contains all required components let hasAll = true; @@ -323,11 +301,6 @@ export class World { } if (hasAll) { - // TEMP: Debug matching archetypes - if (isUnitQuery) { - console.log(`[World] Archetype ${archetype.signature} matches, entities=${archetype.entities.size}`); - } - // Add all entities from this archetype for (const entityId of archetype.entities) { // Archetypes store full EntityIds, but entities Map is keyed by index @@ -339,11 +312,6 @@ export class World { } } - // TEMP: Debug final result - if (isUnitQuery) { - console.log(`[World] getEntitiesWith('Unit'...): Found ${result.length} entities`); - } - // Cache the result this.queryCache.set(cacheKey, result); @@ -410,15 +378,10 @@ export class World { const index = getEntityIndex(entityId); const entity = this.entities.get(index); - // TEMP: Debug archetype registration - if (type === 'Unit') { - console.log(`[World] onComponentAdded: entityId=${entityId}, index=${index}, type=${type}, entityFound=${!!entity}`); - } - if (entity) { this.updateEntityArchetype(entityId, entity); } else { - console.error(`[World] onComponentAdded: Entity not found for id=${entityId}, index=${index}`); + debugInitialization.error(`[World] onComponentAdded: Entity not found for id=${entityId}, index=${index}`); } } diff --git a/src/engine/workers/GameWorker.ts b/src/engine/workers/GameWorker.ts index 8fd94cf2..025303d5 100644 --- a/src/engine/workers/GameWorker.ts +++ b/src/engine/workers/GameWorker.ts @@ -14,13 +14,8 @@ * - Sends: RenderState snapshots, game events for audio/effects */ -// Debug flag for worker logging (workers can't access UI store) -const DEBUG = true; // TEMP: Enable for render state debugging - -import { World } from '../ecs/World'; -import { EventBus } from '../core/EventBus'; -import { SystemRegistry } from '../core/SystemRegistry'; -import { bootstrapDefinitions } from '../definitions'; +import { GameCore, GameConfig } from '../core/GameCore'; +import { debugInitialization, debugPerformance } from '@/utils/debugLogger'; // Components import { Transform } from '../components/Transform'; @@ -31,12 +26,10 @@ import { Health } from '../components/Health'; import { Selectable } from '../components/Selectable'; import { Projectile } from '../components/Projectile'; import { Velocity } from '../components/Velocity'; -import { validateEntity } from '@/utils/EntityValidator'; // Systems (worker-safe) import { SpawnSystem } from '../systems/SpawnSystem'; import { BuildingPlacementSystem } from '../systems/BuildingPlacementSystem'; -import { PathfindingSystem } from '../systems/PathfindingSystem'; import { BuildingMechanicsSystem } from '../systems/BuildingMechanicsSystem'; import { WallSystem } from '../systems/WallSystem'; import { UnitMechanicsSystem } from '../systems/UnitMechanicsSystem'; @@ -47,13 +40,8 @@ import { ProductionSystem } from '../systems/ProductionSystem'; import { ResourceSystem } from '../systems/ResourceSystem'; import { ResearchSystem } from '../systems/ResearchSystem'; import { AbilitySystem } from '../systems/AbilitySystem'; -import { VisionSystem } from '../systems/VisionSystem'; -import { GameStateSystem } from '../systems/GameStateSystem'; -import { SaveLoadSystem } from '../systems/SaveLoadSystem'; -import { EnhancedAISystem, AIDifficulty } from '../systems/EnhancedAISystem'; +import { EnhancedAISystem } from '../systems/EnhancedAISystem'; import { AIEconomySystem } from '../systems/AIEconomySystem'; -import { AIMicroSystem } from '../systems/AIMicroSystem'; -import { ChecksumSystem } from '../systems/ChecksumSystem'; // Types import type { @@ -67,54 +55,29 @@ import type { ProjectileRenderState, SpawnMapData, } from './types'; -import type { GameConfig, GameState, TerrainCell } from '../core/Game'; -import { dispatchCommand, type GameCommand } from '../core/GameCommand'; +import type { GameState, TerrainCell } from '../core/GameCore'; +import type { GameCommand } from '../core/GameCommand'; import { setWorkerDebugSettings } from '@/utils/debugLogger'; +// Re-export types for backwards compatibility +export type { GameState, TerrainCell }; + // ============================================================================ // WORKER GAME CLASS // ============================================================================ /** - * WorkerGame - Self-contained game instance running in a Web Worker + * WorkerGame - Game instance running in a Web Worker * - * This is a stripped-down version of Game.ts that runs entirely in the worker - * without any main-thread dependencies (no Three.js, no Zustand, no Web Audio). + * Extends GameCore with worker-specific features: + * - setInterval-based game loop (NOT throttled in workers!) + * - Render state collection and postMessage + * - Event forwarding to main thread for audio/effects + * + * This is where the actual game simulation runs. */ -export class WorkerGame { - public world: World; - public eventBus: EventBus; - public config: GameConfig; - - // Direct system references (initialized in constructor before initializeSystems) - public visionSystem: VisionSystem; - public gameStateSystem: GameStateSystem; - public saveLoadSystem: SaveLoadSystem; - public pathfindingSystem: PathfindingSystem; - public aiMicroSystem: AIMicroSystem; - public checksumSystem: ChecksumSystem | null = null; - - // System assigned during initializeSystems() - null until then - private _projectileSystem: ProjectileSystem | null = null; - - /** - * Get the ProjectileSystem instance. - * @throws Error if accessed before system initialization - */ - public get projectileSystem(): ProjectileSystem { - if (!this._projectileSystem) { - throw new Error('[WorkerGame] ProjectileSystem accessed before initialization'); - } - return this._projectileSystem; - } - - // Terrain - private terrainGrid: TerrainCell[][] | null = null; - private decorationCollisions: Array<{ x: number; z: number; radius: number }> = []; - - // Game state - private state: GameState = 'initializing'; - private currentTick = 0; +export class WorkerGame extends GameCore { + // Worker-specific state private gameTime = 0; private tickMs: number; @@ -123,8 +86,7 @@ export class WorkerGame { private lastTickTime = 0; private accumulator = 0; - // Command queue - private commandQueue: Map = new Map(); + // Command delay for lockstep private readonly COMMAND_DELAY_TICKS = 4; // Event collection for main thread @@ -137,43 +99,166 @@ export class WorkerGame { private selectedEntityIds: number[] = []; private controlGroups: Map = new Map(); + // Debug tracking + private hasLoggedFirstRenderState = false; + private renderStatesSent = 0; + constructor(config: GameConfig) { - this.config = config; - this.tickMs = 1000 / config.tickRate; + super(config); - // Initialize ECS World - this.world = new World(config.mapWidth, config.mapHeight); - this.eventBus = new EventBus(); + this.tickMs = 1000 / config.tickRate; // Initialize player resources this.playerResources.set(config.playerId, { minerals: 50, vespene: 0, supply: 0, maxSupply: 0 }); - // Bootstrap unit/building definitions - bootstrapDefinitions(); - - // Pre-create systems that need direct references (like Game.ts does) - this.visionSystem = new VisionSystem(this as any, config.mapWidth, config.mapHeight); - this.gameStateSystem = new GameStateSystem(this as any); - this.saveLoadSystem = new SaveLoadSystem(this as any); - this.pathfindingSystem = new PathfindingSystem(this as any, config.mapWidth, config.mapHeight); - this.aiMicroSystem = new AIMicroSystem(this as any); - - if (config.isMultiplayer) { - this.checksumSystem = new ChecksumSystem(this as any, { - checksumInterval: 5, - emitNetworkChecksums: true, - logChecksums: false, - autoDumpOnDesync: true, - }); - } - - // Setup event listeners for game events we need to forward + // Setup event listeners for game events to forward this.setupEventListeners(); - // Register and initialize systems + // Initialize systems this.initializeSystems(); } + // ============================================================================ + // SYSTEM DEFINITIONS (worker excludes Selection + Audio) + // ============================================================================ + + protected getSystemDefinitions(): any[] { + return [ + // SPAWN LAYER + { + name: 'SpawnSystem', + dependencies: [] as string[], + factory: () => new SpawnSystem(this as any), + }, + + // PLACEMENT LAYER + { + name: 'BuildingPlacementSystem', + dependencies: [] as string[], + factory: () => new BuildingPlacementSystem(this as any), + }, + { + name: 'PathfindingSystem', + dependencies: ['BuildingPlacementSystem'], + factory: () => this.pathfindingSystem, + }, + + // MECHANICS LAYER + { + name: 'BuildingMechanicsSystem', + dependencies: ['BuildingPlacementSystem'], + factory: () => new BuildingMechanicsSystem(this as any), + }, + { + name: 'WallSystem', + dependencies: ['BuildingPlacementSystem'], + factory: () => new WallSystem(this as any), + }, + { + name: 'UnitMechanicsSystem', + dependencies: [] as string[], + factory: () => new UnitMechanicsSystem(this as any), + }, + + // MOVEMENT LAYER + { + name: 'MovementSystem', + dependencies: ['PathfindingSystem', 'UnitMechanicsSystem'], + factory: () => new MovementSystem(this as any), + }, + + // VISION LAYER + { + name: 'VisionSystem', + dependencies: ['MovementSystem'], + factory: () => this.visionSystem, + }, + + // COMBAT LAYER + { + name: 'CombatSystem', + dependencies: ['MovementSystem', 'VisionSystem'], + factory: () => new CombatSystem(this as any), + }, + { + name: 'ProjectileSystem', + dependencies: ['CombatSystem'], + factory: () => new ProjectileSystem(this as any), + }, + { + name: 'AbilitySystem', + dependencies: ['CombatSystem'], + factory: () => new AbilitySystem(this as any), + }, + + // ECONOMY LAYER + { + name: 'ResourceSystem', + dependencies: ['MovementSystem'], + factory: () => new ResourceSystem(this as any), + }, + { + name: 'ProductionSystem', + dependencies: ['ResourceSystem'], + factory: () => new ProductionSystem(this as any), + }, + { + name: 'ResearchSystem', + dependencies: ['ProductionSystem'], + factory: () => new ResearchSystem(this as any), + }, + + // AI LAYER (conditional) + ...(this.config.aiEnabled ? [ + { + name: 'EnhancedAISystem', + dependencies: ['CombatSystem', 'ResourceSystem'], + factory: () => new EnhancedAISystem(this as any, this.config.aiDifficulty), + }, + { + name: 'AIEconomySystem', + dependencies: ['EnhancedAISystem'], + factory: () => new AIEconomySystem(this as any), + }, + { + name: 'AIMicroSystem', + dependencies: ['EnhancedAISystem', 'CombatSystem'], + factory: () => this.aiMicroSystem, + }, + ] : []), + + // META LAYER + { + name: 'GameStateSystem', + dependencies: ['CombatSystem', 'ProductionSystem', 'ResourceSystem'], + factory: () => this.gameStateSystem, + }, + ...(this.config.isMultiplayer && this.checksumSystem ? [{ + name: 'ChecksumSystem', + dependencies: ['GameStateSystem'], + factory: () => this.checksumSystem!, + }] : []), + { + name: 'SaveLoadSystem', + dependencies: ['GameStateSystem'], + factory: () => this.saveLoadSystem, + }, + ]; + } + + protected override onSystemCreated(system: any): void { + super.onSystemCreated(system); + + // Capture ProjectileSystem reference + if (system.name === 'ProjectileSystem') { + this._projectileSystem = system as ProjectileSystem; + } + } + + // ============================================================================ + // EVENT FORWARDING + // ============================================================================ + private setupEventListeners(): void { // Combat events this.eventBus.on('combat:attack', (data: any) => { @@ -304,165 +389,14 @@ export class WorkerGame { }); } - private initializeSystems(): void { - // Use SystemRegistry for dependency-based ordering (like Game.ts) - const registry = new SystemRegistry(); - registry.registerAll(this.getWorkerSystemDefinitions()); - - // Validate dependencies at startup - const errors = registry.validate(); - if (errors.length > 0) { - console.error('[WorkerGame] System dependency errors:', errors); - throw new Error(`Invalid system dependencies:\n${errors.join('\n')}`); - } - - // Create systems in dependency order - const systems = registry.createSystems(this as any); - - // Add all systems to world - for (const system of systems) { - this.world.addSystem(system); - - // Capture references to systems that are accessed elsewhere - if (system.name === 'ProjectileSystem') { - this._projectileSystem = system as ProjectileSystem; - } - } - } - - private getWorkerSystemDefinitions() { - // Return system definitions excluding main-thread-only systems (AudioSystem) - return [ - // SPAWN LAYER - { - name: 'SpawnSystem', - dependencies: [] as string[], - factory: () => new SpawnSystem(this as any), - }, - - // PLACEMENT LAYER - { - name: 'BuildingPlacementSystem', - dependencies: [] as string[], - factory: () => new BuildingPlacementSystem(this as any), - }, - { - name: 'PathfindingSystem', - dependencies: ['BuildingPlacementSystem'], - factory: () => this.pathfindingSystem, - }, - - // MECHANICS LAYER - { - name: 'BuildingMechanicsSystem', - dependencies: ['BuildingPlacementSystem'], - factory: () => new BuildingMechanicsSystem(this as any), - }, - { - name: 'WallSystem', - dependencies: ['BuildingPlacementSystem'], - factory: () => new WallSystem(this as any), - }, - { - name: 'UnitMechanicsSystem', - dependencies: [] as string[], - factory: () => new UnitMechanicsSystem(this as any), - }, - - // MOVEMENT LAYER - { - name: 'MovementSystem', - dependencies: ['PathfindingSystem', 'UnitMechanicsSystem'], - factory: () => new MovementSystem(this as any), - }, - - // VISION LAYER - { - name: 'VisionSystem', - dependencies: ['MovementSystem'], - factory: () => this.visionSystem, - }, - - // COMBAT LAYER - { - name: 'CombatSystem', - dependencies: ['MovementSystem', 'VisionSystem'], - factory: () => new CombatSystem(this as any), - }, - { - name: 'ProjectileSystem', - dependencies: ['CombatSystem'], - factory: () => new ProjectileSystem(this as any), - }, - { - name: 'AbilitySystem', - dependencies: ['CombatSystem'], - factory: () => new AbilitySystem(this as any), - }, - - // ECONOMY LAYER - { - name: 'ResourceSystem', - dependencies: ['MovementSystem'], - factory: () => new ResourceSystem(this as any), - }, - { - name: 'ProductionSystem', - dependencies: ['ResourceSystem'], - factory: () => new ProductionSystem(this as any), - }, - { - name: 'ResearchSystem', - dependencies: ['ProductionSystem'], - factory: () => new ResearchSystem(this as any), - }, - - // AI LAYER (conditional) - ...(this.config.aiEnabled ? [ - { - name: 'EnhancedAISystem', - dependencies: ['CombatSystem', 'ResourceSystem'], - factory: () => new EnhancedAISystem(this as any, this.config.aiDifficulty), - }, - { - name: 'AIEconomySystem', - dependencies: ['EnhancedAISystem'], - factory: () => new AIEconomySystem(this as any), - }, - { - name: 'AIMicroSystem', - dependencies: ['EnhancedAISystem', 'CombatSystem'], - factory: () => this.aiMicroSystem, - }, - ] : []), - - // META LAYER - { - name: 'GameStateSystem', - dependencies: ['CombatSystem', 'ProductionSystem', 'ResourceSystem'], - factory: () => this.gameStateSystem, - }, - ...(this.config.isMultiplayer && this.checksumSystem ? [{ - name: 'ChecksumSystem', - dependencies: ['GameStateSystem'], - factory: () => this.checksumSystem!, - }] : []), - { - name: 'SaveLoadSystem', - dependencies: ['GameStateSystem'], - factory: () => this.saveLoadSystem, - }, - ]; - } - // ============================================================================ // GAME LOOP (runs via setInterval in worker - NOT throttled!) // ============================================================================ - public start(): void { + public override start(): void { if (this.state === 'running') return; - if (DEBUG) { + if (debugInitialization.isEnabled()) { console.log('[GameWorker] Starting game loop. Entity counts:', { units: this.world.getEntitiesWith('Unit').length, buildings: this.world.getEntitiesWith('Building').length, @@ -478,7 +412,7 @@ export class WorkerGame { this.loopIntervalId = setInterval(() => this.tick(), this.tickMs); } - public stop(): void { + public override stop(): void { this.state = 'paused'; if (this.loopIntervalId !== null) { clearInterval(this.loopIntervalId); @@ -500,7 +434,7 @@ export class WorkerGame { // Fixed timestep updates let iterations = 0; const maxIterations = 10; - const timeBudgetMs = 40; // 40ms budget per tick for game logic + const timeBudgetMs = 40; const tickStart = performance.now(); while (this.accumulator >= this.tickMs && iterations < maxIterations) { @@ -528,7 +462,7 @@ export class WorkerGame { // Update all systems this.world.update(deltaTime); - // Update player resources from ResourceSystem + // Update player resources this.updatePlayerResources(); } @@ -558,257 +492,8 @@ export class WorkerGame { this.queueCommand(command); } - private queueCommand(command: GameCommand): void { - const tick = command.tick; - if (!this.commandQueue.has(tick)) { - this.commandQueue.set(tick, []); - } - this.commandQueue.get(tick)!.push(command); - } - - private processQueuedCommands(): void { - const commands = this.commandQueue.get(this.currentTick); - if (!commands) return; - - // Sort for determinism - commands.sort((a, b) => { - if (a.playerId !== b.playerId) return a.playerId.localeCompare(b.playerId); - if (a.type !== b.type) return a.type.localeCompare(b.type); - return (a.entityIds[0] ?? 0) - (b.entityIds[0] ?? 0); - }); - - for (const command of commands) { - this.processCommand(command); - } - - this.commandQueue.delete(this.currentTick); - } - - public processCommand(command: GameCommand): void { - // Dispatch command to appropriate event handlers via shared dispatcher - dispatchCommand(this.eventBus, command); - } - - // ============================================================================ - // TERRAIN & NAVMESH - // ============================================================================ - - public setTerrainGrid(terrain: TerrainCell[][]): void { - this.terrainGrid = terrain; - this.pathfindingSystem.loadTerrainData(); - } - - public getTerrainGrid(): TerrainCell[][] | null { - return this.terrainGrid; - } - - public getTerrainAt(worldX: number, worldY: number): TerrainCell | null { - if (!this.terrainGrid || this.terrainGrid.length === 0) return null; - const gridX = Math.floor(worldX); - const gridY = Math.floor(worldY); - const firstRow = this.terrainGrid[0]; - if (!firstRow || gridY < 0 || gridY >= this.terrainGrid.length || - gridX < 0 || gridX >= firstRow.length) { - return null; - } - return this.terrainGrid[gridY][gridX]; - } - - /** - * Get terrain height at a specific world position. - * Converts elevation (0-255) to world height units. - * Returns 0 if terrain data is not available. - */ - public getTerrainHeightAt(worldX: number, worldY: number): number { - const cell = this.getTerrainAt(worldX, worldY); - if (!cell) return 0; - - // Convert elevation to height using same formula as Terrain.ts - // elevation * 0.04 gives range 0 to ~10.2 units - return cell.elevation * 0.04; - } - - /** - * Check if a building position overlaps with decorations (rocks, trees). - */ - public isPositionClearOfDecorations(centerX: number, centerY: number, width: number, height: number): boolean { - const halfW = width / 2 + 0.5; // Small buffer - const halfH = height / 2 + 0.5; - - for (const deco of this.decorationCollisions) { - const dx = Math.abs(centerX - deco.x); - const dz = Math.abs(centerY - deco.z); - - if (dx < halfW + deco.radius && dz < halfH + deco.radius) { - return false; - } - } - - return true; - } - - /** - * Check if a building can be placed at the given position. - * Returns true if all tiles under the building are buildable ground at the same elevation. - */ - public isValidTerrainForBuilding(centerX: number, centerY: number, width: number, height: number): boolean { - if (!this.terrainGrid) { - // No terrain data - allow placement (legacy behavior) - return true; - } - - const halfWidth = width / 2; - const halfHeight = height / 2; - let requiredElevation: number | null = null; - - const firstRow = this.terrainGrid[0]; - if (!firstRow) return false; - - for (let dy = -Math.floor(halfHeight); dy < Math.ceil(halfHeight); dy++) { - for (let dx = -Math.floor(halfWidth); dx < Math.ceil(halfWidth); dx++) { - const tileX = Math.floor(centerX + dx); - const tileY = Math.floor(centerY + dy); - - if (tileY < 0 || tileY >= this.terrainGrid.length || - tileX < 0 || tileX >= firstRow.length) { - return false; - } - - const cell = this.terrainGrid[tileY][tileX]; - - if (cell.terrain !== 'ground' && cell.terrain !== 'platform') { - return false; - } - - if (requiredElevation === null) { - requiredElevation = cell.elevation; - } else if (cell.elevation !== requiredElevation) { - return false; - } - } - } - - return true; - } - - /** - * Check if a building can be placed at the given position. - * Mirrors Game.ts validation for worker parity. - */ - public isValidBuildingPlacement( - centerX: number, - centerY: number, - width: number, - height: number, - excludeEntityId?: number, - skipUnitCheck: boolean = false - ): boolean { - const halfW = width / 2; - const halfH = height / 2; - - if (centerX - halfW < 0 || centerY - halfH < 0 || - centerX + halfW > this.config.mapWidth || centerY + halfH > this.config.mapHeight) { - return false; - } - - if (!this.isValidTerrainForBuilding(centerX, centerY, width, height)) { - return false; - } - - const queryPadding = 10; - const nearbyBuildingIds = this.world.buildingGrid.queryRect( - centerX - halfW - queryPadding, - centerY - halfH - queryPadding, - centerX + halfW + queryPadding, - centerY + halfH + queryPadding - ); - - for (const buildingId of nearbyBuildingIds) { - const entity = this.world.getEntity(buildingId); - if (!validateEntity(entity, buildingId, 'WorkerGame.isValidBuildingPlacement:building', this.currentTick)) continue; - - const transform = entity.get('Transform'); - const building = entity.get('Building'); - if (!transform || !building) continue; - - if (building.isFlying || building.state === 'lifting' || - building.state === 'flying' || building.state === 'landing') { - continue; - } - - const existingHalfW = building.width / 2; - const existingHalfH = building.height / 2; - const dx = Math.abs(centerX - transform.x); - const dy = Math.abs(centerY - transform.y); - - if (dx < halfW + existingHalfW && dy < halfH + existingHalfH) { - return false; - } - } - - const resources = this.world.getEntitiesWith('Resource', 'Transform'); - for (const entity of resources) { - const transform = entity.get('Transform'); - if (!transform) continue; - - const dx = Math.abs(centerX - transform.x); - const dy = Math.abs(centerY - transform.y); - - if (dx < halfW + 1.5 && dy < halfH + 1.5) { - return false; - } - } - - if (!skipUnitCheck) { - const nearbyUnitIds = this.world.unitGrid.queryRect( - centerX - halfW - 2, - centerY - halfH - 2, - centerX + halfW + 2, - centerY + halfH + 2 - ); - - for (const unitId of nearbyUnitIds) { - if (excludeEntityId !== undefined && unitId === excludeEntityId) { - continue; - } - - const entity = this.world.getEntity(unitId); - if (!validateEntity(entity, unitId, 'WorkerGame.isValidBuildingPlacement:unit', this.currentTick)) continue; - - const transform = entity.get('Transform'); - if (!transform) continue; - - const dx = Math.abs(centerX - transform.x); - const dy = Math.abs(centerY - transform.y); - - if (dx < halfW + 0.5 && dy < halfH + 0.5) { - return false; - } - } - } - - if (!this.isPositionClearOfDecorations(centerX, centerY, width, height)) { - return false; - } - - return true; - } - - public async initializeNavMesh(positions: Float32Array, indices: Uint32Array): Promise { - return this.pathfindingSystem.initializeNavMesh(positions, indices); - } - - public async initializeWaterNavMesh(positions: Float32Array, indices: Uint32Array): Promise { - return this.pathfindingSystem.initializeWaterNavMesh(positions, indices); - } - - public setDecorationCollisions(collisions: Array<{ x: number; z: number; radius: number }>): void { - this.decorationCollisions = collisions; - this.pathfindingSystem.registerDecorationCollisions(collisions); - } - - public getDecorationCollisions(): Array<{ x: number; z: number; radius: number }> { - return this.decorationCollisions; + public override getGameTime(): number { + return this.gameTime; } // ============================================================================ @@ -816,7 +501,6 @@ export class WorkerGame { // ============================================================================ private updatePlayerResources(): void { - // Update resources for each player from their entities const buildings = this.world.getEntitiesWith('Building', 'Selectable'); const playerSupply = new Map(); @@ -834,7 +518,6 @@ export class WorkerGame { } } - // Count unit supply const units = this.world.getEntitiesWith('Unit', 'Selectable'); for (const entity of units) { const selectable = entity.get('Selectable')!; @@ -842,11 +525,9 @@ export class WorkerGame { if (!playerSupply.has(selectable.playerId)) { playerSupply.set(selectable.playerId, { supply: 0, maxSupply: 0 }); } - // Each unit costs 1 supply by default (could be expanded) playerSupply.get(selectable.playerId)!.supply += 1; } - // Update our cache for (const [playerId, supply] of playerSupply) { const current = this.playerResources.get(playerId) ?? { minerals: 50, vespene: 0, supply: 0, maxSupply: 0 }; current.supply = supply.supply; @@ -855,10 +536,6 @@ export class WorkerGame { } } - // Debug: log first render state only - private hasLoggedFirstRenderState = false; - private renderStatesSent = 0; - private sendRenderState(): void { const units = this.collectUnitRenderState(); const buildings = this.collectBuildingRenderState(); @@ -867,14 +544,12 @@ export class WorkerGame { this.renderStatesSent++; - // Debug: log first 5 sends and every 100th send - if (DEBUG && (this.renderStatesSent <= 5 || this.renderStatesSent % 100 === 0)) { + if (debugInitialization.isEnabled() && (this.renderStatesSent <= 5 || this.renderStatesSent % 100 === 0)) { console.log(`[GameWorker] sendRenderState #${this.renderStatesSent}: units=${units.length}, buildings=${buildings.length}, resources=${resources.length}`); } - // Debug log first render state with entities - if (DEBUG && !this.hasLoggedFirstRenderState && (units.length > 0 || buildings.length > 0 || resources.length > 0)) { - console.log('[GameWorker] Sending first render state with entities:', { + if (debugInitialization.isEnabled() && !this.hasLoggedFirstRenderState && (units.length > 0 || buildings.length > 0 || resources.length > 0)) { + console.log('[GameWorker] First render state with entities:', { tick: this.currentTick, units: units.length, buildings: buildings.length, @@ -883,7 +558,6 @@ export class WorkerGame { this.hasLoggedFirstRenderState = true; } - // Serialize Maps to arrays for postMessage (Maps can't be serialized) const serializedRenderState: SerializedRenderState = { tick: this.currentTick, gameTime: this.gameTime, @@ -893,7 +567,7 @@ export class WorkerGame { buildings, resources, projectiles, - visionGrids: [], // Vision grids collected by VisionSystem - TODO if needed + visionGrids: [], playerResources: Array.from(this.playerResources.entries()), selectedEntityIds: [...this.selectedEntityIds], controlGroups: Array.from(this.controlGroups.entries()), @@ -906,7 +580,6 @@ export class WorkerGame { const states: UnitRenderState[] = []; const entities = this.world.getEntitiesWith('Transform', 'Unit', 'Health', 'Selectable'); - // TEMP: Diagnostic - log query results periodically if (this.renderStatesSent <= 5 || this.renderStatesSent % 100 === 0) { const allEntities = this.world.getEntities(); const unitsOnly = this.world.getEntitiesWith('Unit'); @@ -1064,21 +737,9 @@ export class WorkerGame { } // ============================================================================ - // GETTERS FOR SYSTEMS + // SELECTION STATE (forwarded from main thread) // ============================================================================ - public getCurrentTick(): number { - return this.currentTick; - } - - public getGameTime(): number { - return this.gameTime; - } - - public getState(): GameState { - return this.state; - } - public setSelection(entityIds: number[]): void { // Deselect previous for (const id of this.selectedEntityIds) { @@ -1111,9 +772,12 @@ export class WorkerGame { } } + // ============================================================================ + // CHECKSUM (multiplayer) + // ============================================================================ + public requestChecksum(): void { if (this.checksumSystem) { - // Get checksum data from the system const checksumData = this.checksumSystem.getLatestChecksum(); if (checksumData) { postMessage({ @@ -1125,12 +789,11 @@ export class WorkerGame { } } - /** - * Spawn initial entities based on map data. - * Creates resources and player bases for all players. - */ + // ============================================================================ + // ENTITY SPAWNING + // ============================================================================ + public spawnInitialEntities(mapData: SpawnMapData): void { - // Always log spawn calls (helps debug duplicate registration issues) console.log('[GameWorker] spawnInitialEntities called:', { resourceCount: mapData.resources?.length ?? 0, spawnCount: mapData.spawns?.length ?? 0, @@ -1148,24 +811,21 @@ export class WorkerGame { resourceDef.amount ?? (resourceDef.type === 'mineral' ? 1500 : 2500) )); } - if (DEBUG) console.log('[GameWorker] Spawned', mapData.resources.length, 'resources'); + debugInitialization.log('[GameWorker] Spawned', mapData.resources.length, 'resources'); } - // Get active player slots (human or AI) + // Get active player slots const activeSlots = mapData.playerSlots?.filter( slot => slot.type === 'human' || slot.type === 'ai' ) ?? []; - // Track used spawn indices to prevent duplicates const usedSpawnIndices = new Set(); const spawns = mapData.spawns ?? []; // Spawn bases for each active player for (const slot of activeSlots) { - // Find the slot's player number (1-8) from the slot.id (e.g., "player1" -> 1) const playerNumber = parseInt(slot.id.replace('player', ''), 10); - // Find spawn point for this player let spawnIndex = spawns.findIndex(s => s.playerSlot === playerNumber); if (spawnIndex === -1 || usedSpawnIndices.has(spawnIndex)) { spawnIndex = spawns.findIndex((_, idx) => !usedSpawnIndices.has(idx)); @@ -1179,7 +839,6 @@ export class WorkerGame { const spawn = spawns[spawnIndex]; usedSpawnIndices.add(spawnIndex); - // Initialize player resources this.playerResources.set(slot.id, { minerals: 50, vespene: 0, @@ -1187,16 +846,12 @@ export class WorkerGame { maxSupply: 11, }); - // Spawn base for this player this.spawnBase(slot.id, spawn.x, spawn.y); - // Register AI players with the AI system + // Register AI players if (slot.type === 'ai' && this.config.aiEnabled) { - if (DEBUG) console.log(`[GameWorker] Registering AI for ${slot.id} (${slot.faction}, ${slot.aiDifficulty})`); + debugInitialization.log(`[GameWorker] Registering AI for ${slot.id}`); - // Register with EnhancedAISystem (which also emits ai:registered event - // that AIMicroSystem listens to for unit micro behavior) - // Note: AI player resources are tracked by AICoordinator, not playerResources const enhancedAI = this.world.getSystem(EnhancedAISystem); if (enhancedAI) { enhancedAI.registerAI(slot.id, slot.faction, slot.aiDifficulty ?? 'medium'); @@ -1204,7 +859,7 @@ export class WorkerGame { } } - // Fallback: If no player slots provided, spawn local player at first spawn + // Fallback if (activeSlots.length === 0 && spawns.length > 0) { this.playerResources.set(this.config.playerId, { minerals: 50, @@ -1215,7 +870,7 @@ export class WorkerGame { this.spawnBase(this.config.playerId, spawns[0].x, spawns[0].y); } - // Set watch towers if available + // Set watch towers if (mapData.watchTowers) { this.visionSystem?.setWatchTowers(mapData.watchTowers); } @@ -1224,7 +879,6 @@ export class WorkerGame { } private spawnBase(playerId: string, x: number, y: number): void { - // Spawn Headquarters using proper BuildingDefinition interface const ccDef = { id: 'headquarters', name: 'Headquarters', @@ -1248,12 +902,10 @@ export class WorkerGame { .add(new Health(ccDef.maxHealth, ccDef.armor, 'structure')) .add(new Selectable(Math.max(ccDef.width, ccDef.height) * 0.6, 10, playerId)); - // Mark as complete const building = headquarters.get('Building')!; building.buildProgress = 1; building.state = 'complete'; - // Emit building placed event this.eventBus.emit('building:placed', { entityId: headquarters.id, buildingType: 'headquarters', @@ -1263,7 +915,6 @@ export class WorkerGame { height: ccDef.height, }); - // Set rally point building.setRallyPoint(x + ccDef.width / 2 + 3, y); // Spawn initial workers @@ -1282,7 +933,6 @@ export class WorkerGame { } private spawnUnit(unitType: string, playerId: string, x: number, y: number): void { - // Spawn unit using proper UnitDefinition interface const unitDef = { id: unitType, name: 'Fabricator', @@ -1310,7 +960,6 @@ export class WorkerGame { .add(new Selectable(0.5, 1, playerId)) .add(new Velocity()); - // Update supply const resources = this.playerResources.get(playerId); if (resources) { resources.supply += 1; @@ -1326,124 +975,123 @@ let game: WorkerGame | null = null; if (typeof self !== 'undefined') { self.onmessage = async (event: MessageEvent) => { - const message = event.data; - - try { - switch (message.type) { - case 'init': { - game = new WorkerGame(message.config); - postMessage({ type: 'initialized', success: true } satisfies WorkerToMainMessage); - break; - } + const message = event.data; + + try { + switch (message.type) { + case 'init': { + game = new WorkerGame(message.config); + postMessage({ type: 'initialized', success: true } satisfies WorkerToMainMessage); + break; + } - case 'start': { - if (DEBUG) console.log('[GameWorker] Received start command'); - if (!game) { - console.error('[GameWorker] Game not initialized when start called'); - postMessage({ type: 'error', message: 'Game not initialized' } satisfies WorkerToMainMessage); - return; + case 'start': { + debugInitialization.log('[GameWorker] Received start command'); + if (!game) { + console.error('[GameWorker] Game not initialized when start called'); + postMessage({ type: 'error', message: 'Game not initialized' } satisfies WorkerToMainMessage); + return; + } + game.start(); + break; } - game.start(); - break; - } - case 'stop': { - game?.stop(); - break; - } + case 'stop': { + game?.stop(); + break; + } - case 'pause': { - game?.stop(); - break; - } + case 'pause': { + game?.stop(); + break; + } - case 'resume': { - game?.start(); - break; - } + case 'resume': { + game?.start(); + break; + } - case 'setDebugSettings': { - setWorkerDebugSettings(message.settings); - break; - } + case 'setDebugSettings': { + setWorkerDebugSettings(message.settings); + break; + } - case 'command': { - if (!game) return; - game.issueCommand(message.command); - break; - } + case 'command': { + if (!game) return; + game.issueCommand(message.command); + break; + } - case 'multiplayerCommand': { - if (!game) return; - game.receiveMultiplayerCommand(message.command); - break; - } + case 'multiplayerCommand': { + if (!game) return; + game.receiveMultiplayerCommand(message.command); + break; + } - case 'setTerrain': { - game?.setTerrainGrid(message.terrain); - break; - } + case 'setTerrain': { + game?.setTerrainGrid(message.terrain); + break; + } - case 'setNavMesh': { - if (!game) return; - const success = await game.initializeNavMesh(message.positions, message.indices); - if (!success) { - postMessage({ type: 'error', message: 'Failed to initialize navmesh' } satisfies WorkerToMainMessage); + case 'setNavMesh': { + if (!game) return; + const success = await game.initializeNavMesh(message.positions, message.indices); + if (!success) { + postMessage({ type: 'error', message: 'Failed to initialize navmesh' } satisfies WorkerToMainMessage); + } + break; } - break; - } - case 'setWaterNavMesh': { - if (!game) return; - await game.initializeWaterNavMesh(message.positions, message.indices); - break; - } + case 'setWaterNavMesh': { + if (!game) return; + await game.initializeWaterNavMesh(message.positions, message.indices); + break; + } - case 'setDecorations': { - game?.setDecorationCollisions(message.collisions); - break; - } + case 'setDecorations': { + game?.setDecorationCollisions(message.collisions); + break; + } - case 'spawnEntities': { - console.log('[GameWorker] Received spawnEntities message'); - game?.spawnInitialEntities(message.mapData); - break; - } + case 'spawnEntities': { + console.log('[GameWorker] Received spawnEntities message'); + game?.spawnInitialEntities(message.mapData); + break; + } - case 'setSelection': { - game?.setSelection(message.entityIds); - break; - } + case 'setSelection': { + game?.setSelection(message.entityIds); + break; + } - case 'setControlGroup': { - game?.setControlGroup(message.groupNumber, message.entityIds); - break; - } + case 'setControlGroup': { + game?.setControlGroup(message.groupNumber, message.entityIds); + break; + } - case 'requestChecksum': { - game?.requestChecksum(); - break; - } + case 'requestChecksum': { + game?.requestChecksum(); + break; + } - case 'networkPause': { - if (message.paused) { - game?.stop(); - } else { - game?.start(); + case 'networkPause': { + if (message.paused) { + game?.stop(); + } else { + game?.start(); + } + break; } - break; } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + postMessage({ type: 'error', message: errorMessage, stack } satisfies WorkerToMainMessage); } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const stack = error instanceof Error ? error.stack : undefined; - postMessage({ type: 'error', message: errorMessage, stack } satisfies WorkerToMainMessage); - } }; } if (typeof self !== 'undefined') { - // Handle uncaught errors self.onerror = (event) => { const message = typeof event === 'string' ? event : (event as ErrorEvent).message ?? 'Unknown error'; let stack: string | undefined;