From 3fd80cc7a4393157158ee398ff5330180828cbb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 05:25:35 +0000 Subject: [PATCH] fix: add destroyed entity validation across game systems - Add validateEntityAlive() helper to EntityValidator that checks both null and isDestroyed() state for stored entity ID references - Apply systematic isDestroyed() checks to 30+ locations across: - CombatSystem: attack queue, target acquisition, splash damage - ResourceSystem: gather commands, worker update, resource return - ProjectileSystem: homing targets, impact damage, splash damage - AbilitySystem: all ability executions and delayed effects - MovementOrchestrator: attack-move, patrol, attacking units - ProductionSystem: production/cancel/reorder commands, upgrades - Remove unused dead code from graphics settings: - taaHistoryBlendRate from uiStore and PostProcessing - outlineEnabled/outlineStrength from uiStore (never wired up) This prevents potential bugs from operating on destroyed entities during the same tick they're marked for destruction. https://claude.ai/code/session_01UmxaG5s7rB9Y6hcEBxw7Mf --- src/engine/systems/AbilitySystem.ts | 54 +++-- src/engine/systems/CombatSystem.ts | 35 +++- src/engine/systems/ProductionSystem.ts | 28 ++- src/engine/systems/ProjectileSystem.ts | 25 ++- src/engine/systems/ResourceSystem.ts | 30 ++- .../systems/movement/MovementOrchestrator.ts | 16 +- src/rendering/tsl/PostProcessing.ts | 2 - src/store/uiStore.ts | 190 +++++++++++------- src/utils/EntityValidator.ts | 44 +++- 9 files changed, 301 insertions(+), 123 deletions(-) diff --git a/src/engine/systems/AbilitySystem.ts b/src/engine/systems/AbilitySystem.ts index e687212f..71c10d51 100644 --- a/src/engine/systems/AbilitySystem.ts +++ b/src/engine/systems/AbilitySystem.ts @@ -7,6 +7,7 @@ import { Ability, AbilityDefinition } from '../components/Ability'; import { Selectable } from '../components/Selectable'; import { Building } from '../components/Building'; import { distance } from '@/utils/math'; +import { validateEntityAlive } from '@/utils/EntityValidator'; interface AbilityCommand { entityIds: number[]; @@ -48,7 +49,7 @@ export class AbilitySystem extends System { private handleAbilityCommand(command: AbilityCommand): void { for (const entityId of command.entityIds) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'AbilitySystem:handleAbilityCommand')) continue; const ability = entity.get('Ability'); if (!ability) continue; @@ -85,11 +86,19 @@ export class AbilitySystem extends System { case 'ally': if (command.targetEntityId === undefined) return false; const target = this.world.getEntity(command.targetEntityId); - if (!target) return false; + if ( + !validateEntityAlive( + target, + command.targetEntityId, + 'AbilitySystem:validateTarget:target' + ) + ) + return false; // Check range const casterEntity = this.world.getEntity(caster.id); - if (!casterEntity) return false; + if (!validateEntityAlive(casterEntity, caster.id, 'AbilitySystem:validateTarget:caster')) + return false; const casterTransform = casterEntity.get('Transform'); const targetTransform = target.get('Transform'); @@ -119,7 +128,7 @@ export class AbilitySystem extends System { command: AbilityCommand ): void { const casterEntity = this.world.getEntity(caster.id); - if (!casterEntity) return; + if (!validateEntityAlive(casterEntity, caster.id, 'AbilitySystem:executeAbility')) return; const casterTransform = casterEntity.get('Transform'); const casterSelectable = casterEntity.get('Selectable'); @@ -228,7 +237,7 @@ export class AbilitySystem extends System { private executeCombatStim(caster: { id: number }): void { const entity = this.world.getEntity(caster.id); - if (!entity) return; + if (!validateEntityAlive(entity, caster.id, 'AbilitySystem:executeCombatStim')) return; const health = entity.get('Health'); const unit = entity.get('Unit'); @@ -263,7 +272,7 @@ export class AbilitySystem extends System { private executeBombardmentMode(caster: { id: number }): void { const entity = this.world.getEntity(caster.id); - if (!entity) return; + if (!validateEntityAlive(entity, caster.id, 'AbilitySystem:executeBombardmentMode')) return; const unit = entity.get('Unit'); if (!unit) return; @@ -277,7 +286,7 @@ export class AbilitySystem extends System { private executeSnipe(caster: { id: number }, targetId: number, damage: number): void { const target = this.world.getEntity(targetId); - if (!target) return; + if (!validateEntityAlive(target, targetId, 'AbilitySystem:executeSnipe:target')) return; const targetHealth = target.get('Health'); if (!targetHealth) return; @@ -286,7 +295,10 @@ export class AbilitySystem extends System { targetHealth.takeDamage(damage, this.game.getGameTime()); const casterEntity = this.world.getEntity(caster.id); - const casterTransform = casterEntity?.get('Transform'); + // Caster may have been destroyed between ability use and execution + const casterTransform = casterEntity?.isDestroyed() + ? undefined + : casterEntity?.get('Transform'); const targetTransform = target.get('Transform'); this.game.eventBus.emit('combat:attack', { @@ -370,10 +382,13 @@ export class AbilitySystem extends System { private executeNovaCannon(caster: { id: number }, targetId: number, damage: number): void { const target = this.world.getEntity(targetId); - if (!target) return; + if (!validateEntityAlive(target, targetId, 'AbilitySystem:executeNovaCannon:target')) return; const casterEntity = this.world.getEntity(caster.id); - const casterTransform = casterEntity?.get('Transform'); + // Caster may have been destroyed - still proceed with channel but skip position info + const casterTransform = casterEntity?.isDestroyed() + ? undefined + : casterEntity?.get('Transform'); const targetTransform = target.get('Transform'); // Nova Cannon has a 3 second channel time @@ -409,7 +424,7 @@ export class AbilitySystem extends System { private executeTacticalJump(caster: { id: number }, position: { x: number; y: number }): void { const entity = this.world.getEntity(caster.id); - if (!entity) return; + if (!validateEntityAlive(entity, caster.id, 'AbilitySystem:executeTacticalJump')) return; const transform = entity.get('Transform'); if (!transform) return; @@ -432,10 +447,13 @@ export class AbilitySystem extends System { private executePowerCannon(caster: { id: number }, targetId: number, damage: number): void { const target = this.world.getEntity(targetId); - if (!target) return; + if (!validateEntityAlive(target, targetId, 'AbilitySystem:executePowerCannon:target')) return; const casterEntity = this.world.getEntity(caster.id); - const casterTransform = casterEntity?.get('Transform'); + // Caster may have been destroyed - still proceed but skip position info + const casterTransform = casterEntity?.isDestroyed() + ? undefined + : casterEntity?.get('Transform'); const targetTransform = target.get('Transform'); const targetHealth = target.get('Health'); @@ -502,7 +520,7 @@ export class AbilitySystem extends System { private executeSupplyDrop(targetId: number): void { const target = this.world.getEntity(targetId); - if (!target) return; + if (!validateEntityAlive(target, targetId, 'AbilitySystem:executeSupplyDrop')) return; const building = target.get('Building'); if (!building || building.buildingId !== 'supply_cache') return; @@ -527,7 +545,7 @@ export class AbilitySystem extends System { private applyConcussiveShells(targetId: number): void { const target = this.world.getEntity(targetId); - if (!target) return; + if (!validateEntityAlive(target, targetId, 'AbilitySystem:applyConcussiveShells')) return; const unit = target.get('Unit'); if (!unit) return; @@ -549,7 +567,7 @@ export class AbilitySystem extends System { private applyCombatShield(caster: { id: number }): void { const entity = this.world.getEntity(caster.id); - if (!entity) return; + if (!validateEntityAlive(entity, caster.id, 'AbilitySystem:applyCombatShield')) return; const health = entity.get('Health'); if (!health) return; @@ -643,7 +661,9 @@ export class AbilitySystem extends System { if (targetId === undefined || damage === undefined) return; const currentTarget = this.world.getEntity(targetId); - if (!currentTarget) return; + // Target may have been destroyed during the channel time + if (!validateEntityAlive(currentTarget, targetId, 'AbilitySystem:executeNovaCannonImpact')) + return; const targetHealth = currentTarget.get('Health'); if (!targetHealth) return; diff --git a/src/engine/systems/CombatSystem.ts b/src/engine/systems/CombatSystem.ts index 1aee2daf..262ac7a2 100644 --- a/src/engine/systems/CombatSystem.ts +++ b/src/engine/systems/CombatSystem.ts @@ -17,6 +17,7 @@ import { SpatialEntityData, SpatialUnitState } from '../core/SpatialGrid'; import { findBestTarget as findBestTargetShared, isEnemy } from '../combat/TargetAcquisition'; import { distance, clamp } from '@/utils/math'; import { ThrottledCache } from '@/utils/ThrottledCache'; +import { validateEntityAlive } from '@/utils/EntityValidator'; // PERF: Reusable event payload objects to avoid allocation per attack const attackEventPayload = { @@ -350,7 +351,7 @@ export class CombatSystem extends System { for (const entityId of this.combatActiveUnits) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'CombatSystem:rebuildAttackQueue')) continue; const unit = entity.get('Unit'); if (!unit || unit.state === 'dead') continue; @@ -394,7 +395,7 @@ export class CombatSystem extends System { }): void { for (const entityId of command.entityIds) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'CombatSystem:handleAttackCommand')) continue; const unit = entity.get('Unit'); if (!unit) continue; @@ -519,7 +520,7 @@ export class CombatSystem extends System { // PERF OPTIMIZATION: Second pass - target acquisition for combat-active units only for (const entityId of this.combatActiveUnits) { const attacker = this.world.getEntity(entityId); - if (!attacker) continue; + if (!validateEntityAlive(attacker, entityId, 'CombatSystem:targetAcquisition')) continue; const transform = attacker.get('Transform'); const unit = attacker.get('Unit'); @@ -790,7 +791,9 @@ export class CombatSystem extends System { const resource = resourceEntity.get('Resource'); if (resource) { resource.extractorEntityId = null; - debugCombat.log(`CombatSystem: Extractor destroyed, plasma geyser ${buildingComp.linkedResourceId} restored`); + debugCombat.log( + `CombatSystem: Extractor destroyed, plasma geyser ${buildingComp.linkedResourceId} restored` + ); } } } @@ -850,7 +853,11 @@ export class CombatSystem extends System { // Get self's player ID const selfEntity = this.world.getEntity(selfId); - const selfSelectable = selfEntity?.get('Selectable'); + if (!validateEntityAlive(selfEntity, selfId, 'CombatSystem:checkCombatZone')) { + this.combatAwareUnits.delete(selfId); + return false; + } + const selfSelectable = selfEntity.get('Selectable'); if (!selfSelectable) { this.combatAwareUnits.delete(selfId); return false; @@ -876,7 +883,8 @@ export class CombatSystem extends System { for (const entityId of nearbyBuildingIds) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'CombatSystem:checkCombatZone:buildings')) + continue; const selectable = entity.get('Selectable'); const health = entity.get('Health'); @@ -921,7 +929,9 @@ export class CombatSystem extends System { // Get self's player ID const selfEntity = this.world.getEntity(selfId); - const selfSelectable = selfEntity?.get('Selectable'); + if (!validateEntityAlive(selfEntity, selfId, 'CombatSystem:findImmediateAttackTarget')) + return null; + const selfSelectable = selfEntity.get('Selectable'); if (!selfSelectable) return null; // Use shared target acquisition for attack-range search @@ -1000,7 +1010,8 @@ export class CombatSystem extends System { ): number | null { // Get self's player ID const selfEntity = this.world.getEntity(selfId); - const selfSelectable = selfEntity?.get('Selectable'); + if (!validateEntityAlive(selfEntity, selfId, 'CombatSystem:findBestTargetSpatial')) return null; + const selfSelectable = selfEntity.get('Selectable'); if (!selfSelectable) return null; // Use shared target acquisition for sight-range search @@ -1204,7 +1215,8 @@ export class CombatSystem extends System { ): void { // Get attacker's player ID const attackerEntity = this.world.getEntity(attackerId); - const attackerSelectable = attackerEntity?.get('Selectable'); + if (!validateEntityAlive(attackerEntity, attackerId, 'CombatSystem:applySplashDamage')) return; + const attackerSelectable = attackerEntity.get('Selectable'); if (!attackerSelectable) return; // Use spatial grid to find nearby units - much faster than checking all entities @@ -1219,7 +1231,7 @@ export class CombatSystem extends System { if (entityId === attackerId) continue; const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'CombatSystem:applySplashDamage:units')) continue; const transform = entity.get('Transform'); const health = entity.get('Health'); @@ -1277,7 +1289,8 @@ export class CombatSystem extends System { for (const entityId of nearbyBuildingIds) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'CombatSystem:applySplashDamage:buildings')) + continue; const transform = entity.get('Transform'); const health = entity.get('Health'); diff --git a/src/engine/systems/ProductionSystem.ts b/src/engine/systems/ProductionSystem.ts index 6a7c0c26..28ef2fc7 100644 --- a/src/engine/systems/ProductionSystem.ts +++ b/src/engine/systems/ProductionSystem.ts @@ -14,6 +14,7 @@ import { } from '@/data/buildings/dominion'; import { debugProduction, debugSpawning } from '@/utils/debugLogger'; import { EnhancedAISystem } from './EnhancedAISystem'; +import { validateEntityAlive } from '@/utils/EntityValidator'; export class ProductionSystem extends System { public readonly name = 'ProductionSystem'; @@ -60,7 +61,14 @@ export class ProductionSystem extends System { playerId?: string; }): void { const entity = this.world.getEntity(command.entityId); - if (!entity) return; + if ( + !validateEntityAlive( + entity, + command.entityId, + 'ProductionSystem:handleCancelProductionCommand' + ) + ) + return; const building = entity.get('Building'); if (!building) return; @@ -111,7 +119,10 @@ export class ProductionSystem extends System { playerId?: string; }): void { const entity = this.world.getEntity(command.entityId); - if (!entity) return; + if ( + !validateEntityAlive(entity, command.entityId, 'ProductionSystem:handleReorderQueueCommand') + ) + return; const building = entity.get('Building'); if (!building) return; @@ -143,7 +154,7 @@ export class ProductionSystem extends System { for (const entityId of entityIds) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'ProductionSystem:handleTrainCommand')) continue; const building = entity.get('Building'); if (!building || !building.isComplete()) continue; @@ -387,7 +398,10 @@ export class ProductionSystem extends System { // Get the building's owner from its Selectable component const buildingEntity = this.world.getEntity(buildingId); - const selectable = buildingEntity?.get('Selectable'); + // Building should still exist since we're in its production callback + const selectable = buildingEntity?.isDestroyed() + ? undefined + : buildingEntity?.get('Selectable'); const ownerPlayerId = selectable?.playerId; // Spawn multiple units if produceCount > 1 (reactor bonus) @@ -433,7 +447,9 @@ export class ProductionSystem extends System { } else { // This is a research upgrade - emit for Phaser overlay const buildingEntity = this.world.getEntity(buildingId); - const buildingSelectable = buildingEntity?.get('Selectable'); + const buildingSelectable = buildingEntity?.isDestroyed() + ? undefined + : buildingEntity?.get('Selectable'); if (buildingSelectable?.playerId && isLocalPlayer(buildingSelectable.playerId)) { this.game.eventBus.emit('research:complete', { buildingId, @@ -466,7 +482,7 @@ export class ProductionSystem extends System { // Update entity for mesh refresh const entity = this.world.getEntity(buildingId); - if (entity) { + if (validateEntityAlive(entity, buildingId, 'ProductionSystem:handleBuildingUpgradeComplete')) { const health = entity.get('Health'); if (health && newDef.maxHealth) { // Keep current health percentage, apply to new max diff --git a/src/engine/systems/ProjectileSystem.ts b/src/engine/systems/ProjectileSystem.ts index df82252c..1c76fe02 100644 --- a/src/engine/systems/ProjectileSystem.ts +++ b/src/engine/systems/ProjectileSystem.ts @@ -13,6 +13,7 @@ import { getDamageMultiplier } from '@/data/combat/combat'; import { debugCombat as debugProjectile } from '@/utils/debugLogger'; import { isLocalPlayer } from '@/store/gameSetupStore'; import { AssetManager } from '@/assets/AssetManager'; +import { validateEntityAlive } from '@/utils/EntityValidator'; /** * ProjectileSystem - Handles projectile movement and damage application on impact @@ -109,10 +110,16 @@ export class ProjectileSystem extends System { projectile: Projectile, transform: Transform ): void { - // Update target position if target entity still exists + // Update target position if target entity still exists and is not destroyed if (projectile.targetEntityId !== null) { const targetEntity = this.world.getEntity(projectile.targetEntityId); - if (targetEntity) { + if ( + validateEntityAlive( + targetEntity, + projectile.targetEntityId, + 'ProjectileSystem:updateHoming' + ) + ) { const targetTransform = targetEntity.get('Transform'); const targetHealth = targetEntity.get('Health'); const targetUnit = targetEntity.get('Unit'); @@ -258,7 +265,13 @@ export class ProjectileSystem extends System { // NOTE: projectile.damage already has multiplier applied from CombatSystem if (projectile.targetEntityId !== null) { const targetEntity = this.world.getEntity(projectile.targetEntityId); - if (targetEntity) { + if ( + validateEntityAlive( + targetEntity, + projectile.targetEntityId, + 'ProjectileSystem:applyImpactDamage' + ) + ) { const targetHealth = targetEntity.get('Health'); const targetTransform = targetEntity.get('Transform'); const targetSelectable = targetEntity.get('Selectable'); @@ -358,7 +371,8 @@ export class ProjectileSystem extends System { if (entityId === projectile.targetEntityId) continue; const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'ProjectileSystem:applySplashDamage:units')) + continue; const transform = entity.get('Transform'); const health = entity.get('Health'); @@ -434,7 +448,8 @@ export class ProjectileSystem extends System { if (entityId === projectile.targetEntityId) continue; const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'ProjectileSystem:applySplashDamage:buildings')) + continue; const transform = entity.get('Transform'); const health = entity.get('Health'); diff --git a/src/engine/systems/ResourceSystem.ts b/src/engine/systems/ResourceSystem.ts index ae6cf1d2..07213904 100644 --- a/src/engine/systems/ResourceSystem.ts +++ b/src/engine/systems/ResourceSystem.ts @@ -11,6 +11,7 @@ import { debugResources } from '@/utils/debugLogger'; import { isLocalPlayer } from '@/store/gameSetupStore'; import { EnhancedAISystem } from './EnhancedAISystem'; import { distance } from '@/utils/math'; +import { validateEntityAlive } from '@/utils/EntityValidator'; // Mining time in seconds (base value - AI may get speed bonuses) const MINING_TIME = 2.5; @@ -116,7 +117,14 @@ export class ResourceSystem extends System { queue?: boolean; }): void { const targetEntity = this.world.getEntity(command.targetEntityId); - if (!targetEntity) return; + if ( + !validateEntityAlive( + targetEntity, + command.targetEntityId, + 'ResourceSystem:handleGatherCommand' + ) + ) + return; const resource = targetEntity.get('Resource'); if (!resource) return; @@ -143,7 +151,8 @@ export class ResourceSystem extends System { for (const entityId of command.entityIds) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'ResourceSystem:handleGatherCommand:workers')) + continue; const unit = entity.get('Unit'); if (!unit || !unit.isWorker) continue; @@ -394,7 +403,14 @@ export class ResourceSystem extends System { // Check if at resource node if (unit.gatherTargetId !== null) { const resourceEntity = this.world.getEntity(unit.gatherTargetId); - if (!resourceEntity) { + // Validate resource entity exists and is not destroyed + if ( + !validateEntityAlive( + resourceEntity, + unit.gatherTargetId, + 'ResourceSystem:update:gatherTarget' + ) + ) { unit.gatherTargetId = null; unit.isMining = false; unit.miningTimer = 0; @@ -665,7 +681,13 @@ export class ResourceSystem extends System { // Return to gather target if it still exists if (unit.gatherTargetId !== null) { const resourceEntity = this.world.getEntity(unit.gatherTargetId); - if (resourceEntity) { + if ( + validateEntityAlive( + resourceEntity, + unit.gatherTargetId, + 'ResourceSystem:handleResourceReturn' + ) + ) { const resourceTransform = resourceEntity.get('Transform'); const resource = resourceEntity.get('Resource'); diff --git a/src/engine/systems/movement/MovementOrchestrator.ts b/src/engine/systems/movement/MovementOrchestrator.ts index e25bff03..6f659e8d 100644 --- a/src/engine/systems/movement/MovementOrchestrator.ts +++ b/src/engine/systems/movement/MovementOrchestrator.ts @@ -37,6 +37,7 @@ import { UNIT_TURN_RATE, ATTACK_STANDOFF_MULTIPLIER, } from '@/data/movement.config'; +import { validateEntityAlive } from '@/utils/EntityValidator'; import { FlockingBehavior, FlockingEntityCache, FlockingSpatialGrid } from './FlockingBehavior'; import { PathfindingMovement, PathfindingWorld, PathfindingGame } from './PathfindingMovement'; @@ -286,7 +287,8 @@ export class MovementOrchestrator { for (const entityId of entityIds) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'MovementOrchestrator:handleAttackMoveCommand')) + continue; const unit = entity.get('Unit'); const transform = entity.get('Transform'); @@ -332,7 +334,8 @@ export class MovementOrchestrator { for (const entityId of entityIds) { const entity = this.world.getEntity(entityId); - if (!entity) continue; + if (!validateEntityAlive(entity, entityId, 'MovementOrchestrator:handlePatrolCommand')) + continue; const unit = entity.get('Unit'); const transform = entity.get('Transform'); @@ -765,7 +768,14 @@ export class MovementOrchestrator { _useWasmThisFrame: boolean ): { handled: boolean; skipMovement: boolean; targetX: number | null; targetY: number | null } { const targetEntity = this.world.getEntity(unit.targetEntityId!); - if (!targetEntity) { + // Validate target exists and is not destroyed + if ( + !validateEntityAlive( + targetEntity, + unit.targetEntityId!, + 'MovementOrchestrator:processAttackingUnit' + ) + ) { return { handled: false, skipMovement: false, targetX: null, targetY: null }; } diff --git a/src/rendering/tsl/PostProcessing.ts b/src/rendering/tsl/PostProcessing.ts index 5d9b1087..c193cd76 100644 --- a/src/rendering/tsl/PostProcessing.ts +++ b/src/rendering/tsl/PostProcessing.ts @@ -163,7 +163,6 @@ export interface PostProcessingConfig { antiAliasingMode: AntiAliasingMode; fxaaEnabled: boolean; taaEnabled: boolean; - taaHistoryBlendRate: number; taaSharpeningEnabled: boolean; taaSharpeningIntensity: number; upscalingMode: UpscalingMode; @@ -214,7 +213,6 @@ const DEFAULT_CONFIG: PostProcessingConfig = { antiAliasingMode: 'fxaa', fxaaEnabled: true, taaEnabled: false, - taaHistoryBlendRate: 0.1, taaSharpeningEnabled: true, taaSharpeningIntensity: 0.5, upscalingMode: 'off', diff --git a/src/store/uiStore.ts b/src/store/uiStore.ts index c1558161..21d984fd 100644 --- a/src/store/uiStore.ts +++ b/src/store/uiStore.ts @@ -44,19 +44,19 @@ export interface DebugSettings { // Performance metrics for display export interface PerformanceMetrics { - cpuTime: number; // milliseconds spent in JS/game logic - gpuTime: number; // estimated GPU time (frame time - cpu time) - frameTime: number; // total frame time in ms - triangles: number; // triangles rendered this frame - drawCalls: number; // draw calls this frame - renderWidth: number; // actual render width in pixels + cpuTime: number; // milliseconds spent in JS/game logic + gpuTime: number; // estimated GPU time (frame time - cpu time) + frameTime: number; // total frame time in ms + triangles: number; // triangles rendered this frame + drawCalls: number; // draw calls this frame + renderWidth: number; // actual render width in pixels renderHeight: number; // actual render height in pixels displayWidth: number; // display/canvas width in pixels displayHeight: number; // display/canvas height in pixels // GPU indirect rendering status - gpuCullingActive: boolean; // true if GPU culling is being used - gpuIndirectActive: boolean; // true if GPU indirect draw is enabled - gpuManagedUnits: number; // units tracked in GPU buffer + gpuCullingActive: boolean; // true if GPU culling is being used + gpuIndirectActive: boolean; // true if GPU indirect draw is enabled + gpuManagedUnits: number; // units tracked in GPU buffer } // Renderer API type (WebGPU or WebGL) @@ -64,10 +64,10 @@ export type RendererAPI = 'WebGPU' | 'WebGL' | null; // GPU adapter info for display export interface GpuInfo { - name: string; // Device description (e.g., "NVIDIA GeForce RTX 4090") - vendor: string; // Vendor name (e.g., "nvidia", "amd", "intel") - architecture: string; // Architecture (e.g., "ampere") - isIntegrated: boolean; // True if likely an integrated GPU + name: string; // Device description (e.g., "NVIDIA GeForce RTX 4090") + vendor: string; // Vendor name (e.g., "nvidia", "amd", "intel") + architecture: string; // Architecture (e.g., "ampere") + isIntegrated: boolean; // True if likely an integrated GPU } // Anti-aliasing mode selection @@ -98,7 +98,10 @@ export type ResolutionMode = 'native' | 'fixed' | 'percentage'; // Common fixed resolutions export type FixedResolution = '720p' | '1080p' | '1440p' | '4k'; -export const FIXED_RESOLUTIONS: Record = { +export const FIXED_RESOLUTIONS: Record< + FixedResolution, + { width: number; height: number; label: string } +> = { '720p': { width: 1280, height: 720, label: '720p (1280×720)' }, '1080p': { width: 1920, height: 1080, label: '1080p (1920×1080)' }, '1440p': { width: 2560, height: 1440, label: '1440p (2560×1440)' }, @@ -137,7 +140,6 @@ export interface GraphicsSettings { // TAA-specific settings taaEnabled: boolean; // Derived from antiAliasingMode - taaHistoryBlendRate: number; // 0.0-1.0, lower = more smoothing taaSharpeningEnabled: boolean; taaSharpeningIntensity: number; // 0.0-1.0 @@ -194,10 +196,6 @@ export interface GraphicsSettings { // Environment environmentMapEnabled: boolean; - // Outline (selection) - outlineEnabled: boolean; - outlineStrength: number; - // Particles particlesEnabled: boolean; particleDensity: number; @@ -259,7 +257,10 @@ function saveGraphicsSettings(settings: GraphicsSettings, preset: GraphicsPreset } } -function loadGraphicsSettings(): { settings: Partial; preset: GraphicsPresetName } | null { +function loadGraphicsSettings(): { + settings: Partial; + preset: GraphicsPresetName; +} | null { if (typeof window === 'undefined' || typeof localStorage === 'undefined') return null; try { const saved = localStorage.getItem(GRAPHICS_SETTINGS_KEY); @@ -353,7 +354,13 @@ const savedDebugSettings = loadDebugSettings(); // Game overlay types for strategic information display // 'navmesh' shows ACTUAL pathfinding data from Recast Navigation (critical for debugging) // 'resource' highlights mineral fields and gas geysers -export type GameOverlayType = 'none' | 'elevation' | 'threat' | 'navmesh' | 'resource' | 'buildable'; +export type GameOverlayType = + | 'none' + | 'elevation' + | 'threat' + | 'navmesh' + | 'resource' + | 'buildable'; // Extended overlay types including RTS-style tactical features (attack/vision are dynamic, not cycleable) export type ExtendedOverlayType = GameOverlayType | 'attackRange' | 'visionRange'; @@ -474,7 +481,10 @@ export interface UIState { togglePing: () => void; // Graphics settings actions toggleGraphicsOptions: () => void; - setGraphicsSetting: (key: K, value: GraphicsSettings[K]) => void; + setGraphicsSetting: ( + key: K, + value: GraphicsSettings[K] + ) => void; toggleGraphicsSetting: (key: keyof GraphicsSettings) => void; setAntiAliasingMode: (mode: AntiAliasingMode) => void; setUpscalingMode: (mode: UpscalingMode) => void; @@ -588,7 +598,6 @@ export const useUIStore = create((set, get) => ({ antiAliasingMode: 'taa' as AntiAliasingMode, fxaaEnabled: false, // Legacy, derived from antiAliasingMode taaEnabled: true, // Derived from antiAliasingMode - taaHistoryBlendRate: 0.1, // Default blend rate (90% history, 10% current) taaSharpeningEnabled: true, // Counter TAA blur with RCAS taaSharpeningIntensity: 0.5, // Moderate sharpening @@ -645,10 +654,6 @@ export const useUIStore = create((set, get) => ({ // Environment environmentMapEnabled: true, - // Outline (selection) - outlineEnabled: true, - outlineStrength: 2, - // Particles particlesEnabled: true, particleDensity: 7.5, // 5.0 is baseline (1x), 7.5 = 1.5x, range 1-15 @@ -795,10 +800,14 @@ export const useUIStore = create((set, get) => ({ // Save to localStorage const s = get(); saveAudioSettings({ - musicEnabled: s.musicEnabled, soundEnabled: s.soundEnabled, - musicVolume: s.musicVolume, soundVolume: s.soundVolume, - voicesEnabled: s.voicesEnabled, alertsEnabled: s.alertsEnabled, - voiceVolume: s.voiceVolume, alertVolume: s.alertVolume, + musicEnabled: s.musicEnabled, + soundEnabled: s.soundEnabled, + musicVolume: s.musicVolume, + soundVolume: s.soundVolume, + voicesEnabled: s.voicesEnabled, + alertsEnabled: s.alertsEnabled, + voiceVolume: s.voiceVolume, + alertVolume: s.alertVolume, version: AUDIO_SETTINGS_VERSION, }); }, @@ -808,10 +817,14 @@ export const useUIStore = create((set, get) => ({ // Save to localStorage const s = get(); saveAudioSettings({ - musicEnabled: s.musicEnabled, soundEnabled: s.soundEnabled, - musicVolume: s.musicVolume, soundVolume: s.soundVolume, - voicesEnabled: s.voicesEnabled, alertsEnabled: s.alertsEnabled, - voiceVolume: s.voiceVolume, alertVolume: s.alertVolume, + musicEnabled: s.musicEnabled, + soundEnabled: s.soundEnabled, + musicVolume: s.musicVolume, + soundVolume: s.soundVolume, + voicesEnabled: s.voicesEnabled, + alertsEnabled: s.alertsEnabled, + voiceVolume: s.voiceVolume, + alertVolume: s.alertVolume, version: AUDIO_SETTINGS_VERSION, }); }, @@ -820,10 +833,14 @@ export const useUIStore = create((set, get) => ({ set({ soundVolume: clamp(volume, 0, 1) }); const s = get(); saveAudioSettings({ - musicEnabled: s.musicEnabled, soundEnabled: s.soundEnabled, - musicVolume: s.musicVolume, soundVolume: s.soundVolume, - voicesEnabled: s.voicesEnabled, alertsEnabled: s.alertsEnabled, - voiceVolume: s.voiceVolume, alertVolume: s.alertVolume, + musicEnabled: s.musicEnabled, + soundEnabled: s.soundEnabled, + musicVolume: s.musicVolume, + soundVolume: s.soundVolume, + voicesEnabled: s.voicesEnabled, + alertsEnabled: s.alertsEnabled, + voiceVolume: s.voiceVolume, + alertVolume: s.alertVolume, version: AUDIO_SETTINGS_VERSION, }); }, @@ -832,10 +849,14 @@ export const useUIStore = create((set, get) => ({ set({ musicVolume: clamp(volume, 0, 1) }); const s = get(); saveAudioSettings({ - musicEnabled: s.musicEnabled, soundEnabled: s.soundEnabled, - musicVolume: s.musicVolume, soundVolume: s.soundVolume, - voicesEnabled: s.voicesEnabled, alertsEnabled: s.alertsEnabled, - voiceVolume: s.voiceVolume, alertVolume: s.alertVolume, + musicEnabled: s.musicEnabled, + soundEnabled: s.soundEnabled, + musicVolume: s.musicVolume, + soundVolume: s.soundVolume, + voicesEnabled: s.voicesEnabled, + alertsEnabled: s.alertsEnabled, + voiceVolume: s.voiceVolume, + alertVolume: s.alertVolume, version: AUDIO_SETTINGS_VERSION, }); }, @@ -845,10 +866,14 @@ export const useUIStore = create((set, get) => ({ set((state) => ({ voicesEnabled: !state.voicesEnabled })); const s = get(); saveAudioSettings({ - musicEnabled: s.musicEnabled, soundEnabled: s.soundEnabled, - musicVolume: s.musicVolume, soundVolume: s.soundVolume, - voicesEnabled: s.voicesEnabled, alertsEnabled: s.alertsEnabled, - voiceVolume: s.voiceVolume, alertVolume: s.alertVolume, + musicEnabled: s.musicEnabled, + soundEnabled: s.soundEnabled, + musicVolume: s.musicVolume, + soundVolume: s.soundVolume, + voicesEnabled: s.voicesEnabled, + alertsEnabled: s.alertsEnabled, + voiceVolume: s.voiceVolume, + alertVolume: s.alertVolume, version: AUDIO_SETTINGS_VERSION, }); }, @@ -857,10 +882,14 @@ export const useUIStore = create((set, get) => ({ set((state) => ({ alertsEnabled: !state.alertsEnabled })); const s = get(); saveAudioSettings({ - musicEnabled: s.musicEnabled, soundEnabled: s.soundEnabled, - musicVolume: s.musicVolume, soundVolume: s.soundVolume, - voicesEnabled: s.voicesEnabled, alertsEnabled: s.alertsEnabled, - voiceVolume: s.voiceVolume, alertVolume: s.alertVolume, + musicEnabled: s.musicEnabled, + soundEnabled: s.soundEnabled, + musicVolume: s.musicVolume, + soundVolume: s.soundVolume, + voicesEnabled: s.voicesEnabled, + alertsEnabled: s.alertsEnabled, + voiceVolume: s.voiceVolume, + alertVolume: s.alertVolume, version: AUDIO_SETTINGS_VERSION, }); }, @@ -869,10 +898,14 @@ export const useUIStore = create((set, get) => ({ set({ voiceVolume: clamp(volume, 0, 1) }); const s = get(); saveAudioSettings({ - musicEnabled: s.musicEnabled, soundEnabled: s.soundEnabled, - musicVolume: s.musicVolume, soundVolume: s.soundVolume, - voicesEnabled: s.voicesEnabled, alertsEnabled: s.alertsEnabled, - voiceVolume: s.voiceVolume, alertVolume: s.alertVolume, + musicEnabled: s.musicEnabled, + soundEnabled: s.soundEnabled, + musicVolume: s.musicVolume, + soundVolume: s.soundVolume, + voicesEnabled: s.voicesEnabled, + alertsEnabled: s.alertsEnabled, + voiceVolume: s.voiceVolume, + alertVolume: s.alertVolume, version: AUDIO_SETTINGS_VERSION, }); }, @@ -881,10 +914,14 @@ export const useUIStore = create((set, get) => ({ set({ alertVolume: clamp(volume, 0, 1) }); const s = get(); saveAudioSettings({ - musicEnabled: s.musicEnabled, soundEnabled: s.soundEnabled, - musicVolume: s.musicVolume, soundVolume: s.soundVolume, - voicesEnabled: s.voicesEnabled, alertsEnabled: s.alertsEnabled, - voiceVolume: s.voiceVolume, alertVolume: s.alertVolume, + musicEnabled: s.musicEnabled, + soundEnabled: s.soundEnabled, + musicVolume: s.musicVolume, + soundVolume: s.soundVolume, + voicesEnabled: s.voicesEnabled, + alertsEnabled: s.alertsEnabled, + voiceVolume: s.voiceVolume, + alertVolume: s.alertVolume, version: AUDIO_SETTINGS_VERSION, }); }, @@ -893,7 +930,8 @@ export const useUIStore = create((set, get) => ({ togglePing: () => set((state) => ({ showPing: !state.showPing })), - toggleGraphicsOptions: () => set((state) => ({ showGraphicsOptions: !state.showGraphicsOptions })), + toggleGraphicsOptions: () => + set((state) => ({ showGraphicsOptions: !state.showGraphicsOptions })), setRendererAPI: (api) => set({ rendererAPI: api }), @@ -912,7 +950,7 @@ export const useUIStore = create((set, get) => ({ debugInitialization.warn('Failed to load graphics presets, using defaults'); return; } - const config = await response.json() as GraphicsPresetsConfig; + const config = (await response.json()) as GraphicsPresetsConfig; set({ graphicsPresetsConfig: config, graphicsPresetsLoaded: true, @@ -1142,27 +1180,31 @@ export const useUIStore = create((set, get) => ({ toggleDebugMenu: () => set((state) => ({ showDebugMenu: !state.showDebugMenu })), - togglePerformancePanel: () => set((state) => ({ showPerformancePanel: !state.showPerformancePanel })), + togglePerformancePanel: () => + set((state) => ({ showPerformancePanel: !state.showPerformancePanel })), // Debug console actions setConsoleEnabled: (enabled: boolean) => set({ consoleEnabled: enabled }), - toggleConsole: () => set((state) => ({ showConsole: state.consoleEnabled && !state.showConsole })), - setShowConsole: (show: boolean) => set((state) => ({ showConsole: state.consoleEnabled && show })), + toggleConsole: () => + set((state) => ({ showConsole: state.consoleEnabled && !state.showConsole })), + setShowConsole: (show: boolean) => + set((state) => ({ showConsole: state.consoleEnabled && show })), // HUD menu actions setShowOptionsMenu: (show: boolean) => set({ showOptionsMenu: show }), setShowOverlayMenu: (show: boolean) => set({ showOverlayMenu: show }), setShowPlayerStatus: (show: boolean) => set({ showPlayerStatus: show }), - closeAllMenus: () => set({ - showOptionsMenu: false, - showOverlayMenu: false, - showPlayerStatus: false, - showGraphicsOptions: false, - showSoundOptions: false, - showPerformancePanel: false, - showDebugMenu: false, - showConsole: false, - }), + closeAllMenus: () => + set({ + showOptionsMenu: false, + showOverlayMenu: false, + showPlayerStatus: false, + showGraphicsOptions: false, + showSoundOptions: false, + showPerformancePanel: false, + showDebugMenu: false, + showConsole: false, + }), toggleDebugSetting: (key) => { set((state) => ({ diff --git a/src/utils/EntityValidator.ts b/src/utils/EntityValidator.ts index 03588766..557a66ea 100644 --- a/src/utils/EntityValidator.ts +++ b/src/utils/EntityValidator.ts @@ -103,7 +103,7 @@ class EntityValidatorClass { if (sources.length > 0) { debugPerformance.warn( `[EntityValidator] Missing entity references in last ${REPORT_INTERVAL_MS / 1000}s:\n ` + - sources.join('\n ') + sources.join('\n ') ); } @@ -173,3 +173,45 @@ export function validateEntity( ): entity is T { return EntityValidator.validate(entity, entityId, source, currentTick); } + +/** + * Interface for entities that can be destroyed (matches Entity class) + */ +interface Destroyable { + isDestroyed(): boolean; +} + +/** + * Validates that an entity exists AND is not destroyed. + * This is the preferred validation for game systems that store entity IDs + * and need to verify the entity is still alive before operating on it. + * + * @example + * const targetEntity = world.getEntity(unit.targetEntityId); + * if (!validateEntityAlive(targetEntity, unit.targetEntityId, 'CombatSystem')) { + * unit.targetEntityId = null; // Clear stale reference + * continue; + * } + * // targetEntity is now typed as non-null and guaranteed alive + */ +export function validateEntityAlive( + entity: T | null | undefined, + entityId: number, + source: string, + currentTick?: number +): entity is T { + // First check if entity exists + if (entity === null || entity === undefined) { + EntityValidator.validate(entity, entityId, source, currentTick); + return false; + } + + // Then check if destroyed + if (entity.isDestroyed()) { + // Track as missing (destroyed counts as missing for our purposes) + EntityValidator.validate(null, entityId, `${source}:destroyed`, currentTick); + return false; + } + + return true; +}