diff --git a/src/data/abilities/abilities.ts b/src/data/abilities/abilities.ts index b08b1827..383019d0 100644 --- a/src/data/abilities/abilities.ts +++ b/src/data/abilities/abilities.ts @@ -440,6 +440,140 @@ export const ABILITY_DEFINITIONS: Record = { requiresResearch: ['supply_drop'], interruptsMovement: false, }, + + // === NAVAL ABILITIES === + + boost: { + id: 'boost', + name: 'Boost', + description: 'Engage emergency thrusters for a burst of speed.', + targetType: 'instant', + cooldown: 15, + duration: 4, + effects: [ + { type: 'buff', value: 1.75, duration: 4 }, // 75% speed boost + ], + interruptsMovement: false, + soundEffect: 'ability_boost', + }, + + flak_barrage: { + id: 'flak_barrage', + name: 'Flak Barrage', + description: 'Release a barrage of flak rounds, dealing damage to all air units in the area.', + targetType: 'instant', + energyCost: 50, + cooldown: 10, + radius: 6, + effects: [ + { + type: 'damage', + value: 40, + radius: 6, + targetFilter: { requiresAir: true, includeEnemies: true }, + }, + ], + interruptsMovement: false, + visualEffect: 'flak_explosion', + soundEffect: 'ability_flak', + }, + + submerge: { + id: 'submerge', + name: 'Submerge', + description: 'Dive beneath the surface, becoming invisible to units without detection.', + targetType: 'toggle', + cooldown: 3, // Brief cooldown to prevent rapid toggle abuse + effects: [ + { type: 'cloak' }, + { type: 'custom', customHandler: 'toggleSubmerge' }, + ], + interruptsMovement: false, + soundEffect: 'submarine_dive', + }, + + depth_charge_defense: { + id: 'depth_charge_defense', + name: 'Depth Charge', + description: 'Launch depth charges that deal heavy damage to naval units.', + targetType: 'targeted', + range: 5, + energyCost: 25, + cooldown: 8, + effects: [ + { + type: 'damage', + value: 75, + targetFilter: { includeEnemies: true }, + }, + ], + interruptsMovement: false, + visualEffect: 'depth_charge_explosion', + soundEffect: 'ability_depth_charge', + }, + + beach_assault: { + id: 'beach_assault', + name: 'Beach Assault', + description: 'Prepare for amphibious landing, granting loaded units a combat bonus upon disembark.', + targetType: 'instant', + cooldown: 30, + duration: 15, + effects: [ + { type: 'buff', value: 1.25, duration: 15 }, // 25% damage boost to unloaded units + ], + interruptsMovement: false, + soundEffect: 'ability_beach_assault', + }, + + amphibious_mode: { + id: 'amphibious_mode', + name: 'Amphibious Mode', + description: 'Toggle between water and land movement. Slower on land but can traverse both terrains.', + targetType: 'toggle', + cooldown: 2, + effects: [ + { type: 'custom', customHandler: 'toggleAmphibious' }, + ], + interruptsMovement: true, + soundEffect: 'vehicle_transform', + }, + + shore_bombardment: { + id: 'shore_bombardment', + name: 'Shore Bombardment', + description: 'Fire a salvo of heavy shells at a ground target location.', + targetType: 'ground', + range: 14, + radius: 3, + energyCost: 75, + cooldown: 12, + castTime: 1.5, // Brief aiming delay + effects: [ + { type: 'damage', value: 100, radius: 3 }, + ], + interruptsMovement: false, + visualEffect: 'artillery_strike', + soundEffect: 'ability_bombardment', + }, + + yamato_cannon: { + id: 'yamato_cannon', + name: 'Yamato Cannon', + description: 'Fire the main battery, dealing devastating damage to a single target.', + targetType: 'targeted', + range: 10, + energyCost: 150, + cooldown: 60, + castTime: 2, // Charge-up time + effects: [ + { type: 'damage', value: 300 }, + ], + channeled: true, + interruptsMovement: true, + visualEffect: 'yamato_beam', + soundEffect: 'ability_yamato', + }, }; // ==================== ABILITY REGISTRY ==================== diff --git a/src/engine/core/Game.ts b/src/engine/core/Game.ts index be3d7ea1..57784775 100644 --- a/src/engine/core/Game.ts +++ b/src/engine/core/Game.ts @@ -389,6 +389,47 @@ export class Game extends GameCore { return; } + // LOCKSTEP BARRIER: In multiplayer, wait for all players before advancing tick + if (this.config.isMultiplayer) { + const nextTick = this.currentTick + 1; + if (!this.hasAllCommandsForTick(nextTick)) { + // Start tracking wait time if not already + if (!this.tickWaitStart.has(nextTick)) { + this.tickWaitStart.set(nextTick, this.currentTick); + debugNetworking.log(`[Game] LOCKSTEP: Waiting for commands for tick ${nextTick}`); + } + + // Check for timeout + const waitStartTick = this.tickWaitStart.get(nextTick)!; + const ticksWaited = this.currentTick - waitStartTick; + + if (ticksWaited >= this.LOCKSTEP_TIMEOUT_TICKS) { + // Timeout - report desync and proceed anyway to avoid infinite hang + console.error( + `[Game] LOCKSTEP TIMEOUT: No commands from all players for tick ${nextTick} ` + + `after waiting ${ticksWaited} ticks. Expected: ${this.getExpectedPlayerIds().join(', ')}, ` + + `Received: ${Array.from(this.tickCommandReceipts.get(nextTick) || []).join(', ')}` + ); + reportDesync(nextTick); + this.eventBus.emit('desync:detected', { + tick: nextTick, + localChecksum: 0, + remoteChecksum: 0, + remotePeerId: useMultiplayerStore.getState().remotePeerId || 'unknown', + reason: 'lockstep_timeout', + }); + // Clear wait tracking and proceed - game may desync but won't freeze + this.tickWaitStart.delete(nextTick); + } else { + // Still waiting - skip this frame + return; + } + } else { + // Got all commands - clear wait tracking + this.tickWaitStart.delete(nextTick); + } + } + const tickStart = performance.now(); this.currentTick++; @@ -396,6 +437,8 @@ export class Game extends GameCore { if (this.config.isMultiplayer) { this.processQueuedCommandsWithCleanup(); + // Send heartbeats for upcoming ticks to keep lockstep flowing + this.ensureHeartbeatsForUpcomingTicks(); } this.world.update(deltaTime); @@ -421,6 +464,101 @@ export class Game extends GameCore { } } + // ============================================================================ + // LOCKSTEP BARRIER HELPERS + // ============================================================================ + + /** + * Get the set of player IDs that should send commands for lockstep. + * In 2-player multiplayer, this is local player + remote peer. + */ + private getExpectedPlayerIds(): string[] { + const players: string[] = [this.config.playerId]; + const remotePeerId = useMultiplayerStore.getState().remotePeerId; + if (remotePeerId) { + players.push(remotePeerId); + } + return players; + } + + /** + * Check if we have received command acknowledgments from all expected players for a tick. + * For lockstep, every player must send at least one command or heartbeat for each tick. + * We track receipts when commands are queued via queueCommandWithReceipt(). + */ + private hasAllCommandsForTick(tick: number): boolean { + const expectedPlayers = this.getExpectedPlayerIds(); + const receipts = this.tickCommandReceipts.get(tick); + + if (!receipts) { + // No commands received yet for this tick + return false; + } + + // Check if all expected players have sent commands + for (const playerId of expectedPlayers) { + if (!receipts.has(playerId)) { + return false; + } + } + + return true; + } + + // Track which ticks we've sent commands for (to avoid duplicate heartbeats) + private sentCommandForTick: Set = new Set(); + + /** + * Send a heartbeat command to acknowledge a tick when no other commands were sent. + * This ensures the lockstep barrier can proceed even when a player has no actions. + */ + private sendHeartbeatForTick(tick: number): void { + if (this.sentCommandForTick.has(tick)) { + return; // Already sent a command for this tick + } + + const heartbeat: GameCommand = { + tick, + playerId: this.config.playerId, + type: 'HEARTBEAT', + entityIds: [], + }; + + sendMultiplayerMessage({ + type: 'command', + payload: heartbeat, + }); + + this.queueCommandWithReceipt(heartbeat); + this.sentCommandForTick.add(tick); + + // Cleanup old entries + const oldestRelevant = this.currentTick - this.COMMAND_HISTORY_SIZE; + for (const t of this.sentCommandForTick) { + if (t < oldestRelevant) { + this.sentCommandForTick.delete(t); + } + } + + debugNetworking.log(`[Game] Sent heartbeat for tick ${tick}`); + } + + /** + * Ensure heartbeats are sent for upcoming ticks that need acknowledgment. + * Called periodically to keep lockstep in sync. + */ + private ensureHeartbeatsForUpcomingTicks(): void { + // Send heartbeats for ticks within the command delay window + const startTick = this.currentTick + 1; + const endTick = this.currentTick + this.currentCommandDelay + 1; + + for (let tick = startTick; tick <= endTick; tick++) { + if (!this.sentCommandForTick.has(tick)) { + this.sendHeartbeatForTick(tick); + } + } + } + private updateEntityCounts(): void { const units = this.world.getEntitiesWith('Unit', 'Transform'); const buildings = this.world.getEntitiesWith('Building', 'Transform'); @@ -472,6 +610,9 @@ export class Game extends GameCore { debugNetworking.log('[Game] Sent command for tick', executionTick); this.queueCommandWithReceipt(command); + + // Track that we sent a real command for this tick (not just a heartbeat) + this.sentCommandForTick.add(executionTick); } else { this.processCommand(command); } diff --git a/src/engine/core/GameCommand.ts b/src/engine/core/GameCommand.ts index efd335f4..7c61b13f 100644 --- a/src/engine/core/GameCommand.ts +++ b/src/engine/core/GameCommand.ts @@ -48,7 +48,8 @@ export type GameCommandType = | 'BUILD_WALL' | 'ADDON_LIFT' | 'ADDON_LAND' - | 'SUBMERGE'; + | 'SUBMERGE' + | 'HEARTBEAT'; // Lockstep sync - no-op command to acknowledge tick /** * Game command structure for all player actions @@ -150,6 +151,7 @@ const COMMAND_EVENTS: Record = { ADDON_LIFT: 'addon:lift', ADDON_LAND: 'addon:land', SUBMERGE: 'command:submerge', + HEARTBEAT: 'command:heartbeat', // No-op for lockstep sync }; /** @@ -390,6 +392,11 @@ export function dispatchCommand(eventBus: EventBus, command: GameCommand): void playerId: command.playerId, }); break; + + case 'HEARTBEAT': + // No-op command for lockstep sync - just acknowledges player is alive for this tick + // The command:received event already fired at the start of dispatchCommand + break; } } diff --git a/src/engine/definitions/DefinitionValidator.ts b/src/engine/definitions/DefinitionValidator.ts index 46d5eadb..8517c925 100644 --- a/src/engine/definitions/DefinitionValidator.ts +++ b/src/engine/definitions/DefinitionValidator.ts @@ -408,12 +408,14 @@ export class DefinitionValidator { units: Record, buildings: Record, research: Record, - abilities: Record + abilities: Record, + projectileTypes?: Set ): ValidationResult { this.reset(); - // Validate unit ability references + // Validate unit references for (const [id, unit] of Object.entries(units)) { + // Validate ability references if (unit.abilities) { for (const abilityId of unit.abilities) { if (!abilities[abilityId]) { @@ -425,9 +427,39 @@ export class DefinitionValidator { } } } + + // Validate projectileType references + if (projectileTypes && (unit as any).projectileType) { + const projectileType = (unit as any).projectileType as string; + if (!projectileTypes.has(projectileType)) { + this.addError( + 'invalid_reference', + `Unit[${id}].projectileType`, + `References unknown projectile type: ${projectileType}` + ); + } + } + + // Validate transform mode projectileType references + if ((unit as any).transformModes && projectileTypes) { + const modes = (unit as any).transformModes as TransformMode[]; + for (let i = 0; i < modes.length; i++) { + const mode = modes[i]; + if ((mode as any).projectileType) { + const projectileType = (mode as any).projectileType as string; + if (!projectileTypes.has(projectileType)) { + this.addError( + 'invalid_reference', + `Unit[${id}].transformModes[${i}].projectileType`, + `References unknown projectile type: ${projectileType}` + ); + } + } + } + } } - // Validate building production references + // Validate building references for (const [id, building] of Object.entries(buildings)) { if (building.canProduce) { for (const unitId of building.canProduce) { @@ -476,6 +508,18 @@ export class DefinitionValidator { } } } + + // Validate building projectileType (for turrets/defensive structures) + if (projectileTypes && (building as any).projectileType) { + const projectileType = (building as any).projectileType as string; + if (!projectileTypes.has(projectileType)) { + this.addError( + 'invalid_reference', + `Building[${id}].projectileType`, + `References unknown projectile type: ${projectileType}` + ); + } + } } // Validate research references diff --git a/src/utils/debugLogger.ts b/src/utils/debugLogger.ts index 918cf9d8..788b701c 100644 --- a/src/utils/debugLogger.ts +++ b/src/utils/debugLogger.ts @@ -108,122 +108,42 @@ export const debugLog = { isEnabled, }; -// Category-specific loggers for convenience -export const debugAnimation = { - log: (...args: unknown[]) => debugLog.log('animation', ...args), - warn: (...args: unknown[]) => debugLog.warn('animation', ...args), - error: (...args: unknown[]) => debugLog.error('animation', ...args), - isEnabled: () => isEnabled('animation'), -}; - -export const debugMesh = { - log: (...args: unknown[]) => debugLog.log('mesh', ...args), - warn: (...args: unknown[]) => debugLog.warn('mesh', ...args), - error: (...args: unknown[]) => debugLog.error('mesh', ...args), - isEnabled: () => isEnabled('mesh'), -}; - -export const debugTerrain = { - log: (...args: unknown[]) => debugLog.log('terrain', ...args), - warn: (...args: unknown[]) => debugLog.warn('terrain', ...args), - error: (...args: unknown[]) => debugLog.error('terrain', ...args), - isEnabled: () => isEnabled('terrain'), -}; - -export const debugShaders = { - log: (...args: unknown[]) => debugLog.log('shaders', ...args), - warn: (...args: unknown[]) => debugLog.warn('shaders', ...args), - error: (...args: unknown[]) => debugLog.error('shaders', ...args), - isEnabled: () => isEnabled('shaders'), -}; - -export const debugPostProcessing = { - log: (...args: unknown[]) => debugLog.log('postProcessing', ...args), - warn: (...args: unknown[]) => debugLog.warn('postProcessing', ...args), - error: (...args: unknown[]) => debugLog.error('postProcessing', ...args), - isEnabled: () => isEnabled('postProcessing'), -}; - -export const debugBuildingPlacement = { - log: (...args: unknown[]) => debugLog.log('buildingPlacement', ...args), - warn: (...args: unknown[]) => debugLog.warn('buildingPlacement', ...args), - error: (...args: unknown[]) => debugLog.error('buildingPlacement', ...args), - isEnabled: () => isEnabled('buildingPlacement'), -}; - -export const debugCombat = { - log: (...args: unknown[]) => debugLog.log('combat', ...args), - warn: (...args: unknown[]) => debugLog.warn('combat', ...args), - error: (...args: unknown[]) => debugLog.error('combat', ...args), - isEnabled: () => isEnabled('combat'), -}; - -export const debugResources = { - log: (...args: unknown[]) => debugLog.log('resources', ...args), - warn: (...args: unknown[]) => debugLog.warn('resources', ...args), - error: (...args: unknown[]) => debugLog.error('resources', ...args), - isEnabled: () => isEnabled('resources'), -}; - -export const debugProduction = { - log: (...args: unknown[]) => debugLog.log('production', ...args), - warn: (...args: unknown[]) => debugLog.warn('production', ...args), - error: (...args: unknown[]) => debugLog.error('production', ...args), - isEnabled: () => isEnabled('production'), -}; - -export const debugSpawning = { - log: (...args: unknown[]) => debugLog.log('spawning', ...args), - warn: (...args: unknown[]) => debugLog.warn('spawning', ...args), - error: (...args: unknown[]) => debugLog.error('spawning', ...args), - isEnabled: () => isEnabled('spawning'), -}; - -export const debugAI = { - log: (...args: unknown[]) => debugLog.log('ai', ...args), - warn: (...args: unknown[]) => debugLog.warn('ai', ...args), - error: (...args: unknown[]) => debugLog.error('ai', ...args), - isEnabled: () => isEnabled('ai'), -}; - -export const debugPathfinding = { - log: (...args: unknown[]) => debugLog.log('pathfinding', ...args), - warn: (...args: unknown[]) => debugLog.warn('pathfinding', ...args), - error: (...args: unknown[]) => debugLog.error('pathfinding', ...args), - isEnabled: () => isEnabled('pathfinding'), -}; - -export const debugAssets = { - log: (...args: unknown[]) => debugLog.log('assets', ...args), - warn: (...args: unknown[]) => debugLog.warn('assets', ...args), - error: (...args: unknown[]) => debugLog.error('assets', ...args), - isEnabled: () => isEnabled('assets'), -}; - -export const debugInitialization = { - log: (...args: unknown[]) => debugLog.log('initialization', ...args), - warn: (...args: unknown[]) => debugLog.warn('initialization', ...args), - error: (...args: unknown[]) => debugLog.error('initialization', ...args), - isEnabled: () => isEnabled('initialization'), -}; - -export const debugAudio = { - log: (...args: unknown[]) => debugLog.log('audio', ...args), - warn: (...args: unknown[]) => debugLog.warn('audio', ...args), - error: (...args: unknown[]) => debugLog.error('audio', ...args), - isEnabled: () => isEnabled('audio'), -}; +// Category-specific logger interface +interface CategoryLogger { + log: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + isEnabled: () => boolean; +} -export const debugNetworking = { - log: (...args: unknown[]) => debugLog.log('networking', ...args), - warn: (...args: unknown[]) => debugLog.warn('networking', ...args), - error: (...args: unknown[]) => debugLog.error('networking', ...args), - isEnabled: () => isEnabled('networking'), -}; +/** + * Factory function to create category-specific loggers. + * Eliminates boilerplate for each debug category. + */ +function createCategoryLogger(category: DebugCategory): CategoryLogger { + return { + log: (...args: unknown[]) => debugLog.log(category, ...args), + warn: (...args: unknown[]) => debugLog.warn(category, ...args), + error: (...args: unknown[]) => debugLog.error(category, ...args), + isEnabled: () => isEnabled(category), + }; +} -export const debugPerformance = { - log: (...args: unknown[]) => debugLog.log('performance', ...args), - warn: (...args: unknown[]) => debugLog.warn('performance', ...args), - error: (...args: unknown[]) => debugLog.error('performance', ...args), - isEnabled: () => isEnabled('performance'), -}; +// Category-specific loggers generated via factory +export const debugAnimation = createCategoryLogger('animation'); +export const debugMesh = createCategoryLogger('mesh'); +export const debugTerrain = createCategoryLogger('terrain'); +export const debugShaders = createCategoryLogger('shaders'); +export const debugPostProcessing = createCategoryLogger('postProcessing'); +export const debugBuildingPlacement = createCategoryLogger('buildingPlacement'); +export const debugCombat = createCategoryLogger('combat'); +export const debugResources = createCategoryLogger('resources'); +export const debugProduction = createCategoryLogger('production'); +export const debugSpawning = createCategoryLogger('spawning'); +export const debugAI = createCategoryLogger('ai'); +export const debugPathfinding = createCategoryLogger('pathfinding'); +export const debugAssets = createCategoryLogger('assets'); +export const debugInitialization = createCategoryLogger('initialization'); +export const debugAudio = createCategoryLogger('audio'); +export const debugNetworking = createCategoryLogger('networking'); +export const debugPerformance = createCategoryLogger('performance');