From c4a1d46bc5035f399019d26432e271ee6e352bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 02:37:28 +0000 Subject: [PATCH] feat: Integrate AI primitives into AI orchestration layer Wire up sophisticated AI primitive systems into the AI orchestration layer: - AICoordinator: Initialize shared InfluenceMap and PositionalAnalysis, add per-player ScoutingMemory, WorkerDistribution, RetreatCoordination, and FormationControl instances - AIScoutingManager: Use ScoutingMemory for enemy intel tracking with confidence decay, strategy inference, and unit type detection - AITacticsManager: Use InfluenceMap for threat analysis, RetreatCoordination for coordinated retreats with rally points, FormationControl for army positioning with concave/box/spread formations - AIEconomyManager: Use WorkerDistribution for optimal worker saturation tracking and transfer recommendations across multiple bases - AIBuildOrderExecutor: Use PositionalAnalysis for strategic defensive building placement near choke points and pre-analyzed expansion locations This eliminates code duplication between src/engine/ai (primitives) and src/engine/systems/ai (orchestration) by properly integrating the primitives' sophisticated algorithms. https://claude.ai/code/session_01DNptntMLovVJiitzsVeDgY --- src/engine/systems/ai/AIBuildOrderExecutor.ts | 151 ++++-- src/engine/systems/ai/AICoordinator.ts | 122 +++++ src/engine/systems/ai/AIEconomyManager.ts | 345 +++++++----- src/engine/systems/ai/AIScoutingManager.ts | 147 +++++- src/engine/systems/ai/AITacticsManager.ts | 494 ++++++++++++------ 5 files changed, 929 insertions(+), 330 deletions(-) diff --git a/src/engine/systems/ai/AIBuildOrderExecutor.ts b/src/engine/systems/ai/AIBuildOrderExecutor.ts index e39e85d6..a1dc71e3 100644 --- a/src/engine/systems/ai/AIBuildOrderExecutor.ts +++ b/src/engine/systems/ai/AIBuildOrderExecutor.ts @@ -6,7 +6,12 @@ * - Data-driven macro rule evaluation and execution * - Building placement and addon construction * - Unit training - * - Research initiation (NEW - implements the stubbed research feature) + * - Research initiation + * + * Integrates PositionalAnalysis primitive for strategic building placement: + * - Uses analyzed expansion locations near mineral clusters + * - Places defensive buildings near choke points + * - Considers terrain when placing buildings * * Uses the data-driven FactionAIConfig for all production decisions. */ @@ -441,7 +446,7 @@ export class AIBuildOrderExecutor { return false; } } else { - buildPos = this.findBuildingSpot(ai.playerId, basePos, buildingDef.width, buildingDef.height, workerId); + buildPos = this.findBuildingSpot(ai.playerId, basePos, buildingDef.width, buildingDef.height, workerId, buildingType); if (!buildPos) { if (shouldLog) { debugAI.log(`[AIBuildOrder] ${ai.playerId}: tryBuildBuilding - no valid building spot for ${buildingType}`); @@ -465,7 +470,8 @@ export class AIBuildOrderExecutor { /** * Find a suitable spot to place a building. - * Searches in expanding rings around the base with increasing density. + * Uses PositionalAnalysis for strategic placement when applicable. + * Defensive buildings are placed near choke points for better defense. * AI buildings are placed with extra spacing for unit pathing. */ private findBuildingSpot( @@ -473,17 +479,52 @@ export class AIBuildOrderExecutor { basePos: { x: number; y: number }, width: number, height: number, - excludeEntityId?: number + excludeEntityId?: number, + buildingType?: string ): { x: number; y: number } | null { - const offsets: Array<{ x: number; y: number }> = []; - - // Extra spacing between AI buildings for unit pathing (2 units on each side) + const positionalAnalysis = this.coordinator.getPositionalAnalysis(); const AI_BUILDING_SPACING = 2; - // Generate offsets in expanding rings around the base - // Expanded range: 5-40 units with more angles per ring for better coverage + // Check if this is a defensive building that should be placed near chokes + const defensiveBuildings = ['bunker', 'turret', 'missile_turret', 'siege_turret', 'photon_cannon']; + const isDefensiveBuilding = buildingType && defensiveBuildings.includes(buildingType); + + if (isDefensiveBuilding) { + // Try to place near a choke point for strategic defense + const chokePoints = positionalAnalysis.getChokePoints(); + for (const choke of chokePoints) { + // Only consider chokes within reasonable range of base + const distToBase = Math.sqrt( + Math.pow(choke.x - basePos.x, 2) + + Math.pow(choke.y - basePos.y, 2) + ); + if (distToBase > 40) continue; + + // Try positions near the choke point + for (let offset = 2; offset <= 8; offset += 2) { + for (let angle = 0; angle < 8; angle++) { + const theta = (angle * Math.PI * 2) / 8; + const pos = { + x: choke.x + Math.cos(theta) * offset, + y: choke.y + Math.sin(theta) * offset, + }; + + if (!this.game.isValidBuildingPlacement(pos.x, pos.y, width, height, excludeEntityId, true)) { + continue; + } + if (this.hasAdequateBuildingSpacing(pos.x, pos.y, width, height, AI_BUILDING_SPACING)) { + debugAI.log(`[AIBuildOrder] Placing ${buildingType} near choke point at (${pos.x.toFixed(0)}, ${pos.y.toFixed(0)})`); + return pos; + } + } + } + } + } + + // Default placement: expanding rings around base + const offsets: Array<{ x: number; y: number }> = []; + for (let radius = 5; radius <= 40; radius += 2) { - // More angles at larger radii for better coverage const angleCount = radius <= 12 ? 8 : radius <= 24 ? 12 : 16; for (let angle = 0; angle < angleCount; angle++) { const theta = (angle * Math.PI * 2) / angleCount + this.coordinator.getRandom(playerId).next() * 0.3; @@ -493,8 +534,7 @@ export class AIBuildOrderExecutor { } } - // Shuffle offsets for variety (but keep closer positions biased toward front) - // Shuffle in chunks to prefer closer positions while still adding variety + // Shuffle in chunks for variety while preferring closer positions const chunkSize = 24; const random = this.coordinator.getRandom(playerId); for (let chunkStart = 0; chunkStart < offsets.length; chunkStart += chunkSize) { @@ -505,14 +545,11 @@ export class AIBuildOrderExecutor { } } - // Try each offset until we find a valid spot with spacing for (const offset of offsets) { const pos = { x: basePos.x + offset.x, y: basePos.y + offset.y }; - // Check core validity first (skip unit collision - units will be pushed away) if (!this.game.isValidBuildingPlacement(pos.x, pos.y, width, height, excludeEntityId, true)) { continue; } - // Check for adequate spacing from other buildings for unit pathing if (this.hasAdequateBuildingSpacing(pos.x, pos.y, width, height, AI_BUILDING_SPACING)) { return pos; } @@ -1198,15 +1235,14 @@ export class AIBuildOrderExecutor { /** * Find a suitable expansion location (near mineral patches). + * Uses PositionalAnalysis for pre-analyzed expansion locations when available. */ private findExpansionLocation(ai: AIPlayer): { x: number; y: number } | null { - const resources = this.coordinator.getCachedResources(); - const buildings = this.coordinator.getCachedBuildingsWithTransform(); - - // Find mineral clusters not near existing bases + const positionalAnalysis = this.coordinator.getPositionalAnalysis(); const existingBases = this.coordinator.getAIBasePositions(ai); + const buildings = this.coordinator.getCachedBuildingsWithTransform(); - // Also consider enemy bases to avoid + // Get enemy bases to avoid const enemyBases: Array<{ x: number; y: number }> = []; for (const entity of buildings) { const selectable = entity.get('Selectable')!; @@ -1219,7 +1255,63 @@ export class AIBuildOrderExecutor { } } - // Find mineral clusters + // First try to use PositionalAnalysis expansion locations + const expansionLocations = positionalAnalysis.getExpansionLocations(); + + if (expansionLocations.length > 0) { + // Score each expansion location + const scoredLocations = expansionLocations.map(loc => { + let score = 100; + + // Penalize if too close to existing bases + for (const base of existingBases) { + const dist = Math.sqrt(Math.pow(loc.x - base.x, 2) + Math.pow(loc.y - base.y, 2)); + if (dist < 30) { + score -= 1000; // Disqualify + } else if (dist < 50) { + score -= 50; + } + } + + // Penalize if close to enemy bases + for (const enemyBase of enemyBases) { + const dist = Math.sqrt(Math.pow(loc.x - enemyBase.x, 2) + Math.pow(loc.y - enemyBase.y, 2)); + if (dist < 25) { + score -= 500; + } else if (dist < 40) { + score -= 100; + } + } + + // Prefer closer expansions (shorter travel distance for workers) + const distToMain = existingBases.length > 0 + ? Math.sqrt(Math.pow(loc.x - existingBases[0].x, 2) + Math.pow(loc.y - existingBases[0].y, 2)) + : 0; + score -= distToMain * 0.5; + + return { location: loc, score }; + }); + + // Sort by score descending + scoredLocations.sort((a, b) => b.score - a.score); + + // Try each location + for (const { location, score } of scoredLocations) { + if (score < 0) continue; // Skip disqualified locations + + const buildPos = { x: location.x + 5, y: location.y + 5 }; + const buildingDef = BUILDING_DEFINITIONS[ai.config!.roles.mainBase]; + + if (this.game.isValidBuildingPlacement(buildPos.x, buildPos.y, buildingDef.width, buildingDef.height, undefined, true)) { + debugAI.log(`[AIBuildOrder] Using analyzed expansion location at (${buildPos.x.toFixed(0)}, ${buildPos.y.toFixed(0)})`); + return buildPos; + } + } + } + + // Fallback: manual mineral cluster detection + const resources = this.coordinator.getCachedResources(); + interface MineralCluster { x: number; y: number; @@ -1231,7 +1323,7 @@ export class AIBuildOrderExecutor { const visited = new Set(); for (const entity of resources) { - const resource = entity.get('Resource'); + const resource = entity.get('Resource'); const transform = entity.get('Transform'); if (!resource || !transform) continue; @@ -1251,16 +1343,14 @@ export class AIBuildOrderExecutor { distanceToNearestBase = Math.min(distanceToNearestBase, dist); } - // Skip if too close to existing base if (distanceToNearestBase < 30) continue; - // Check distance to enemy bases + // Check enemy proximity let tooCloseToEnemy = false; for (const enemyBase of enemyBases) { const dx = transform.x - enemyBase.x; const dy = transform.y - enemyBase.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < 25) { + if (Math.sqrt(dx * dx + dy * dy) < 25) { tooCloseToEnemy = true; break; } @@ -1270,7 +1360,7 @@ export class AIBuildOrderExecutor { // Count nearby minerals let mineralCount = 0; for (const other of resources) { - const otherResource = other.get('Resource'); + const otherResource = other.get('Resource'); const otherTransform = other.get('Transform'); if (!otherResource || !otherTransform) continue; @@ -1293,7 +1383,7 @@ export class AIBuildOrderExecutor { } } - // Sort by mineral count (descending) then by distance (prefer closer) + // Sort by mineral count then distance mineralClusters.sort((a, b) => { if (b.mineralCount !== a.mineralCount) { return b.mineralCount - a.mineralCount; @@ -1301,13 +1391,10 @@ export class AIBuildOrderExecutor { return a.distanceToNearestBase - b.distanceToNearestBase; }); - // Return first valid location for (const cluster of mineralClusters) { - // Offset slightly from mineral cluster center const buildPos = { x: cluster.x + 5, y: cluster.y + 5 }; - - // Check if valid placement const buildingDef = BUILDING_DEFINITIONS[ai.config!.roles.mainBase]; + if (this.game.isValidBuildingPlacement(buildPos.x, buildPos.y, buildingDef.width, buildingDef.height, undefined, true)) { return buildPos; } diff --git a/src/engine/systems/ai/AICoordinator.ts b/src/engine/systems/ai/AICoordinator.ts index a01b5706..7cc5ba33 100644 --- a/src/engine/systems/ai/AICoordinator.ts +++ b/src/engine/systems/ai/AICoordinator.ts @@ -7,6 +7,14 @@ * - AITacticsManager: Combat state, attack/defend/harass execution * - AIScoutingManager: Map exploration, intel gathering * + * Integrates AI primitive systems for sophisticated decision-making: + * - InfluenceMap: Spatial threat tracking for pathfinding and positioning + * - PositionalAnalysis: Map terrain analysis for chokes, expansions + * - ScoutingMemory: Enemy intel tracking with confidence decay + * - WorkerDistribution: Optimal worker saturation across bases + * - RetreatCoordination: Coordinated army retreat with rally points + * - FormationControl: Army positioning and formation management + * * This replaces the monolithic EnhancedAISystem with a modular architecture. */ @@ -41,6 +49,14 @@ import { AIBuildOrderExecutor } from './AIBuildOrderExecutor'; import { AITacticsManager } from './AITacticsManager'; import { AIScoutingManager } from './AIScoutingManager'; +// Import AI primitives for sophisticated decision-making +import { InfluenceMap } from '../../ai/InfluenceMap'; +import { PositionalAnalysis } from '../../ai/PositionalAnalysis'; +import { ScoutingMemory } from '../../ai/ScoutingMemory'; +import { WorkerDistribution } from '../../ai/WorkerDistribution'; +import { RetreatCoordination } from '../../ai/RetreatCoordination'; +import { FormationControl } from '../../ai/FormationControl'; + export type AIState = 'building' | 'expanding' | 'attacking' | 'defending' | 'scouting' | 'harassing'; export type { AIDifficulty }; @@ -216,6 +232,12 @@ export interface AIPlayer { // Research tracking completedResearch: Set; researchInProgress: Map; // researchId -> buildingId + + // AI Primitive instances (per-player) + scoutingMemory: ScoutingMemory; + workerDistribution: WorkerDistribution; + retreatCoordinator: RetreatCoordination; + formationControl: FormationControl; } // Entity query cache - cleared each update cycle @@ -254,6 +276,11 @@ export class AICoordinator extends System { private tacticsManager: AITacticsManager; private scoutingManager: AIScoutingManager; + // Shared AI primitives (across all AI players) + private influenceMap: InfluenceMap; + private positionalAnalysis: PositionalAnalysis; + private positionalAnalysisInitialized: boolean = false; + constructor(game: Game, difficulty: AIDifficulty = 'medium') { super(game); this.defaultDifficulty = difficulty; @@ -264,6 +291,14 @@ export class AICoordinator extends System { this.tacticsManager = new AITacticsManager(game, this); this.scoutingManager = new AIScoutingManager(game, this); + // Initialize shared AI primitives + this.influenceMap = new InfluenceMap(game.config.mapWidth, game.config.mapHeight, 4); + this.positionalAnalysis = new PositionalAnalysis( + game.config.mapWidth, + game.config.mapHeight, + 4 // Cell size matching influence map + ); + // Wire up subsystem dependencies this.buildOrderExecutor.setEconomyManager(this.economyManager); @@ -360,6 +395,56 @@ export class AICoordinator extends System { return this.entityCache.resources; } + // === Shared AI Primitives Accessors === + + /** Get the shared influence map for spatial threat analysis */ + public getInfluenceMap(): InfluenceMap { + return this.influenceMap; + } + + /** Get the shared positional analysis for map terrain data */ + public getPositionalAnalysis(): PositionalAnalysis { + return this.positionalAnalysis; + } + + /** + * Update shared AI primitives with current game state. + * Called once per update cycle, before individual AI updates. + */ + private updateSharedPrimitives(): void { + // Initialize positional analysis on first update (needs passability data) + if (!this.positionalAnalysisInitialized) { + this.initializePositionalAnalysis(); + this.positionalAnalysisInitialized = true; + } + + // Update influence map with current unit positions + this.updateInfluenceMap(); + } + + /** + * Initialize positional analysis with map terrain data. + * Analyzes the map for choke points, expansion locations, etc. + */ + private initializePositionalAnalysis(): void { + // Analyze map terrain using the World for building/obstacle detection + this.positionalAnalysis.analyzeMap(this.world); + + const chokePoints = this.positionalAnalysis.getChokePoints(); + const expansionLocations = this.positionalAnalysis.getExpansionLocations(); + + debugAI.log(`[AICoordinator] Positional analysis initialized: ${chokePoints.length} choke points, ${expansionLocations.length} expansion locations`); + } + + /** + * Update influence map with current unit and building positions. + */ + private updateInfluenceMap(): void { + const currentTick = this.game.getCurrentTick(); + // InfluenceMap.update handles all influence tracking internally + this.influenceMap.update(this.world, currentTick); + } + // === Public API === public registerAI( @@ -400,6 +485,32 @@ export class AICoordinator extends System { const difficultySettings = factionConfig.difficultyConfig[difficulty]; + // Initialize per-player AI primitives + const scoutingMemory = new ScoutingMemory(playerId); + + const workerDistribution = new WorkerDistribution({ + workersPerMineral: 2, + workersPerGas: 3, + baseRadius: 12, + oversaturationThreshold: 1.3, + undersaturationThreshold: 0.7, + }); + + const retreatCoordinator = new RetreatCoordination({ + healthThreshold: 0.3, + strengthRetreatRatio: 0.6, + reengageRatio: 0.9, + minRetreatTicks: 60, + rallyDistance: 15, + }); + + const formationControl = new FormationControl({ + unitSpacing: 1.5, + maxConcaveAngle: Math.PI * 0.6, + rangedOffset: 3, + splashSpread: 2.5, + }); + this.aiPlayers.set(playerId, { playerId, faction, @@ -463,6 +574,12 @@ export class AICoordinator extends System { completedResearch: new Set(), researchInProgress: new Map(), + + // AI Primitive instances + scoutingMemory, + workerDistribution, + retreatCoordinator, + formationControl, }); } @@ -809,6 +926,11 @@ export class AICoordinator extends System { debugAI.log(`[AICoordinator] Tick 1: Registered AI players: ${Array.from(this.aiPlayers.keys()).join(', ') || '(none)'}`); } + // Update shared AI primitives once per cycle + if (this.aiPlayers.size > 0) { + this.updateSharedPrimitives(); + } + for (const [playerId, ai] of this.aiPlayers) { const actionDelay = this.getActionDelay(ai.difficulty); if (currentTick - ai.lastActionTick < actionDelay) continue; diff --git a/src/engine/systems/ai/AIEconomyManager.ts b/src/engine/systems/ai/AIEconomyManager.ts index 39f7def0..fb40276e 100644 --- a/src/engine/systems/ai/AIEconomyManager.ts +++ b/src/engine/systems/ai/AIEconomyManager.ts @@ -7,6 +7,11 @@ * - Resume incomplete building construction * - Repair of damaged buildings and mechanical units * + * Integrates WorkerDistribution primitive for optimal saturation: + * - Tracks worker counts per base + * - Calculates transfer recommendations + * - Manages optimal workers per mineral/gas + * * Uses simulation-based economy (real worker gathering, no passive income). */ @@ -21,10 +26,6 @@ import { Game } from '../../core/Game'; import { debugAI } from '@/utils/debugLogger'; import type { AICoordinator, AIPlayer } from './AICoordinator'; -// Optimal workers per resource patch (standard RTS saturation model) -const OPTIMAL_WORKERS_PER_MINERAL = 2; -const OPTIMAL_WORKERS_PER_VESPENE = 3; - export class AIEconomyManager { private game: Game; private coordinator: AICoordinator; @@ -43,7 +44,6 @@ export class AIEconomyManager { /** * Find an available worker for the AI to assign to construction. * Prefers idle workers, then gathering workers, then moving workers. - * Single-pass implementation for performance. */ public findAvailableWorker(playerId: string): number | null { const units = this.coordinator.getCachedUnits(); @@ -61,9 +61,8 @@ export class AIEconomyManager { if (!unit.isWorker) continue; if (health.isDead()) continue; - // Track by priority - idle > gathering > moving if (unit.state === 'idle') { - return entity.id; // Best priority - return immediately + return entity.id; } else if (unit.state === 'gathering' && gatheringWorker === null) { gatheringWorker = entity.id; } else if (unit.state === 'moving' && movingWorker === null) { @@ -76,7 +75,6 @@ export class AIEconomyManager { /** * Find an available worker that's not already building. - * Stricter than findAvailableWorker - excludes workers in 'building' state. */ public findAvailableWorkerNotBuilding(playerId: string): number | null { const units = this.coordinator.getCachedUnits(); @@ -118,7 +116,6 @@ export class AIEconomyManager { /** * Find incomplete buildings (paused or waiting_for_worker) that need workers assigned. - * Returns buildings sorted by progress (highest first). */ public findIncompleteBuildings(playerId: string): Array<{ buildingId: number; progress: number }> { const buildings = this.coordinator.getCachedBuildingsWithTransform(); @@ -141,7 +138,6 @@ export class AIEconomyManager { } } - // Sort by progress descending (prioritize nearly complete buildings) incomplete.sort((a, b) => b.progress - a.progress); return incomplete; @@ -149,7 +145,6 @@ export class AIEconomyManager { /** * Try to resume construction on incomplete buildings. - * Returns true if a worker was assigned. */ public tryResumeIncompleteBuildings(ai: AIPlayer): boolean { const incompleteBuildings = this.findIncompleteBuildings(ai.playerId); @@ -181,7 +176,6 @@ export class AIEconomyManager { * Assign workers to repair damaged buildings and mechanical units. */ public assignWorkersToRepair(ai: AIPlayer): void { - // Find damaged buildings that need repair (below 90% health) const damagedBuildings: Array<{ entityId: number; x: number; y: number; healthPercent: number }> = []; const buildings = this.coordinator.getCachedBuildingsWithTransform(); @@ -206,7 +200,6 @@ export class AIEconomyManager { } } - // Find damaged mechanical units (below 90% health) const damagedUnits: Array<{ entityId: number; x: number; y: number; healthPercent: number }> = []; const units = this.coordinator.getCachedUnitsWithTransform(); @@ -234,11 +227,9 @@ export class AIEconomyManager { if (damagedBuildings.length === 0 && damagedUnits.length === 0) return; - // Sort by health (most damaged first) damagedBuildings.sort((a, b) => a.healthPercent - b.healthPercent); damagedUnits.sort((a, b) => a.healthPercent - b.healthPercent); - // Find available workers for repair const availableWorkers: Array<{ entityId: number; x: number; y: number; isIdle: boolean }> = []; for (const entity of units) { @@ -270,13 +261,10 @@ export class AIEconomyManager { if (availableWorkers.length === 0) return; - // Sort workers - idle first availableWorkers.sort((a, b) => (b.isIdle ? 1 : 0) - (a.isIdle ? 1 : 0)); - // Assign workers to repair targets let workerIndex = 0; - // Repair critically damaged buildings first (below 50%) for (const building of damagedBuildings) { if (building.healthPercent < 0.5 && workerIndex < availableWorkers.length) { const worker = availableWorkers[workerIndex++]; @@ -287,7 +275,6 @@ export class AIEconomyManager { } } - // Then repair other damaged buildings for (const building of damagedBuildings) { if (building.healthPercent >= 0.5 && workerIndex < availableWorkers.length) { const worker = availableWorkers[workerIndex++]; @@ -298,7 +285,6 @@ export class AIEconomyManager { } } - // Finally repair damaged mechanical units for (const unit of damagedUnits) { if (workerIndex < availableWorkers.length) { const worker = availableWorkers[workerIndex++]; @@ -310,18 +296,53 @@ export class AIEconomyManager { } } - // === Resource Gathering === + // === Resource Gathering with WorkerDistribution === + + /** + * Get saturation summary from WorkerDistribution. + */ + private getSaturationSummary(ai: AIPlayer): { + totalMineralWorkers: number; + optimalMineralWorkers: number; + totalGasWorkers: number; + optimalGasWorkers: number; + } { + const saturations = ai.workerDistribution.getSaturations(ai.playerId); + let totalMineralWorkers = 0; + let optimalMineralWorkers = 0; + let totalGasWorkers = 0; + let optimalGasWorkers = 0; + + for (const saturation of saturations) { + totalMineralWorkers += saturation.mineralWorkers; + optimalMineralWorkers += saturation.optimalMineralWorkers; + totalGasWorkers += saturation.gasWorkers; + optimalGasWorkers += saturation.optimalGasWorkers; + } + + return { + totalMineralWorkers, + optimalMineralWorkers, + totalGasWorkers, + optimalGasWorkers, + }; + } /** * Find idle workers and send them to gather minerals or vespene. - * Uses optimal saturation targeting and considers all AI bases. + * Uses WorkerDistribution primitive for optimal saturation management. */ public assignIdleWorkersToGather(ai: AIPlayer): void { const config = ai.config!; const baseTypes = config.roles.baseTypes; + const workerDistribution = ai.workerDistribution; + const currentTick = this.game.getCurrentTick(); - // Find ALL AI base positions (main and expansions) - const basePositions: Array<{ x: number; y: number }> = []; + // Update WorkerDistribution with current game state (handles all tracking internally) + workerDistribution.update(this.world, ai.playerId, currentTick); + + // Find ALL AI base positions + const basePositions: Array<{ entityId: number; x: number; y: number }> = []; const buildings = this.coordinator.getCachedBuildingsWithTransform(); for (const entity of buildings) { @@ -330,8 +351,9 @@ export class AIEconomyManager { const transform = entity.get('Transform')!; if (selectable.playerId !== ai.playerId) continue; + if (!building.isComplete()) continue; if (baseTypes.includes(building.buildingId)) { - basePositions.push({ x: transform.x, y: transform.y }); + basePositions.push({ entityId: entity.id, x: transform.x, y: transform.y }); } } @@ -340,10 +362,22 @@ export class AIEconomyManager { return; } - // Find mineral patches near ANY base with their current saturation + // Get transfer recommendations from WorkerDistribution + const transfers = workerDistribution.getPendingTransfers(ai.playerId); + + // Build resource lookup maps const resources = this.coordinator.getCachedResources(); - const nearbyMinerals: Array<{ entityId: number; x: number; y: number; distance: number; currentWorkers: number }> = []; + const baseToResources = new Map; + refineries: Array<{ entityId: number; resourceEntityId: number; currentWorkers: number }>; + }>(); + + // Initialize resource maps for each base + for (const base of basePositions) { + baseToResources.set(base.entityId, { minerals: [], refineries: [] }); + } + // Populate mineral data for (const entity of resources) { const resource = entity.get('Resource'); const transform = entity.get('Transform'); @@ -352,38 +386,37 @@ export class AIEconomyManager { if (resource.resourceType !== 'minerals') continue; if (resource.isDepleted()) continue; - // Check distance to ANY base - let minDistance = Infinity; - for (const basePos of basePositions) { - const dx = transform.x - basePos.x; - const dy = transform.y - basePos.y; + // Find nearest base + let nearestBase: { entityId: number; distance: number } | null = null; + for (const base of basePositions) { + const dx = transform.x - base.x; + const dy = transform.y - base.y; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance < minDistance) { - minDistance = distance; + if (distance < 30 && (!nearestBase || distance < nearestBase.distance)) { + nearestBase = { entityId: base.entityId, distance }; } } - if (minDistance < 30) { - nearbyMinerals.push({ - entityId: entity.id, - x: transform.x, - y: transform.y, - distance: minDistance, - currentWorkers: resource.getCurrentGatherers() - }); + if (nearestBase) { + const baseResources = baseToResources.get(nearestBase.entityId); + if (baseResources) { + baseResources.minerals.push({ + entityId: entity.id, + x: transform.x, + y: transform.y, + currentWorkers: resource.getCurrentGatherers(), + }); + } } } - // Find AI's completed refineries for vespene harvesting - const refineries: Array<{ entityId: number; resourceEntityId: number; currentWorkers: number }> = []; + // Populate refinery data const extractorBuildings = this.world.getEntitiesWith('Building', 'Selectable', 'Transform'); - - // Build map of extractorEntityId -> resource for O(1) lookup const extractorToResource = new Map(); + for (const resEntity of resources) { const resource = resEntity.get('Resource'); - if (!resource) continue; - if (resource.resourceType !== 'vespene') continue; + if (!resource || resource.resourceType !== 'vespene') continue; if (resource.extractorEntityId !== null) { extractorToResource.set(resource.extractorEntityId, { entity: resEntity, resource }); } @@ -392,45 +425,57 @@ export class AIEconomyManager { for (const entity of extractorBuildings) { const building = entity.get('Building'); const selectable = entity.get('Selectable'); + const transform = entity.get('Transform'); - if (!building || !selectable) continue; + if (!building || !selectable || !transform) continue; if (selectable.playerId !== ai.playerId) continue; if (building.buildingId !== config.roles.gasExtractor) continue; if (!building.isComplete()) continue; const vespeneData = extractorToResource.get(entity.id); - if (vespeneData) { - refineries.push({ - entityId: entity.id, - resourceEntityId: vespeneData.entity.id, - currentWorkers: vespeneData.resource.getCurrentGatherers() - }); + if (!vespeneData) continue; + + // Find nearest base + let nearestBase: { entityId: number; distance: number } | null = null; + for (const base of basePositions) { + const dx = transform.x - base.x; + const dy = transform.y - base.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < 30 && (!nearestBase || distance < nearestBase.distance)) { + nearestBase = { entityId: base.entityId, distance }; + } + } + + if (nearestBase) { + const baseResources = baseToResources.get(nearestBase.entityId); + if (baseResources) { + baseResources.refineries.push({ + entityId: entity.id, + resourceEntityId: vespeneData.entity.id, + currentWorkers: vespeneData.resource.getCurrentGatherers(), + }); + } } } - // Find idle AI workers and count workers assigned to each resource - const units = this.coordinator.getCachedUnits(); - const idleWorkers: number[] = []; + // Find idle workers + const units = this.coordinator.getCachedUnitsWithTransform(); + const idleWorkers: Array<{ entityId: number; x: number; y: number }> = []; const workerStates: Record = {}; - const workersMovingToResource: Map = new Map(); for (const entity of units) { const selectable = entity.get('Selectable'); const unit = entity.get('Unit'); const health = entity.get('Health'); + const transform = entity.get('Transform'); - if (!selectable || !unit || !health) continue; + if (!selectable || !unit || !health || !transform) continue; if (selectable.playerId !== ai.playerId) continue; if (!unit.isWorker) continue; if (health.isDead()) continue; workerStates[unit.state] = (workerStates[unit.state] || 0) + 1; - if (unit.gatherTargetId !== null && (unit.state === 'moving' || unit.state === 'gathering')) { - const count = workersMovingToResource.get(unit.gatherTargetId) || 0; - workersMovingToResource.set(unit.gatherTargetId, count + 1); - } - const isIdle = unit.state === 'idle'; const isMovingNoTarget = unit.state === 'moving' && unit.targetX === null && @@ -438,101 +483,142 @@ export class AIEconomyManager { unit.gatherTargetId === null; if (isIdle || isMovingNoTarget) { - idleWorkers.push(entity.id); + idleWorkers.push({ + entityId: entity.id, + x: transform.x, + y: transform.y, + }); } } - // Update worker counts with workers moving to each resource - for (const refinery of refineries) { - const movingCount = workersMovingToResource.get(refinery.resourceEntityId) || 0; - refinery.currentWorkers = Math.max(refinery.currentWorkers, movingCount); - } - - for (const mineral of nearbyMinerals) { - const movingCount = workersMovingToResource.get(mineral.entityId) || 0; - mineral.currentWorkers = Math.max(mineral.currentWorkers, movingCount); - } - - if (nearbyMinerals.length === 0 && refineries.length === 0) return; - // Debug log periodically if (this.game.getCurrentTick() % 200 === 0) { const statesStr = Object.entries(workerStates).map(([k, v]) => `${k}:${v}`).join(', '); - const totalMineralWorkers = nearbyMinerals.reduce((sum, m) => sum + m.currentWorkers, 0); - const totalGasWorkers = refineries.reduce((sum, r) => sum + r.currentWorkers, 0); - debugAI.log(`[AIEconomy] ${ai.playerId}: workers=[${statesStr}], idle=${idleWorkers.length}, minerals=${totalMineralWorkers}/${nearbyMinerals.length * OPTIMAL_WORKERS_PER_MINERAL}, gas=${totalGasWorkers}/${refineries.length * OPTIMAL_WORKERS_PER_VESPENE}`); + const saturationSummary = this.getSaturationSummary(ai); + debugAI.log( + `[AIEconomy] ${ai.playerId}: workers=[${statesStr}], idle=${idleWorkers.length}, ` + + `saturation: ${saturationSummary.totalMineralWorkers}/${saturationSummary.optimalMineralWorkers} minerals, ` + + `${saturationSummary.totalGasWorkers}/${saturationSummary.optimalGasWorkers} gas` + ); } - // Sort minerals by workers first (fewest first), then distance - nearbyMinerals.sort((a, b) => { - if (a.currentWorkers !== b.currentWorkers) { - return a.currentWorkers - b.currentWorkers; - } - return a.distance - b.distance; - }); + // Process worker transfers first + for (const transfer of transfers) { + // The transfer already specifies which worker to move + const workerId = transfer.workerId; + const workerIndex = idleWorkers.findIndex(w => w.entityId === workerId); + if (workerIndex === -1) continue; - // Pre-compute closest mineral for fallback case - const closestMineral = nearbyMinerals.length > 0 - ? nearbyMinerals.reduce((closest, m) => m.distance < closest.distance ? m : closest) - : null; + const worker = idleWorkers[workerIndex]; - // Sort refineries by current workers - refineries.sort((a, b) => a.currentWorkers - b.currentWorkers); + // Find destination base + const destBase = basePositions.find(b => b.entityId === transfer.toBase); + if (!destBase) continue; - // Track indices into sorted arrays - let gasIndex = 0; - let mineralIndex = 0; - let oversatIndex = 0; + const destResources = baseToResources.get(transfer.toBase); + if (!destResources) continue; - // Assign idle workers using optimal saturation - for (const workerId of idleWorkers) { - // Priority 1: Fill undersaturated gas (vespene is more valuable) - while (gasIndex < refineries.length && refineries[gasIndex].currentWorkers >= OPTIMAL_WORKERS_PER_VESPENE) { - gasIndex++; - } - if (gasIndex < refineries.length) { - const undersaturatedGas = refineries[gasIndex]; + // If transfer has a specific target resource, use it + if (transfer.targetResource !== null) { this.game.eventBus.emit('command:gather', { entityIds: [workerId], - targetEntityId: undersaturatedGas.resourceEntityId, + targetEntityId: transfer.targetResource, }); - undersaturatedGas.currentWorkers++; + idleWorkers.splice(workerIndex, 1); continue; } - // Priority 2: Fill undersaturated minerals (patches with < 2 workers) - while (mineralIndex < nearbyMinerals.length && nearbyMinerals[mineralIndex].currentWorkers >= OPTIMAL_WORKERS_PER_MINERAL) { - mineralIndex++; - } - if (mineralIndex < nearbyMinerals.length) { - const undersaturatedMineral = nearbyMinerals[mineralIndex]; + // Otherwise assign to undersaturated resource at destination + const refinery = destResources.refineries.find(r => r.currentWorkers < 3); + if (refinery) { this.game.eventBus.emit('command:gather', { entityIds: [workerId], - targetEntityId: undersaturatedMineral.entityId, + targetEntityId: refinery.resourceEntityId, }); - undersaturatedMineral.currentWorkers++; + refinery.currentWorkers++; + idleWorkers.splice(workerIndex, 1); continue; } - // Priority 3: If all minerals are at optimal, allow 3rd worker - while (oversatIndex < nearbyMinerals.length && nearbyMinerals[oversatIndex].currentWorkers >= 3) { - oversatIndex++; - } - if (oversatIndex < nearbyMinerals.length) { - const mineralWithRoom = nearbyMinerals[oversatIndex]; + const mineral = destResources.minerals.find(m => m.currentWorkers < 2); + if (mineral) { this.game.eventBus.emit('command:gather', { entityIds: [workerId], - targetEntityId: mineralWithRoom.entityId, + targetEntityId: mineral.entityId, }); - mineralWithRoom.currentWorkers++; + mineral.currentWorkers++; + idleWorkers.splice(workerIndex, 1); + } + } + + // Assign remaining idle workers to resources + for (const worker of idleWorkers) { + // Find nearest base for this worker + let nearestBase: { entityId: number; distance: number } | null = null; + for (const base of basePositions) { + const dx = worker.x - base.x; + const dy = worker.y - base.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (!nearestBase || distance < nearestBase.distance) { + nearestBase = { entityId: base.entityId, distance }; + } + } + + if (!nearestBase) continue; + + const targetResources = baseToResources.get(nearestBase.entityId); + if (!targetResources) continue; + + // Check saturation to determine if we need gas workers + const saturations = workerDistribution.getSaturations(ai.playerId); + const baseSaturation = saturations.find(s => s.baseEntityId === nearestBase.entityId); + + // Prioritize gas if undersaturated + if (baseSaturation && baseSaturation.gasWorkers < baseSaturation.optimalGasWorkers) { + const refinery = targetResources.refineries.find(r => r.currentWorkers < 3); + if (refinery) { + this.game.eventBus.emit('command:gather', { + entityIds: [worker.entityId], + targetEntityId: refinery.resourceEntityId, + }); + refinery.currentWorkers++; + continue; + } + } + + // Assign to undersaturated mineral (optimal is 2 per patch) + const mineral = targetResources.minerals + .filter(m => m.currentWorkers < 2) + .sort((a, b) => a.currentWorkers - b.currentWorkers)[0]; + + if (mineral) { + this.game.eventBus.emit('command:gather', { + entityIds: [worker.entityId], + targetEntityId: mineral.entityId, + }); + mineral.currentWorkers++; continue; } - // Fallback: assign to closest mineral - if (closestMineral) { + // Allow 3rd worker on minerals if all at 2 + const oversatMineral = targetResources.minerals + .filter(m => m.currentWorkers < 3) + .sort((a, b) => a.currentWorkers - b.currentWorkers)[0]; + + if (oversatMineral) { this.game.eventBus.emit('command:gather', { - entityIds: [workerId], - targetEntityId: closestMineral.entityId, + entityIds: [worker.entityId], + targetEntityId: oversatMineral.entityId, + }); + oversatMineral.currentWorkers++; + continue; + } + + // Fallback: assign to any mineral + if (targetResources.minerals.length > 0) { + this.game.eventBus.emit('command:gather', { + entityIds: [worker.entityId], + targetEntityId: targetResources.minerals[0].entityId, }); } } @@ -639,4 +725,5 @@ export class AIEconomyManager { return availableCount; } + } diff --git a/src/engine/systems/ai/AIScoutingManager.ts b/src/engine/systems/ai/AIScoutingManager.ts index 1f32007c..fd1fff35 100644 --- a/src/engine/systems/ai/AIScoutingManager.ts +++ b/src/engine/systems/ai/AIScoutingManager.ts @@ -6,16 +6,24 @@ * - Scout target determination * - Scouting phase execution * - Tracking scouted locations - * - Enemy intel gathering + * - Enemy intel gathering via ScoutingMemory primitive + * + * Integrates with ScoutingMemory for sophisticated intel tracking: + * - Building sightings with confidence decay + * - Unit type tracking with last-seen timestamps + * - Strategy inference (rush/macro/tech/air transition) + * - Tech tree reconstruction from observed buildings */ import { Transform } from '../../components/Transform'; import { Unit } from '../../components/Unit'; +import { Building } from '../../components/Building'; import { Health } from '../../components/Health'; import { Selectable } from '../../components/Selectable'; import { Game, GameCommand } from '../../core/Game'; import { debugAI } from '@/utils/debugLogger'; import type { AICoordinator, AIPlayer } from './AICoordinator'; +import type { InferredStrategy, ScoutedBuilding, ScoutedUnitType, StrategicInference } from '../../ai/ScoutingMemory'; export class AIScoutingManager { private game: Game; @@ -72,6 +80,7 @@ export class AIScoutingManager { /** * Get the next scouting target based on map layout and what's been scouted. + * Uses ScoutingMemory to prioritize areas with stale intel. */ public getScoutTarget(ai: AIPlayer): { x: number; y: number } | null { const config = this.game.config; @@ -90,7 +99,7 @@ export class AIScoutingManager { { x: config.mapWidth - 30, y: config.mapHeight / 2 }, // Right middle ]; - // Find first unscouted location + // Find first location we haven't scouted for (const target of targets) { const key = `${Math.floor(target.x / 20)},${Math.floor(target.y / 20)}`; if (!ai.scoutedLocations.has(key)) { @@ -98,7 +107,7 @@ export class AIScoutingManager { } } - // All predefined locations scouted, pick random location + // All locations scouted, pick random location for re-scouting const random = this.coordinator.getRandom(ai.playerId); return { x: random.next() * config.mapWidth, @@ -158,18 +167,24 @@ export class AIScoutingManager { /** * Update enemy intel based on what's visible. + * Uses ScoutingMemory for tracking enemy buildings and units. * Called periodically to track enemy composition and locations. */ public updateEnemyIntel(ai: AIPlayer): void { const config = ai.config!; const baseTypes = config.roles.baseTypes; + const currentTick = this.game.getCurrentTick(); + const scoutingMemory = ai.scoutingMemory; + + // Gather visible enemy entity IDs for ScoutingMemory update + const visibleEnemyIds = new Set(); let enemyAirUnits = 0; let enemyArmyStrength = 0; let enemyBaseCount = 0; let lastKnownEnemyBase: { x: number; y: number } | null = null; - // Count enemy units + // Collect visible enemy units const units = this.coordinator.getCachedUnitsWithTransform(); for (const entity of units) { const selectable = entity.get('Selectable')!; @@ -179,6 +194,8 @@ export class AIScoutingManager { if (selectable.playerId === ai.playerId) continue; if (health.isDead()) continue; + visibleEnemyIds.add(entity.id); + // Track air units if (unit.isFlying && !unit.isWorker) { enemyAirUnits++; @@ -190,24 +207,29 @@ export class AIScoutingManager { } } - // Count enemy bases + // Collect visible enemy buildings const buildings = this.coordinator.getCachedBuildingsWithTransform(); for (const entity of buildings) { const selectable = entity.get('Selectable')!; - const building = entity.get('Building')!; + const building = entity.get('Building')!; const transform = entity.get('Transform')!; const health = entity.get('Health')!; if (selectable.playerId === ai.playerId) continue; if (health.isDead()) continue; + visibleEnemyIds.add(entity.id); + if (baseTypes.includes(building.buildingId)) { enemyBaseCount++; lastKnownEnemyBase = { x: transform.x, y: transform.y }; } } - // Update AI's intel + // Update ScoutingMemory with visible enemies (handles intel tracking internally) + scoutingMemory.update(this.coordinator['world'], currentTick, visibleEnemyIds); + + // Update AI's intel from direct observations ai.enemyAirUnits = enemyAirUnits; ai.enemyArmyStrength = enemyArmyStrength; ai.enemyBaseCount = Math.max(1, enemyBaseCount); // Assume at least 1 base @@ -215,6 +237,54 @@ export class AIScoutingManager { if (lastKnownEnemyBase) { ai.enemyBaseLocation = lastKnownEnemyBase; } + + // Log strategy inference periodically + if (currentTick % 500 === 0) { + // Get intel for each known enemy + for (const intel of scoutingMemory.getAllIntel()) { + debugAI.log( + `[AIScouting] ${ai.playerId}: Enemy ${intel.playerId} strategy: ` + + `${intel.strategy.strategy} (${intel.strategy.confidence}), ` + + `tech level: ${intel.tech.techLevel}, ` + + `army supply: ${intel.estimatedArmySupply}, ` + + `workers: ${intel.estimatedWorkers}` + ); + } + } + } + + /** + * Get the inferred enemy strategy from ScoutingMemory. + * Returns the most likely strategy the enemy is pursuing. + */ + public getInferredEnemyStrategy(ai: AIPlayer, enemyPlayerId: string): InferredStrategy | null { + const intel = ai.scoutingMemory.getIntel(enemyPlayerId); + return intel?.strategy.strategy ?? null; + } + + /** + * Get full strategic inference for an enemy. + */ + public getStrategicInference(ai: AIPlayer, enemyPlayerId: string): StrategicInference | null { + const intel = ai.scoutingMemory.getIntel(enemyPlayerId); + return intel?.strategy ?? null; + } + + /** + * Get known enemy buildings from ScoutingMemory. + * Includes confidence levels based on when they were last seen. + */ + public getKnownEnemyBuildings(ai: AIPlayer, enemyPlayerId: string): ScoutedBuilding[] { + return ai.scoutingMemory.getConfirmedBuildings(enemyPlayerId); + } + + /** + * Get known enemy unit types from ScoutingMemory. + */ + public getKnownEnemyUnitTypes(ai: AIPlayer, enemyPlayerId: string): ScoutedUnitType[] { + const intel = ai.scoutingMemory.getIntel(enemyPlayerId); + if (!intel) return []; + return Array.from(intel.unitTypes.values()); } /** @@ -238,4 +308,67 @@ export class AIScoutingManager { public clearScoutedLocations(ai: AIPlayer): void { ai.scoutedLocations.clear(); } + + /** + * Get enemy intel summary for a specific enemy player. + */ + public getEnemyIntelSummary(ai: AIPlayer, enemyPlayerId: string): { + knownBuildingCount: number; + knownUnitTypes: number; + averageIntelConfidence: number; + inferredStrategy: InferredStrategy | null; + strategyConfidence: string; + estimatedArmySupply: number; + estimatedWorkers: number; + } { + const intel = ai.scoutingMemory.getIntel(enemyPlayerId); + + if (!intel) { + return { + knownBuildingCount: 0, + knownUnitTypes: 0, + averageIntelConfidence: 0, + inferredStrategy: null, + strategyConfidence: 'low', + estimatedArmySupply: 0, + estimatedWorkers: 0, + }; + } + + const buildings = ai.scoutingMemory.getConfirmedBuildings(enemyPlayerId); + const avgConfidence = buildings.length > 0 + ? buildings.reduce((sum, b) => sum + b.confidence, 0) / buildings.length + : 0; + + return { + knownBuildingCount: buildings.length, + knownUnitTypes: intel.unitTypes.size, + averageIntelConfidence: avgConfidence, + inferredStrategy: intel.strategy.strategy, + strategyConfidence: intel.strategy.confidence, + estimatedArmySupply: intel.estimatedArmySupply, + estimatedWorkers: intel.estimatedWorkers, + }; + } + + /** + * Check if we should build anti-air based on enemy intel. + */ + public shouldBuildAntiAir(ai: AIPlayer, enemyPlayerId: string): boolean { + return ai.scoutingMemory.shouldBuildAntiAir(enemyPlayerId); + } + + /** + * Get all known enemy players. + */ + public getKnownEnemies(ai: AIPlayer): string[] { + return ai.scoutingMemory.getAllIntel().map(intel => intel.playerId); + } + + /** + * Get strategic recommendation for dealing with an enemy. + */ + public getStrategicRecommendation(ai: AIPlayer, enemyPlayerId: string): string { + return ai.scoutingMemory.getStrategicRecommendation(enemyPlayerId); + } } diff --git a/src/engine/systems/ai/AITacticsManager.ts b/src/engine/systems/ai/AITacticsManager.ts index 1e30f8db..b98ee4d9 100644 --- a/src/engine/systems/ai/AITacticsManager.ts +++ b/src/engine/systems/ai/AITacticsManager.ts @@ -4,19 +4,18 @@ * Handles: * - Tactical state determination (attacking, defending, harassing, etc.) * - Attack phase execution with RTS-style engagement persistence - * - Defense phase execution + * - Defense phase execution with coordinated retreat * - Harass phase execution * - Expand phase execution * - Army rallying and unit coordination * - Hunt mode for finishing off enemies (victory pursuit) * - * Works with AIMicroSystem for unit-level micro (kiting, focus fire). + * Integrates AI primitives for sophisticated tactical decisions: + * - InfluenceMap: Spatial threat analysis and safe pathfinding + * - RetreatCoordination: Coordinated army retreat with rally points + * - FormationControl: Army positioning and formation management * - * RTS-STYLE IMPROVEMENTS: - * - Army stays in attacking state while engaged - * - Idle assault units get re-commanded to continue fighting - * - Hunt mode activates when enemy has few buildings left - * - Actively seeks out remaining enemy buildings to trigger victory + * Works with AIMicroSystem for unit-level micro (kiting, focus fire). */ import { Transform } from '../../components/Transform'; @@ -26,8 +25,10 @@ import { Health } from '../../components/Health'; import { Selectable } from '../../components/Selectable'; import { Game, GameCommand } from '../../core/Game'; import { debugAI } from '@/utils/debugLogger'; -import type { AICoordinator, AIPlayer, AIState, EnemyRelation } from './AICoordinator'; +import type { AICoordinator, AIPlayer, EnemyRelation } from './AICoordinator'; import { isEnemy } from '../../combat/TargetAcquisition'; +import type { ThreatAnalysis } from '../../ai/InfluenceMap'; +import type { FormationType } from '../../ai/FormationControl'; // Threat assessment constants const THREAT_WINDOW_TICKS = 200; // ~10 seconds at 20 ticks/sec @@ -37,7 +38,6 @@ const ENGAGEMENT_CHECK_INTERVAL = 10; // Check engagement every 10 ticks (~500ms const RE_COMMAND_IDLE_INTERVAL = 40; // Re-command idle units every 40 ticks (~2 sec) const DEFENSE_COMMAND_INTERVAL = 20; // Re-command defending units every 20 ticks (~1 sec) const HUNT_MODE_BUILDING_THRESHOLD = 3; // Enter hunt mode when enemy has <= 3 buildings -const ASSAULT_IDLE_TIMEOUT_TICKS = 100; // If assault unit idle for 5 sec, consider it "stuck" export class AITacticsManager { private game: Game; @@ -68,7 +68,7 @@ export class AITacticsManager { /** * Update enemy relations for an AI player. - * Calculates base distances, threat scores, and selects primary enemy. + * Uses InfluenceMap for threat analysis instead of manual calculations. */ public updateEnemyRelations(ai: AIPlayer, currentTick: number): void { const lastUpdate = this.lastEnemyRelationsUpdate.get(ai.playerId) ?? 0; @@ -81,6 +81,9 @@ export class AITacticsManager { const myBase = this.coordinator.findAIBase(ai); if (!myBase) return; + // Get influence map for threat analysis + const influenceMap = this.coordinator.getInfluenceMap(); + // Find all enemy players const buildings = this.world.getEntitiesWith('Building', 'Transform', 'Selectable'); const enemyPlayerIds = new Set(); @@ -88,8 +91,6 @@ export class AITacticsManager { for (const entity of buildings) { const selectable = entity.get('Selectable')!; if (selectable.playerId === ai.playerId) continue; - // Check if this is actually an enemy (not an ally on the same team) - // In FFA (team 0), everyone is an enemy. In team games, only different teams are enemies. const myBuildings = buildings.filter(b => b.get('Selectable')?.playerId === ai.playerId); const myTeam = myBuildings[0]?.get('Selectable')?.teamId ?? 0; if (!isEnemy(ai.playerId, myTeam, selectable.playerId, selectable.teamId)) continue; @@ -147,32 +148,16 @@ export class AITacticsManager { const decayFactor = Math.pow(0.5, (currentTick - relation.lastAttackedUsTick) / AITacticsManager.GRUDGE_HALF_LIFE_TICKS); relation.damageDealtToUs *= decayFactor; - // Calculate enemy army near our base (within 40 units) - const THREAT_RADIUS = 40; - let armyNearUs = 0; - const units = this.coordinator.getCachedUnitsWithTransform(); - for (const entity of units) { - const selectable = entity.get('Selectable')!; - const unit = entity.get('Unit')!; - const transform = entity.get('Transform')!; - const health = entity.get('Health')!; - if (selectable.playerId !== enemyPlayerId) continue; - if (health.isDead() || unit.isWorker) continue; - const dx = transform.x - myBase.x; - const dy = transform.y - myBase.y; - if (Math.sqrt(dx * dx + dy * dy) < THREAT_RADIUS) { - // Unit component doesn't store supplyCost, use 1 as default - armyNearUs += 1; - } - } - relation.armyNearUs = armyNearUs; + // Use InfluenceMap for threat analysis near our base + const threatAnalysis = influenceMap.getThreatAnalysis(myBase.x, myBase.y, ai.playerId); + relation.armyNearUs = Math.round(threatAnalysis.enemyInfluence); - // Calculate threat score - relation.threatScore = this.calculateThreatScore(ai, relation, currentTick); + // Calculate threat score using influence map data + relation.threatScore = this.calculateThreatScoreWithInfluence(ai, relation, threatAnalysis, currentTick); } // Clean up relations for dead players - for (const [enemyId, _relation] of ai.enemyRelations) { + for (const [enemyId] of ai.enemyRelations) { if (!enemyPlayerIds.has(enemyId)) { ai.enemyRelations.delete(enemyId); } @@ -191,27 +176,31 @@ export class AITacticsManager { } /** - * Calculate threat score for an enemy based on various factors. + * Calculate threat score using InfluenceMap threat analysis. */ - private calculateThreatScore(ai: AIPlayer, relation: EnemyRelation, currentTick: number): number { + private calculateThreatScoreWithInfluence( + ai: AIPlayer, + relation: EnemyRelation, + threatAnalysis: ThreatAnalysis, + currentTick: number + ): number { const weights = ai.personalityWeights; // Normalize base distance (closer = higher score, max at 200 units) const maxDistance = 200; const proximityScore = Math.max(0, 1 - relation.baseDistance / maxDistance); - // Threat from army near our base (normalized by our army supply) - const myArmy = Math.max(1, ai.armySupply); - const threatScore = Math.min(1, relation.armyNearUs / myArmy); + // Use influence map threat data + const threatScore = Math.min(1, threatAnalysis.dangerLevel); // Retaliation score based on recent damage and recency const ticksSinceAttack = currentTick - relation.lastAttackedUsTick; const recency = Math.max(0, 1 - ticksSinceAttack / 2400); // 2 minute falloff const retaliationScore = Math.min(1, relation.damageDealtToUs / 500) * recency; - // Opportunity score - inversely proportional to their strength - // (we don't track enemy army supply directly, so use proximity as proxy) - const opportunityScore = proximityScore * 0.5; // Closer enemies are easier to attack + // Opportunity score - use influence data to determine if we have area control + const weHaveControl = threatAnalysis.friendlyInfluence > threatAnalysis.enemyInfluence; + const opportunityScore = proximityScore * (weHaveControl ? 0.7 : 0.3); // Weighted sum using personality weights return ( @@ -248,7 +237,6 @@ export class AITacticsManager { /** * Record damage dealt to this AI by an attacker. - * Called from event handler when units take damage. */ public recordDamageReceived(ai: AIPlayer, attackerPlayerId: string, damage: number, currentTick: number): void { let relation = ai.enemyRelations.get(attackerPlayerId); @@ -316,12 +304,23 @@ export class AITacticsManager { } /** - * Check if the AI is currently under attack. + * Check if the AI is currently under attack using InfluenceMap. */ public isUnderAttack(ai: AIPlayer): boolean { const currentTick = this.game.getCurrentTick(); const recentEnemyContact = (currentTick - ai.lastEnemyContact) < THREAT_WINDOW_TICKS; + // Use influence map for threat detection + const basePos = this.coordinator.findAIBase(ai); + if (basePos) { + const influenceMap = this.coordinator.getInfluenceMap(); + const threatAnalysis = influenceMap.getThreatAnalysis(basePos.x, basePos.y, ai.playerId); + + // High danger level indicates attack + if (threatAnalysis.dangerLevel > 0.5) return true; + } + + // Also check building damage const buildings = this.coordinator.getCachedBuildings(); for (const entity of buildings) { const selectable = entity.get('Selectable')!; @@ -329,9 +328,7 @@ export class AITacticsManager { if (selectable.playerId !== ai.playerId) continue; - // Critical damage always counts as under attack if (health.getHealthPercent() < 0.5) return true; - // Moderate damage with recent enemy contact if (health.getHealthPercent() < 0.9 && recentEnemyContact) return true; } return false; @@ -369,7 +366,6 @@ export class AITacticsManager { if (selectable.playerId !== playerId) continue; if (unit.isWorker) continue; if (health.isDead()) continue; - // Exclude non-combat units (0 attack damage) if (unit.attackDamage === 0) continue; armyUnits.push(entity.id); @@ -378,6 +374,42 @@ export class AITacticsManager { return armyUnits; } + /** + * Get army units with their positions for formation calculations. + */ + private getArmyUnitsWithPositions(playerId: string): Array<{ + entityId: number; + x: number; + y: number; + unitId: string; + attackRange: number; + }> { + const units: Array<{ entityId: number; x: number; y: number; unitId: string; attackRange: number }> = []; + const entities = this.coordinator.getCachedUnitsWithTransform(); + + for (const entity of entities) { + const selectable = entity.get('Selectable')!; + const unit = entity.get('Unit')!; + const health = entity.get('Health')!; + const transform = entity.get('Transform')!; + + if (selectable.playerId !== playerId) continue; + if (unit.isWorker) continue; + if (health.isDead()) continue; + if (unit.attackDamage === 0) continue; + + units.push({ + entityId: entity.id, + x: transform.x, + y: transform.y, + unitId: unit.unitId, + attackRange: unit.attackRange, + }); + } + + return units; + } + /** * Get fast units suitable for harassment. */ @@ -407,20 +439,14 @@ export class AITacticsManager { // === Enemy Detection === /** - * Find the enemy base location. - * Uses RTS-style primary enemy selection - each AI targets their own primary enemy - * based on proximity, threat, and personality-weighted scores. - * - * @param targetPlayerId - Optional specific enemy to target. If not provided, uses primary enemy. + * Find the enemy base location using primary enemy selection. */ public findEnemyBase(ai: AIPlayer, targetPlayerId?: string): { x: number; y: number } | null { const config = ai.config!; const baseTypes = config.roles.baseTypes; - // Use primary enemy if no specific target provided const enemyToTarget = targetPlayerId ?? ai.primaryEnemyId; - // If we have a primary enemy with a known base position, use it directly if (enemyToTarget) { const relation = ai.enemyRelations.get(enemyToTarget); if (relation?.basePosition) { @@ -430,7 +456,6 @@ export class AITacticsManager { const buildings = this.world.getEntitiesWith('Building', 'Transform', 'Selectable'); - // If we have a specific enemy to target, find their base if (enemyToTarget) { for (const entity of buildings) { const selectable = entity.get('Selectable')!; @@ -443,7 +468,6 @@ export class AITacticsManager { } } - // Fallback: any building from this enemy for (const entity of buildings) { const selectable = entity.get('Selectable')!; const transform = entity.get('Transform')!; @@ -453,8 +477,6 @@ export class AITacticsManager { } } - // No primary enemy selected - fallback to legacy behavior (first enemy found) - // This should rarely happen as updateEnemyRelations should set primaryEnemyId for (const entity of buildings) { const selectable = entity.get('Selectable')!; const building = entity.get('Building')!; @@ -466,7 +488,6 @@ export class AITacticsManager { } } - // Return any enemy building for (const entity of buildings) { const selectable = entity.get('Selectable')!; const transform = entity.get('Transform')!; @@ -497,10 +518,64 @@ export class AITacticsManager { } } - // Otherwise target enemy base return this.findEnemyBase(ai); } + // === Formation Control Integration === + + // Track active formation groups per player + private activeFormationGroups: Map = new Map(); + + /** + * Calculate and apply formation positions for army units before attack. + */ + private applyFormation( + ai: AIPlayer, + armyUnits: Array<{ entityId: number; x: number; y: number; unitId: string; attackRange: number }>, + targetPosition: { x: number; y: number }, + formationType: FormationType + ): Array<{ entityId: number; position: { x: number; y: number } }> { + const formationControl = ai.formationControl; + + // Get or create formation group for this player + let groupId = this.activeFormationGroups.get(ai.playerId); + + // Delete old group and create new one with current unit IDs + if (groupId) { + formationControl.deleteGroup(groupId); + } + + const unitIds = armyUnits.map(u => u.entityId); + groupId = formationControl.createGroup(this.world, unitIds, ai.playerId); + this.activeFormationGroups.set(ai.playerId, groupId); + + // Calculate formation based on type (returns FormationSlot[]) + let formationSlots; + + switch (formationType) { + case 'concave': + formationSlots = formationControl.calculateConcaveFormation(this.world, groupId, targetPosition); + break; + case 'box': + formationSlots = formationControl.calculateBoxFormation(this.world, groupId); + break; + case 'spread': + formationSlots = formationControl.calculateSpreadFormation(this.world, groupId); + break; + case 'line': + default: + // Line formation - use box as fallback since line doesn't exist + formationSlots = formationControl.calculateBoxFormation(this.world, groupId); + break; + } + + // Convert FormationSlot[] to expected return type + return formationSlots.map(slot => ({ + entityId: slot.entityId, + position: slot.targetPosition, + })); + } + // === Phase Execution === /** @@ -510,13 +585,11 @@ export class AITacticsManager { const basePos = this.coordinator.findAIBase(ai); if (!basePos) return; - // Rally point slightly in front of base const rallyPoint = { x: basePos.x + 10, y: basePos.y + 10, }; - // Find idle army units and rally them const units = this.coordinator.getCachedUnitsWithTransform(); for (const entity of units) { const selectable = entity.get('Selectable')!; @@ -529,7 +602,6 @@ export class AITacticsManager { if (health.isDead()) continue; if (unit.state !== 'idle') continue; - // Check if unit is far from rally point const dx = transform.x - rallyPoint.x; const dy = transform.y - rallyPoint.y; if (Math.sqrt(dx * dx + dy * dy) > 15) { @@ -549,19 +621,11 @@ export class AITacticsManager { * Execute the expanding phase. */ public executeExpandingPhase(ai: AIPlayer): void { - // Expansion is handled by AIBuildOrderExecutor - // Just switch state back to building ai.state = 'building'; } /** - * Execute the attacking phase. - * - * RTS-STYLE: This is a persistent engagement system, not fire-and-forget. - * - Tracks whether army is currently engaged in combat - * - Re-commands idle assault units to continue fighting - * - Enters hunt mode when enemy has few buildings left - * - Only returns to building state when battle is over + * Execute the attacking phase with formation control. */ public executeAttackingPhase(ai: AIPlayer, currentTick: number): void { const armyUnits = this.getArmyUnits(ai.playerId); @@ -571,10 +635,7 @@ export class AITacticsManager { return; } - // Count enemy buildings to determine if we should enter hunt mode const enemyBuildingCount = this.countEnemyBuildings(ai); - - // RTS-STYLE: Check if we should enter hunt mode const inHuntMode = enemyBuildingCount > 0 && enemyBuildingCount <= HUNT_MODE_BUILDING_THRESHOLD; // Check engagement status periodically @@ -587,14 +648,12 @@ export class AITacticsManager { const engaged = this.isEngaged.get(ai.playerId) || false; - // Find target - use different strategy based on mode + // Find target let attackTarget: { x: number; y: number } | null = null; if (inHuntMode) { - // RTS-STYLE HUNT MODE: Find ANY enemy building, spread units to hunt attackTarget = this.findAnyEnemyBuilding(ai); if (!attackTarget) { - // No buildings found - enemy defeated or buildings are hidden ai.state = 'building'; this.isEngaged.set(ai.playerId, false); debugAI.log(`[AITactics] ${ai.playerId}: Hunt mode - no enemy buildings found, returning to build`); @@ -602,10 +661,8 @@ export class AITacticsManager { } debugAI.log(`[AITactics] ${ai.playerId}: HUNT MODE - targeting enemy building at (${attackTarget.x.toFixed(0)}, ${attackTarget.y.toFixed(0)})`); } else { - // Normal attack - target enemy base attackTarget = this.findEnemyBase(ai); if (!attackTarget) { - // Try finding ANY enemy building as fallback attackTarget = this.findAnyEnemyBuilding(ai); if (!attackTarget) { ai.state = 'building'; @@ -615,16 +672,33 @@ export class AITacticsManager { } } - // RTS-STYLE: Re-command idle assault units periodically + // Apply formation for coordinated attack + const armyWithPositions = this.getArmyUnitsWithPositions(ai.playerId); + if (armyWithPositions.length > 3 && !engaged) { + // Use concave formation for engaging enemy + const formation = this.applyFormation(ai, armyWithPositions, attackTarget, 'concave'); + + // Move units to formation positions before attack + for (const { entityId, position } of formation) { + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'MOVE', + entityIds: [entityId], + targetPosition: position, + }; + this.game.issueAICommand(command); + } + } + + // Re-command idle assault units periodically const lastReCommand = this.lastReCommandTick.get(ai.playerId) || 0; const shouldReCommand = currentTick - lastReCommand >= RE_COMMAND_IDLE_INTERVAL; if (shouldReCommand) { - // Find units that are idle in assault mode or have been stuck const idleAssaultUnits = this.getIdleAssaultUnits(ai.playerId, armyUnits); if (idleAssaultUnits.length > 0) { - // Re-command idle assault units to attack const command: GameCommand = { tick: currentTick, playerId: ai.playerId, @@ -639,11 +713,10 @@ export class AITacticsManager { this.lastReCommandTick.set(ai.playerId, currentTick); } - // Initial attack command (only when first entering attacking state) + // Initial attack command if (ai.lastAttackTick === 0 || currentTick - ai.lastAttackTick >= ai.attackCooldown) { ai.lastAttackTick = currentTick; - // Issue attack command to all army units const command: GameCommand = { tick: currentTick, playerId: ai.playerId, @@ -656,34 +729,23 @@ export class AITacticsManager { debugAI.log(`[AITactics] ${ai.playerId}: Attacking with ${armyUnits.length} units${inHuntMode ? ' (HUNT MODE)' : ''}`); } - // RTS-STYLE: Stay in attacking state while engaged or in hunt mode - // Only return to building when: - // 1. No enemy buildings exist (victory imminent) - // 2. Not engaged and no enemy buildings nearby + // State transition logic if (enemyBuildingCount === 0) { - // Victory pursuit complete ai.state = 'building'; this.isEngaged.set(ai.playerId, false); debugAI.log(`[AITactics] ${ai.playerId}: No enemy buildings remaining, returning to build`); } else if (!engaged && !inHuntMode) { - // Not engaged and not in hunt mode - check if we should retreat - // Only retreat if we've been disengaged for a while const disengagedDuration = currentTick - (this.lastEngagementCheck.get(ai.playerId) || 0); - if (disengagedDuration > 100) { // 5 seconds of no combat + if (disengagedDuration > 100) { ai.state = 'building'; this.isEngaged.set(ai.playerId, false); debugAI.log(`[AITactics] ${ai.playerId}: Disengaged for ${disengagedDuration} ticks, returning to build`); } } - // Otherwise stay in attacking state - RTS-style persistence } /** - * Execute the defending phase. - * - * FIX: Added throttling to prevent constant re-commanding which was causing units - * to move around without actually attacking. Now only re-commands idle units - * periodically, allowing engaged units to continue fighting. + * Execute the defending phase with RetreatCoordination. */ public executeDefendingPhase(ai: AIPlayer, currentTick: number): void { const armyUnits = this.getArmyUnits(ai.playerId); @@ -692,18 +754,71 @@ export class AITacticsManager { return; } - // Find our base to defend const basePos = this.coordinator.findAIBase(ai); if (!basePos) { ai.state = 'building'; return; } - // Find nearest enemy threat - search wider radius for defense (100 units instead of 50) + // Get retreat coordinator and influence map + const retreatCoordinator = ai.retreatCoordinator; + const influenceMap = this.coordinator.getInfluenceMap(); + + // Update retreat coordination with current state + retreatCoordinator.update(this.world, currentTick, ai.playerId, influenceMap); + + // Check group retreat status + const retreatStatus = retreatCoordinator.getGroupStatus(this.world, ai.playerId); + if (retreatStatus.isRetreating) { + // Issue retreat commands to retreating units + for (const entityId of armyUnits) { + if (retreatCoordinator.shouldRetreat(ai.playerId, entityId)) { + const retreatTarget = retreatCoordinator.getRetreatTarget(ai.playerId, entityId); + if (retreatTarget) { + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'MOVE', + entityIds: [entityId], + targetPosition: retreatTarget, + }; + this.game.issueAICommand(command); + } + } + } + + // Check if we can re-engage + if (retreatStatus.canReengage) { + retreatCoordinator.forceReengage(ai.playerId); + debugAI.log(`[AITactics] ${ai.playerId}: Re-engaging after retreat`); + } + return; + } + + // Find nearest enemy threat using InfluenceMap + const threatAnalysis = influenceMap.getThreatAnalysis(basePos.x, basePos.y, ai.playerId); + + // If threat is too high, force retreat for low health units + if (threatAnalysis.dangerLevel > 0.7 && armyUnits.length < 5) { + // Calculate retreat rally point (use safe direction from influence map) + const safeDir = threatAnalysis.safeDirection; + const rallyPoint = { + x: basePos.x + safeDir.x * 15, + y: basePos.y + safeDir.y * 15, + }; + + // Force retreat for all units + for (const entityId of armyUnits) { + retreatCoordinator.forceRetreat(ai.playerId, entityId, rallyPoint, currentTick); + } + + debugAI.log(`[AITactics] ${ai.playerId}: Initiating coordinated retreat, danger level: ${threatAnalysis.dangerLevel.toFixed(2)}`); + return; + } + + // Normal defense - find threat position const threatPos = this.findNearestThreat(ai, basePos); - // If no threat found near base, check if we're still under attack - // (enemy might have retreated or been killed) if (!threatPos) { if (!this.isUnderAttack(ai)) { ai.state = 'building'; @@ -712,27 +827,41 @@ export class AITacticsManager { return; } - // Throttle defense commands - don't re-command every tick + // Throttle defense commands const lastDefenseCommand = this.lastDefenseCommandTick.get(ai.playerId) || 0; const shouldCommand = currentTick - lastDefenseCommand >= DEFENSE_COMMAND_INTERVAL; if (shouldCommand) { - // Only command units that are idle or not yet engaged in combat - // Units already attacking or moving to attack should continue without interruption const idleDefenders = this.getIdleDefendingUnits(ai.playerId, armyUnits); if (idleDefenders.length > 0) { - // Issue attack-move command towards threat - // Using ATTACK with targetPosition triggers attack-move behavior - const command: GameCommand = { - tick: currentTick, - playerId: ai.playerId, - type: 'ATTACK', - entityIds: idleDefenders, - targetPosition: threatPos, - }; - - this.game.issueAICommand(command); + // Apply spread formation for defense + const armyWithPositions = this.getArmyUnitsWithPositions(ai.playerId); + if (armyWithPositions.length > 2) { + const formation = this.applyFormation(ai, armyWithPositions, threatPos, 'spread'); + + for (const { entityId, position } of formation) { + if (idleDefenders.includes(entityId)) { + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'ATTACK', + entityIds: [entityId], + targetPosition: position, + }; + this.game.issueAICommand(command); + } + } + } else { + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'ATTACK', + entityIds: idleDefenders, + targetPosition: threatPos, + }; + this.game.issueAICommand(command); + } debugAI.log( `[AITactics] ${ai.playerId}: Commanding ${idleDefenders.length}/${armyUnits.length} ` + @@ -743,7 +872,6 @@ export class AITacticsManager { this.lastDefenseCommandTick.set(ai.playerId, currentTick); } - // Check if threat is eliminated if (!this.isUnderAttack(ai)) { ai.state = 'building'; debugAI.log(`[AITactics] ${ai.playerId}: Threat eliminated, returning to build`); @@ -751,8 +879,7 @@ export class AITacticsManager { } /** - * Get army units that are idle and need to be commanded for defense. - * Excludes units already engaged in combat or moving to attack. + * Get army units that are idle and need commanding for defense. */ private getIdleDefendingUnits(playerId: string, armyUnits: number[]): number[] { const idleUnits: number[] = []; @@ -766,16 +893,8 @@ export class AITacticsManager { if (!unit || !health) continue; if (health.isDead()) continue; - // Unit needs commanding if: - // 1. It's idle (not doing anything) - // 2. It's completely stopped (no target, no destination) const isIdle = unit.state === 'idle' && unit.targetEntityId === null; const isStuck = unit.state === 'idle' && !unit.isInAssaultMode; - - // Don't interrupt units that are: - // - Currently attacking (has a target) - // - Moving to attack (attackmoving state) - // - In assault mode and actively scanning const isEngaged = unit.targetEntityId !== null || unit.state === 'attacking' || (unit.state === 'attackmoving' && unit.targetX !== null); @@ -789,15 +908,12 @@ export class AITacticsManager { } /** - * Find the nearest enemy threat to a position. - * Searches within a reasonable radius of the base for enemy units. + * Find the nearest enemy threat using InfluenceMap for guidance. */ private findNearestThreat(ai: AIPlayer, position: { x: number; y: number }): { x: number; y: number } | null { const units = this.coordinator.getCachedUnitsWithTransform(); let nearestThreat: { x: number; y: number; distance: number } | null = null; - // Search radius for threats - 80 units should cover most base areas - // This is larger than before (50) to catch enemies that are attacking from range const THREAT_SEARCH_RADIUS = 80; for (const entity of units) { @@ -808,15 +924,12 @@ export class AITacticsManager { if (selectable.playerId === ai.playerId) continue; if (health.isDead()) continue; - // Include workers too - they might be attacking or part of a worker rush - // Only skip if unit has 0 attack damage if (unit.attackDamage === 0) continue; const dx = transform.x - position.x; const dy = transform.y - position.y; const distance = Math.sqrt(dx * dx + dy * dy); - // Only consider threats within search radius of base if (distance > THREAT_SEARCH_RADIUS) continue; if (!nearestThreat || distance < nearestThreat.distance) { @@ -839,13 +952,41 @@ export class AITacticsManager { return; } - // Attack enemy workers or expansion const harassTarget = this.findHarassTarget(ai); if (!harassTarget) { ai.state = 'building'; return; } + // Use InfluenceMap to check if path is safe + const influenceMap = this.coordinator.getInfluenceMap(); + const startPos = this.coordinator.findAIBase(ai); + + if (startPos) { + // Check threat level at target - if too dangerous, find safer approach + const targetThreat = influenceMap.getThreatAnalysis(harassTarget.x, harassTarget.y, ai.playerId); + if (targetThreat.dangerLevel > 0.6) { + // Try to approach from safe direction + const safeOffset = { + x: targetThreat.safeDirection.x * 10, + y: targetThreat.safeDirection.y * 10, + }; + const safeApproach = { + x: harassTarget.x + safeOffset.x, + y: harassTarget.y + safeOffset.y, + }; + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'MOVE', + entityIds: harassUnits, + targetPosition: safeApproach, + }; + this.game.issueAICommand(command); + return; // Move to safe position first, attack next cycle + } + } + const command: GameCommand = { tick: currentTick, playerId: ai.playerId, @@ -861,40 +1002,31 @@ export class AITacticsManager { ai.state = 'building'; } - // ==================== RTS-STYLE ENGAGEMENT TRACKING ==================== + // === Engagement Tracking === /** * Check if the AI's army is currently engaged in combat. - * Returns true if units are attacking or being attacked. */ private checkEngagementStatus(ai: AIPlayer, armyUnits: number[]): boolean { let engagedCount = 0; - const entities = this.coordinator.getCachedUnitsWithTransform(); for (const entityId of armyUnits) { const entity = this.world.getEntity(entityId); if (!entity) continue; const unit = entity.get('Unit'); - const health = entity.get('Health'); - if (!unit || !health) continue; + if (!unit) continue; - // Unit is engaged if: - // 1. It has an active target - // 2. It's in attacking state - // 3. It recently took damage if (unit.targetEntityId !== null || unit.state === 'attacking') { engagedCount++; } } - // Consider engaged if >= 20% of army is in combat return engagedCount >= Math.max(1, armyUnits.length * 0.2); } /** - * Find army units that are idle in assault mode (need re-commanding). - * These are units that arrived at their destination and are scanning but found nothing. + * Find army units that are idle in assault mode. */ private getIdleAssaultUnits(playerId: string, armyUnits: number[]): number[] { const idleUnits: number[] = []; @@ -908,15 +1040,10 @@ export class AITacticsManager { if (!unit || !health) continue; if (health.isDead()) continue; - // Unit needs re-commanding if: - // 1. It's in assault mode AND - // 2. It's idle (no target, not moving) AND - // 3. It's been idle for a while (stuck or no enemies found) const isIdleAssault = unit.isInAssaultMode && unit.state === 'idle' && unit.targetEntityId === null; - // Also include units that are completely idle (not in assault mode but should be attacking) const isCompletelyIdle = unit.state === 'idle' && unit.targetEntityId === null && !unit.isInAssaultMode && @@ -931,7 +1058,7 @@ export class AITacticsManager { } /** - * Count all enemy buildings (for hunt mode determination). + * Count all enemy buildings. */ private countEnemyBuildings(ai: AIPlayer): number { let count = 0; @@ -944,7 +1071,7 @@ export class AITacticsManager { if (selectable.playerId === ai.playerId) continue; if (health.isDead()) continue; - if (!building.isOperational()) continue; // Don't count blueprints + if (!building.isOperational()) continue; count++; } @@ -953,13 +1080,10 @@ export class AITacticsManager { } /** - * Find ANY enemy building (not just base buildings). - * Used for hunt mode to track down remaining structures. + * Find ANY enemy building for hunt mode. */ private findAnyEnemyBuilding(ai: AIPlayer): { x: number; y: number } | null { const buildings = this.world.getEntitiesWith('Building', 'Transform', 'Selectable', 'Health'); - - // First try to find a base building (higher priority) const config = ai.config!; const baseTypes = config.roles.baseTypes; @@ -978,7 +1102,6 @@ export class AITacticsManager { } } - // If no base found, return ANY enemy building for (const entity of buildings) { const selectable = entity.get('Selectable')!; const transform = entity.get('Transform')!; @@ -996,8 +1119,7 @@ export class AITacticsManager { } /** - * Find all enemy buildings and return them for map sweeping. - * Used when in hunt mode to spread units across multiple targets. + * Find all enemy buildings for map sweeping. */ public findAllEnemyBuildings(ai: AIPlayer): Array<{ x: number; y: number; entityId: number }> { const results: Array<{ x: number; y: number; entityId: number }> = []; @@ -1018,4 +1140,52 @@ export class AITacticsManager { return results; } + + /** + * Get safe retreat path using InfluenceMap. + * Returns array of waypoints from current position toward base via low-threat areas. + */ + public getSafeRetreatPath( + ai: AIPlayer, + fromPosition: { x: number; y: number } + ): Array<{ x: number; y: number }> | null { + const basePos = this.coordinator.findAIBase(ai); + if (!basePos) return null; + + const influenceMap = this.coordinator.getInfluenceMap(); + + // Get safe direction from current position + const threatAnalysis = influenceMap.getThreatAnalysis(fromPosition.x, fromPosition.y, ai.playerId); + + // Build path using safe direction as guide + const path: Array<{ x: number; y: number }> = []; + const stepSize = 10; + let currentX = fromPosition.x; + let currentY = fromPosition.y; + + // Generate waypoints toward base, biasing toward safe direction + for (let i = 0; i < 5; i++) { + // Direction to base + const dx = basePos.x - currentX; + const dy = basePos.y - currentY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < stepSize) { + path.push(basePos); + break; + } + + // Blend safe direction with direct path to base + const directX = dx / dist; + const directY = dy / dist; + const blendedX = directX * 0.7 + threatAnalysis.safeDirection.x * 0.3; + const blendedY = directY * 0.7 + threatAnalysis.safeDirection.y * 0.3; + + currentX += blendedX * stepSize; + currentY += blendedY * stepSize; + path.push({ x: currentX, y: currentY }); + } + + return path.length > 0 ? path : null; + } }