diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index f3ca8acd..d06ddeae 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -2854,6 +2854,46 @@ Multiple build order variants exist per difficulty level (2-4 variants each), ta Naval units are excluded from land attack operations to prevent them being sent to unreachable targets. +#### Air Unit Control System + +The AI controls air units independently from ground forces through a multi-layered system: + +**Army Separation** (`AITacticsManager`) +- During attacks, the army is split into `groundUnits` and `airCombatUnits` +- Ground units form the main army with concave formations +- Air units receive independent flanking commands perpendicular to the main attack vector +- If no ground units exist, air becomes the main force (no wasted air units) + +**Air Formations** (`FormationControl`) +- Air units are positioned past the enemy center at a perpendicular offset +- Wide spacing (2x ground) reduces splash damage vulnerability +- Independent priority level (4) prevents air from interfering with ground formations + +**Air Harassment** (`AITacticsManager.executeAirHarassment()`) +- Up to 3 air units sent to enemy worker lines +- Air bypasses terrain and ground defenses (direct paths) +- 10-second cooldown between harassment waves + +**Support Air** (`AITacticsManager.commandSupportAir()`) +- Lifter and Overseer units follow the army centroid +- Positioned behind the army toward the AI's own base +- Provides healing and detection support during combat + +**Air Micro** (`AIMicroSystem`) +- Health-based disengage: air units flee below 30% health +- Hit-and-run repositioning: perpendicular movement every 15 ticks while attacking +- Proactive Valkyrie transformation: lower threshold (1.5x) for mode switching + +**Anti-Air Response** +- Emergency counter-air production on ALL difficulties (not just hard+) +- Preemptive anti-air when enemy air tech detected (medium+) +- Air superiority Valkyrie production (priority 75) when enemy has air units +- `enemyAirUnits` tracking feeds into production decisions + +**Air Scouting** (`AIScoutingManager`) +- Flying units preferred as scouts (bypass terrain, fastest paths) +- Falls back to ground scouts, then idle workers + #### Unit Production Coverage All Dominion combat and support units have dedicated macro rules: diff --git a/docs/design/GAME_DESIGN.md b/docs/design/GAME_DESIGN.md index 27c2fb90..3344ded6 100644 --- a/docs/design/GAME_DESIGN.md +++ b/docs/design/GAME_DESIGN.md @@ -168,6 +168,34 @@ Units have restrictions on what they can attack based on air/ground targeting: **AI Counter-Building**: When AI units are attacked by enemies they cannot hit (e.g., air units attacking ground-only troops), the AI urgently prioritizes building anti-air capable units. +### AI Air Unit Control + +The AI manages air units as an independent tactical arm: + +**Air Combat Units** (Valkyrie, Specter, Dreadnought): +- Separated from ground army during attacks +- Execute flanking maneuvers perpendicular to the main ground attack +- Perform hit-and-run micro (reposition after attacking) +- Disengage automatically when health drops below 30% +- Used for worker harassment between major attacks + +**Air Support Units** (Lifter, Overseer): +- Follow the main army at a safe distance behind the centroid +- Provide healing (Lifter) and detection (Overseer) support +- Not included in combat formations + +**Valkyrie Transform Intelligence**: +- Switches to Fighter mode when air threats dominate (1.5x threshold) +- Switches to Assault mode when ground threats dominate (1.5x threshold) +- More aggressive mode-switching than previous 2x threshold + +**Air Production Priority**: +- Valkyrie: priority 54 (primary air unit) +- Specter: priority 52 (cloaked strike) +- Dreadnought: priority 58 (capital ship) +- Air Superiority response: priority 75 when enemy has air +- Emergency anti-air: priority 95 on all difficulties + ### AI Personality System Each AI player is assigned a personality that determines its strategic behavior, army composition, and build order selection. In multi-AI games, personalities are varied so no two AIs play identically. diff --git a/src/data/ai/factions/dominion.ts b/src/data/ai/factions/dominion.ts index 4b0c559c..84683daf 100644 --- a/src/data/ai/factions/dominion.ts +++ b/src/data/ai/factions/dominion.ts @@ -287,7 +287,6 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ ], }, cooldownTicks: 10, - difficulties: ['hard', 'very_hard', 'insane'], }, // === Scouting-Reactive Rules === @@ -342,7 +341,7 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ ], }, cooldownTicks: 10, - difficulties: ['hard', 'very_hard', 'insane'], + difficulties: ['medium', 'hard', 'very_hard', 'insane'], }, // === Unit Production - Heavy Units (ALL DIFFICULTIES) === @@ -399,7 +398,7 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ id: 'train_valkyrie', name: 'Train Valkyrie', description: 'Build anti-air fighter', - priority: 50, + priority: 54, conditions: [ { type: 'buildingCount', operator: '>=', value: 1, targetId: 'hangar' }, { type: 'plasma', operator: '>=', value: 50 }, // Lowered @@ -414,7 +413,7 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ id: 'train_specter', name: 'Train Specter', description: 'Build cloaked air unit', - priority: 48, + priority: 52, conditions: [ { type: 'buildingCount', operator: '>=', value: 1, targetId: 'hangar' }, { type: 'buildingCount', operator: '>=', value: 1, targetId: 'research_module' }, @@ -426,6 +425,22 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ // NO difficulty restriction }, + // Build more air when enemy has air (air superiority) + { + id: 'train_valkyrie_air_superiority', + name: 'Air Superiority Valkyrie', + description: 'Build Valkyrie fighters when enemy has air units', + priority: 75, + conditions: [ + { type: 'enemyAirUnits', operator: '>', value: 0 }, + { type: 'buildingCount', operator: '>=', value: 1, targetId: 'hangar' }, + { type: 'plasma', operator: '>=', value: 50 }, + { type: 'minerals', operator: '>=', value: 100 }, + ], + action: { type: 'train', targetId: 'valkyrie' }, + cooldownTicks: 30, + }, + // === Unit Production - Vehicles (ALL DIFFICULTIES) === { id: 'train_scorcher', @@ -991,7 +1006,7 @@ export const DOMINION_AI_CONFIG: FactionAIConfig = { scout: 'trooper', antiAir: ['trooper', 'valkyrie', 'colossus', 'specter', 'breacher'], siege: ['devastator', 'colossus', 'dreadnought'], - harass: ['scorcher', 'vanguard', 'valkyrie'], + harass: ['scorcher', 'vanguard', 'valkyrie', 'specter'], baseTypes: ['headquarters', 'orbital_station', 'bastion'], }, diff --git a/src/engine/ai/FormationControl.ts b/src/engine/ai/FormationControl.ts index 6584adcb..8ea5f1a5 100644 --- a/src/engine/ai/FormationControl.ts +++ b/src/engine/ai/FormationControl.ts @@ -268,22 +268,25 @@ export class FormationControl { }); } - // Air units hover above center + // Air units position in a wide spread targeting the enemy flank + // They approach from a perpendicular angle to the ground army const airUnits = unitsByRole.get('air') || []; - const airSlots = this.calculateLinePositions( - group.center, - facing, - airUnits.length, - 2 // Slightly behind - ); - - for (let i = 0; i < airUnits.length; i++) { - slots.push({ - entityId: airUnits[i].entityId, - role: 'air', - targetPosition: airSlots[i], - priority: 2, - }); + if (airUnits.length > 0) { + const airSlots = this.calculateAirPositions( + group.center, + facing, + enemyCenter, + airUnits.length + ); + + for (let i = 0; i < airUnits.length; i++) { + slots.push({ + entityId: airUnits[i].entityId, + role: 'air', + targetPosition: airSlots[i], + priority: 4, // Lowest priority — air acts independently + }); + } } group.slots = slots; @@ -370,6 +373,53 @@ export class FormationControl { return positions; } + /** + * Calculate air unit positions for flanking attacks. + * Air units spread in a wide arc on the far side of the enemy, + * creating a pincer with the ground army. + */ + private calculateAirPositions( + _armyCenter: { x: number; y: number }, + facing: { x: number; y: number }, + enemyCenter: { x: number; y: number }, + count: number + ): Array<{ x: number; y: number }> { + if (count === 0) return []; + + // Air units position past the enemy, flanking from perpendicular angle + const perpX = -facing.y; + const perpY = facing.x; + + // Distance past the enemy center + const flankDepth = 8; + // Center of the air formation: offset to the side and slightly past enemy + const airCenter = { + x: enemyCenter.x + perpX * 10 + facing.x * flankDepth, + y: enemyCenter.y + perpY * 10 + facing.y * flankDepth, + }; + + const positions: Array<{ x: number; y: number }> = []; + const spacing = this.config.unitSpacing * 2; // Wider spread for air (anti-splash) + + if (count === 1) { + return [airCenter]; + } + + // Spread along the perpendicular axis + const totalWidth = (count - 1) * spacing; + const startOffset = -totalWidth / 2; + + for (let i = 0; i < count; i++) { + const offset = startOffset + i * spacing; + positions.push({ + x: airCenter.x + perpX * offset, + y: airCenter.y + perpY * offset, + }); + } + + return positions; + } + /** * Calculate spread formation (anti-splash) */ diff --git a/src/engine/systems/AIMicroSystem.ts b/src/engine/systems/AIMicroSystem.ts index c16e2dbb..7420f4da 100644 --- a/src/engine/systems/AIMicroSystem.ts +++ b/src/engine/systems/AIMicroSystem.ts @@ -24,6 +24,8 @@ const THREAT_ASSESSMENT_INTERVAL = DOMINION_AI_CONFIG.micro.global.threatUpdateI const FOCUS_FIRE_THRESHOLD = DOMINION_AI_CONFIG.micro.global.focusFireThreshold; const TRANSFORM_DECISION_INTERVAL = 20; // Update transform decision every 20 ticks (1 second at 20 TPS) const TRANSFORM_SCAN_RANGE = 15; // Range to scan for potential targets when deciding to transform +const AIR_DISENGAGE_HEALTH_PCT = 0.3; // Air units flee below 30% health +const AIR_HIT_AND_RUN_INTERVAL = 15; // Ticks between hit-and-run repositions interface UnitMicroState { behaviorTree: BehaviorTreeRunner; @@ -34,6 +36,7 @@ interface UnitMicroState { primaryTarget: number | null; retreating: boolean; retreatEndTick: number | null; // Tick when retreat should end (replaces setTimeout) + lastHitAndRunTick: number; // Tick when last hit-and-run reposition happened } // Delayed command to be processed at a specific tick @@ -230,6 +233,7 @@ export class AIMicroSystem extends System { primaryTarget: decision.targetId ?? null, retreating: false, retreatEndTick: null, + lastHitAndRunTick: 0, }; this.unitStates.set(decision.unitId, state); } @@ -380,6 +384,7 @@ export class AIMicroSystem extends System { primaryTarget: null, retreating: false, retreatEndTick: null, + lastHitAndRunTick: 0, }; this.unitStates.set(entity.id, state); } @@ -439,6 +444,7 @@ export class AIMicroSystem extends System { primaryTarget: null, retreating: false, retreatEndTick: null, + lastHitAndRunTick: 0, }; this.unitStates.set(entity.id, state); } @@ -471,6 +477,11 @@ export class AIMicroSystem extends System { this.handleTransformDecision(entity.id, selectable.playerId, unit, state, currentTick); } + // Air unit micro: disengage, hit-and-run + if (unit.isFlying && unit.attackDamage > 0) { + this.handleAirMicro(entity.id, selectable.playerId, unit, state, currentTick); + } + // Focus fire logic if (unit.state === 'attacking') { this.handleFocusFire(entity.id, selectable.playerId, unit, state); @@ -883,26 +894,24 @@ export class AIMicroSystem extends System { const isInAssaultMode = !isInFighterMode; if (isInFighterMode) { - // Fighter mode (air-only attacks): switch to assault if ground enemies dominate + // Switch to assault if ground enemies present and no/few air threats if (nearbyGroundEnemies > 0 && nearbyAirEnemies === 0) { shouldTransform = true; targetMode = 'assault'; - } else if (nearbyGroundEnemies > 0 && groundThreatScore > airThreatScore * 2) { - if (nearbyAirEnemies <= 1 && nearbyGroundEnemies >= 3) { - shouldTransform = true; - targetMode = 'assault'; - } + } else if (nearbyGroundEnemies > 0 && groundThreatScore > airThreatScore * 1.5) { + // Lower threshold: switch to ground when ground threat significantly outweighs air + shouldTransform = true; + targetMode = 'assault'; } } else if (isInAssaultMode) { - // Assault mode (ground-only attacks): switch to fighter if air enemies dominate + // Switch to fighter if air enemies present and no/few ground threats if (nearbyAirEnemies > 0 && nearbyGroundEnemies === 0) { shouldTransform = true; targetMode = 'fighter'; - } else if (nearbyAirEnemies > 0 && airThreatScore > groundThreatScore * 2) { - if (nearbyGroundEnemies <= 1 && nearbyAirEnemies >= 2) { - shouldTransform = true; - targetMode = 'fighter'; - } + } else if (nearbyAirEnemies > 0 && airThreatScore > groundThreatScore * 1.5) { + // Lower threshold: switch to air when air threat significantly outweighs ground + shouldTransform = true; + targetMode = 'fighter'; } } } @@ -919,6 +928,147 @@ export class AIMicroSystem extends System { this.game.issueAICommand(command); } } + + /** + * Handle air unit micro-management. + * - Disengage when health is low (air units are fast — use that speed) + * - Avoid anti-air clusters + * - Hit-and-run: attack, reposition, attack + * - Focus high-value targets (workers, support, siege) + */ + private handleAirMicro( + entityId: number, + playerId: string, + unit: Unit, + state: UnitMicroState, + currentTick: number + ): void { + const entity = this.world.getEntity(entityId); + if (!entity) return; + + const transform = entity.get('Transform'); + const health = entity.get('Health'); + if (!transform || !health) return; + + const healthPct = health.getHealthPercent(); + + // Priority 1: Disengage at low health — air units are fast enough to escape + if (healthPct < AIR_DISENGAGE_HEALTH_PCT && unit.state !== 'dead') { + // Flee toward own base (use rally point as proxy) + const baseDir = this.findRetreatDirection(entityId, playerId, transform); + if (baseDir && !state.retreating) { + const command: GameCommand = { + tick: currentTick, + playerId, + type: 'MOVE', + entityIds: [entityId], + targetPosition: { + x: transform.x + baseDir.x * 20, + y: transform.y + baseDir.y * 20, + }, + }; + this.game.issueAICommand(command); + state.retreating = true; + state.retreatEndTick = currentTick + 60; // 3 seconds retreat + return; + } + } + + // Priority 2: Hit-and-run repositioning + // After attacking for a bit, reposition to a different angle + if ( + unit.state === 'attacking' && + unit.attackDamage > 0 && + currentTick - state.lastHitAndRunTick >= AIR_HIT_AND_RUN_INTERVAL + ) { + state.lastHitAndRunTick = currentTick; + + // Find the attack target position + if (unit.targetEntityId !== null) { + const targetEntity = this.world.getEntity(unit.targetEntityId); + if (targetEntity) { + const targetTransform = targetEntity.get('Transform'); + if (targetTransform) { + // Reposition perpendicular to the attack vector + const dx = targetTransform.x - transform.x; + const dy = targetTransform.y - transform.y; + const dist = distance(transform.x, transform.y, targetTransform.x, targetTransform.y); + if (dist > 0) { + // Move perpendicular to attack direction + const perpX = -dy / dist; + const perpY = dx / dist; + const repositionDist = 4 + (entityId % 3); // Slight variation per unit + + const moveCommand: GameCommand = { + tick: currentTick, + playerId, + type: 'MOVE', + entityIds: [entityId], + targetPosition: { + x: transform.x + perpX * repositionDist, + y: transform.y + perpY * repositionDist, + }, + }; + this.game.issueAICommand(moveCommand); + + // Re-engage after repositioning + const retargetCommand: GameCommand = { + tick: currentTick + 5, + playerId, + type: 'ATTACK', + entityIds: [entityId], + targetEntityId: unit.targetEntityId, + }; + this.pendingCommands.push({ + executeTick: currentTick + 5, + command: retargetCommand, + }); + } + } + } + } + } + } + + /** + * Find retreat direction for a unit (toward friendly base). + */ + private findRetreatDirection( + _entityId: number, + playerId: string, + transform: Transform + ): { x: number; y: number } | null { + // Find friendly buildings as retreat anchor + const buildings = this.world.getEntitiesWith('Building', 'Transform', 'Selectable'); + let nearestBaseX = 0; + let nearestBaseY = 0; + let found = false; + let minDist = Infinity; + + for (const building of buildings) { + const selectable = building.get('Selectable'); + if (!selectable || selectable.playerId !== playerId) continue; + const bTransform = building.get('Transform'); + if (!bTransform) continue; + + const d = distance(transform.x, transform.y, bTransform.x, bTransform.y); + if (d < minDist) { + minDist = d; + nearestBaseX = bTransform.x; + nearestBaseY = bTransform.y; + found = true; + } + } + + if (!found) return null; + + const dx = nearestBaseX - transform.x; + const dy = nearestBaseY - transform.y; + const dist = distance(transform.x, transform.y, nearestBaseX, nearestBaseY); + if (dist === 0) return null; + + return { x: dx / dist, y: dy / dist }; + } } // ==================== COUNTER-BUILDING LOGIC ==================== diff --git a/src/engine/systems/ai/AIScoutingManager.ts b/src/engine/systems/ai/AIScoutingManager.ts index 0666fe1e..7603913d 100644 --- a/src/engine/systems/ai/AIScoutingManager.ts +++ b/src/engine/systems/ai/AIScoutingManager.ts @@ -53,7 +53,22 @@ export class AIScoutingManager { const entities = this.coordinator.getCachedUnits(); - // First pass: find fast units + // First pass: prefer idle flying units (best scouts — bypass terrain) + for (const entity of entities) { + const selectable = entity.get('Selectable')!; + const unit = entity.get('Unit')!; + const health = entity.get('Health')!; + + if (selectable.playerId !== ai.playerId) continue; + if (health.isDead()) continue; + if (!unit.isFlying) continue; + if (unit.isWorker) continue; + if (unit.state !== 'idle') continue; + + return entity.id; + } + + // Second pass: find preferred fast ground units for (const entity of entities) { const selectable = entity.get('Selectable')!; const unit = entity.get('Unit')!; @@ -67,7 +82,7 @@ export class AIScoutingManager { } } - // Second pass: find idle worker + // Third pass: find idle worker for (const entity of entities) { const selectable = entity.get('Selectable')!; const unit = entity.get('Unit')!; diff --git a/src/engine/systems/ai/AITacticsManager.ts b/src/engine/systems/ai/AITacticsManager.ts index 0efa39bc..a188751b 100644 --- a/src/engine/systems/ai/AITacticsManager.ts +++ b/src/engine/systems/ai/AITacticsManager.ts @@ -50,6 +50,13 @@ const COMMITMENT_NEAR_ELIMINATION_SCORE_FLOOR = 0.05; // Near-dead enemies almos // Prevents armies from sitting forever at a destroyed base while hunt mode blocks normal disengage. const HUNT_MODE_STUCK_DISENGAGE_TICKS = 300; // ~15 seconds with no combat → force disengage +// Air control constants +const AIR_HARASS_MAX_UNITS = 3; // Max air units for harassment +const AIR_FLANK_OFFSET = 20; // How far air units flank from the main army's attack vector +const AIR_REGROUP_DISTANCE = 30; // Distance from base to regroup air units +const AIR_COMMAND_INTERVAL = 30; // Re-command air units every 30 ticks (~1.5 sec) +const SUPPORT_FOLLOW_DISTANCE = 12; // Support units stay this far behind army center + // Defense scaling during committed attacks const COMMITTED_ATTACK_DANGER_THRESHOLD = 0.8; // Higher danger required to interrupt cleanup const COMMITTED_ATTACK_BUILDING_DAMAGE_THRESHOLD = 0.3; // Only defend badly damaged buildings @@ -72,6 +79,10 @@ export class AITacticsManager { private lastDefenseCommandTick: Map = new Map(); private isEngaged: Map = new Map(); + // Air unit control tracking + private lastAirCommandTick: Map = new Map(); + private lastAirHarassTick: Map = new Map(); + constructor(game: IGameInstance, coordinator: AICoordinator) { this.game = game; this.coordinator = coordinator; @@ -683,6 +694,31 @@ export class AITacticsManager { return airUnits; } + /** + * Get support air units (healers, detectors) that should follow the army. + */ + public getSupportAirUnits(playerId: string): number[] { + const supportUnits: number[] = []; + const entities = this.coordinator.getCachedUnits(); + + for (const entity of entities) { + const selectable = entity.get('Selectable')!; + const unit = entity.get('Unit')!; + const health = entity.get('Health')!; + + if (selectable.playerId !== playerId) continue; + if (health.isDead()) continue; + if (!unit.isFlying) continue; + if (unit.isWorker) continue; + // Support air = flying + no attack damage + if (unit.attackDamage > 0) continue; + + supportUnits.push(entity.id); + } + + return supportUnits; + } + /** * Get army units with their positions for formation calculations. */ @@ -1072,6 +1108,25 @@ export class AITacticsManager { } } + // Separate air units from ground army for independent control + const groundUnits: number[] = []; + const airCombatUnits: number[] = []; + for (const entityId of armyUnits) { + const entity = this.world.getEntity(entityId); + if (!entity) continue; + const unit = entity.get('Unit'); + if (!unit) continue; + if (unit.isFlying) { + airCombatUnits.push(entityId); + } else { + groundUnits.push(entityId); + } + } + + // Use ground units for main army operations, air operates independently. + // If no ground units exist, air units become the main force. + const mainArmyUnits = groundUnits.length > 0 ? groundUnits : armyUnits; + // Per-enemy hunt mode: check if the PRIMARY enemy is near elimination const primaryEnemyId = ai.primaryEnemyId; const primaryRelation = primaryEnemyId ? ai.enemyRelations.get(primaryEnemyId) : null; @@ -1152,8 +1207,8 @@ export class AITacticsManager { ); // Use concave formation for initial attack to spread units naturally - if (armyUnits.length >= 6) { - const groupId = ai.formationControl.createGroup(this.world, armyUnits, ai.playerId); + if (mainArmyUnits.length >= 6) { + const groupId = ai.formationControl.createGroup(this.world, mainArmyUnits, ai.playerId); const slots = ai.formationControl.calculateConcaveFormation( this.world, groupId, @@ -1183,14 +1238,14 @@ export class AITacticsManager { tick: currentTick, playerId: ai.playerId, type: 'ATTACK_MOVE', - entityIds: armyUnits, + entityIds: mainArmyUnits, ...(attackTarget.entityId !== undefined ? { targetEntityId: attackTarget.entityId } : { targetPosition: attackTarget }), }; this.game.issueAICommand(command); debugAI.log( - `[AITactics] ${ai.playerId}: Attacking with ${armyUnits.length} units (no formation)` + `[AITactics] ${ai.playerId}: Attacking with ${mainArmyUnits.length} units (no formation)` ); } } else { @@ -1201,13 +1256,13 @@ export class AITacticsManager { tick: currentTick, playerId: ai.playerId, type: 'ATTACK_MOVE', - entityIds: armyUnits, + entityIds: mainArmyUnits, ...(attackTarget.entityId !== undefined ? { targetEntityId: attackTarget.entityId } : { targetPosition: attackTarget }), }; this.game.issueAICommand(command); - debugAI.log(`[AITactics] ${ai.playerId}: Attacking with ${armyUnits.length} units`); + debugAI.log(`[AITactics] ${ai.playerId}: Attacking with ${mainArmyUnits.length} units`); } } else { // Update target position (may have changed if building was destroyed) @@ -1219,17 +1274,25 @@ export class AITacticsManager { } } + // Command air force independently for flanking attacks + if (airCombatUnits.length > 0 && groundUnits.length > 0) { + this.commandAirForce(ai, airCombatUnits, attackTarget, currentTick); + } + + // Support air units follow the army + this.commandSupportAir(ai, currentTick); + // Re-command idle assault units periodically const lastReCommand = this.lastReCommandTick.get(ai.playerId) || 0; const shouldReCommand = currentTick - lastReCommand >= RE_COMMAND_IDLE_INTERVAL; if (shouldReCommand) { - const idleAssaultUnits = this.getIdleAssaultUnits(ai.playerId, armyUnits, attackTarget); + const idleAssaultUnits = this.getIdleAssaultUnits(ai.playerId, mainArmyUnits, attackTarget); if (idleAssaultUnits.length > 0) { // Check for nearby enemy combat units that should be dealt with first. // In FFA, third-party armies standing near ours must be engaged, not ignored. - const nearbyThreat = this.findNearbyArmyThreat(ai, armyUnits); + const nearbyThreat = this.findNearbyArmyThreat(ai, mainArmyUnits); if (nearbyThreat) { // Enemy combat units detected near our army - engage them first @@ -1468,6 +1531,22 @@ export class AITacticsManager { ); } + // Air units help defend independently + const airDefenders = this.getAirArmyUnits(ai.playerId); + if (airDefenders.length > 0 && threatPos) { + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'ATTACK_MOVE', + entityIds: airDefenders, + targetPosition: threatPos, + }; + this.game.issueAICommand(command); + } + + // Support air follows during defense too + this.commandSupportAir(ai, currentTick); + this.lastDefenseCommandTick.set(ai.playerId, currentTick); if (!this.isUnderAttack(ai)) { @@ -1624,6 +1703,163 @@ export class AITacticsManager { ai.state = 'building'; } + /** + * Command air combat units independently during attacks. + * Air units flank the enemy from a different angle than the ground army, + * creating multi-pronged pressure. Prioritizes enemy air > support > workers. + */ + private commandAirForce( + ai: AIPlayer, + airUnits: number[], + attackTarget: { x: number; y: number }, + currentTick: number + ): void { + if (airUnits.length === 0) return; + + const lastCommand = this.lastAirCommandTick.get(ai.playerId) || 0; + if (currentTick - lastCommand < AIR_COMMAND_INTERVAL) return; + this.lastAirCommandTick.set(ai.playerId, currentTick); + + const basePos = this.coordinator.findAIBase(ai); + if (!basePos) return; + + // Flank direction: perpendicular to the base->target vector + const dx = attackTarget.x - basePos.x; + const dy = attackTarget.y - basePos.y; + const dist = deterministicMagnitude(dx, dy); + if (dist === 0) return; + + const normX = dx / dist; + const normY = dy / dist; + // Perpendicular offset for flanking (air approaches from the side) + const perpX = -normY; + const perpY = normX; + + const flankTarget = { + x: attackTarget.x + perpX * AIR_FLANK_OFFSET, + y: attackTarget.y + perpY * AIR_FLANK_OFFSET, + }; + + // Clamp to map bounds + const mapWidth = this.game.config.mapWidth; + const mapHeight = this.game.config.mapHeight; + flankTarget.x = Math.max(5, Math.min(mapWidth - 5, flankTarget.x)); + flankTarget.y = Math.max(5, Math.min(mapHeight - 5, flankTarget.y)); + + // Issue ATTACK_MOVE to flank position — air units auto-engage enemies along the way + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'ATTACK_MOVE', + entityIds: airUnits, + targetPosition: flankTarget, + }; + this.game.issueAICommand(command); + + debugAI.log( + `[AITactics] ${ai.playerId}: Air force (${airUnits.length} units) flanking to ` + + `(${flankTarget.x.toFixed(0)}, ${flankTarget.y.toFixed(0)})` + ); + } + + /** + * Command support air units (Lifter, Overseer) to follow the main army. + * Support units shadow the army centroid, staying slightly behind. + */ + private commandSupportAir( + ai: AIPlayer, + currentTick: number + ): void { + const supportUnits = this.getSupportAirUnits(ai.playerId); + if (supportUnits.length === 0) return; + + // Only re-command periodically + const lastCommand = this.lastAirCommandTick.get(`${ai.playerId}_support`) || 0; + if (currentTick - lastCommand < AIR_COMMAND_INTERVAL * 2) return; + this.lastAirCommandTick.set(`${ai.playerId}_support`, currentTick); + + // Find army centroid + const armyUnits = this.getArmyUnits(ai.playerId); + if (armyUnits.length === 0) return; + + let sumX = 0; + let sumY = 0; + let count = 0; + for (const entityId of armyUnits) { + const entity = this.world.getEntity(entityId); + if (!entity) continue; + const transform = entity.get('Transform'); + if (!transform) continue; + sumX += transform.x; + sumY += transform.y; + count++; + } + if (count === 0) return; + + const centroidX = sumX / count; + const centroidY = sumY / count; + + // Position support behind army center (toward own base) + const basePos = this.coordinator.findAIBase(ai); + if (!basePos) return; + + const dx = centroidX - basePos.x; + const dy = centroidY - basePos.y; + const dist = deterministicMagnitude(dx, dy); + let followX = centroidX; + let followY = centroidY; + if (dist > 0) { + // Slightly behind the army, toward base + followX = centroidX - (dx / dist) * SUPPORT_FOLLOW_DISTANCE; + followY = centroidY - (dy / dist) * SUPPORT_FOLLOW_DISTANCE; + } + + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'MOVE', + entityIds: supportUnits, + targetPosition: { x: followX, y: followY }, + }; + this.game.issueAICommand(command); + } + + /** + * Execute air harassment against enemy economy. + * Sends a small group of air units to attack enemy worker lines. + * Air units bypass ground defenses (they fly over terrain/obstacles). + */ + public executeAirHarassment(ai: AIPlayer, currentTick: number): void { + const airUnits = this.getAirArmyUnits(ai.playerId); + if (airUnits.length === 0) return; + + const lastHarass = this.lastAirHarassTick.get(ai.playerId) || 0; + if (currentTick - lastHarass < 200) return; // 10-second cooldown between air harass + this.lastAirHarassTick.set(ai.playerId, currentTick); + + // Select up to AIR_HARASS_MAX_UNITS for harassment + const harassGroup = airUnits.slice(0, AIR_HARASS_MAX_UNITS); + + // Find enemy base/workers to harass + const harassTarget = this.findHarassTarget(ai); + if (!harassTarget) return; + + // Air units don't need safe paths — they fly over obstacles + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'ATTACK_MOVE', + entityIds: harassGroup, + targetPosition: harassTarget, + }; + this.game.issueAICommand(command); + + debugAI.log( + `[AITactics] ${ai.playerId}: Air harassment with ${harassGroup.length} units to ` + + `(${harassTarget.x.toFixed(0)}, ${harassTarget.y.toFixed(0)})` + ); + } + // === Near-Army Threat Detection === /** diff --git a/tests/engine/systems/AIMicroSystem.test.ts b/tests/engine/systems/AIMicroSystem.test.ts index 9cbefa44..35c2c6a4 100644 --- a/tests/engine/systems/AIMicroSystem.test.ts +++ b/tests/engine/systems/AIMicroSystem.test.ts @@ -1293,4 +1293,409 @@ describe('AIMicroSystem', () => { expect(canKite(state, 100)).toBe(true); }); }); + + describe('air unit micro behavior', () => { + describe('disengage at low health', () => { + const AIR_DISENGAGE_HEALTH_PCT = 0.3; + + it('should disengage when health below threshold', () => { + const healthPct = 0.25; + expect(healthPct < AIR_DISENGAGE_HEALTH_PCT).toBe(true); + }); + + it('should not disengage at moderate health', () => { + const healthPct = 0.5; + expect(healthPct < AIR_DISENGAGE_HEALTH_PCT).toBe(false); + }); + + it('should not disengage at full health', () => { + const healthPct = 1.0; + expect(healthPct < AIR_DISENGAGE_HEALTH_PCT).toBe(false); + }); + + it('should disengage at exactly the threshold', () => { + const healthPct = 0.3; + // At exactly 0.3, should NOT disengage (strictly less than) + expect(healthPct < AIR_DISENGAGE_HEALTH_PCT).toBe(false); + }); + }); + + describe('hit-and-run repositioning', () => { + const AIR_HIT_AND_RUN_INTERVAL = 15; + + it('should reposition when interval has elapsed', () => { + const lastHitAndRunTick = 100; + const currentTick = 120; + expect(currentTick - lastHitAndRunTick >= AIR_HIT_AND_RUN_INTERVAL).toBe(true); + }); + + it('should not reposition when interval has not elapsed', () => { + const lastHitAndRunTick = 100; + const currentTick = 110; + expect(currentTick - lastHitAndRunTick >= AIR_HIT_AND_RUN_INTERVAL).toBe(false); + }); + + it('should calculate perpendicular reposition direction', () => { + // Attack vector pointing east (1, 0) + const dx = 10; + const dy = 0; + const dist = Math.sqrt(dx * dx + dy * dy); + + // Perpendicular should be (0, 1) or (0, -1) + const perpX = -dy / dist; + const perpY = dx / dist; + + expect(Math.abs(perpX)).toBeCloseTo(0); + expect(Math.abs(perpY)).toBeCloseTo(1); + }); + + it('should calculate perpendicular for diagonal attack vector', () => { + const dx = 10; + const dy = 10; + const dist = Math.sqrt(dx * dx + dy * dy); + + const perpX = -dy / dist; + const perpY = dx / dist; + + // Perpendicular to (1,1) should be (-1,1) normalized + expect(perpX).toBeCloseTo(-Math.SQRT1_2, 4); + expect(perpY).toBeCloseTo(Math.SQRT1_2, 4); + }); + }); + + describe('retreat direction calculation', () => { + it('should retreat toward nearest friendly building', () => { + const unitPos = { x: 50, y: 50 }; + const basePos = { x: 20, y: 20 }; + + const dx = basePos.x - unitPos.x; + const dy = basePos.y - unitPos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + const retreatDir = { x: dx / dist, y: dy / dist }; + + // Should point toward base (negative x, negative y) + expect(retreatDir.x).toBeLessThan(0); + expect(retreatDir.y).toBeLessThan(0); + + // Should be normalized + const magnitude = Math.sqrt(retreatDir.x * retreatDir.x + retreatDir.y * retreatDir.y); + expect(magnitude).toBeCloseTo(1.0, 4); + }); + }); + }); + + describe('air army separation', () => { + interface MockUnitForSeparation { + id: number; + isFlying: boolean; + isWorker: boolean; + isNaval: boolean; + attackDamage: number; + } + + function separateAirAndGround(units: MockUnitForSeparation[]): { + groundUnits: number[]; + airCombatUnits: number[]; + } { + const groundUnits: number[] = []; + const airCombatUnits: number[] = []; + for (const unit of units) { + if (unit.isFlying) { + airCombatUnits.push(unit.id); + } else { + groundUnits.push(unit.id); + } + } + return { groundUnits, airCombatUnits }; + } + + it('should separate ground and air units', () => { + const units: MockUnitForSeparation[] = [ + { id: 1, isFlying: false, isWorker: false, isNaval: false, attackDamage: 10 }, + { id: 2, isFlying: true, isWorker: false, isNaval: false, attackDamage: 15 }, + { id: 3, isFlying: false, isWorker: false, isNaval: false, attackDamage: 20 }, + { id: 4, isFlying: true, isWorker: false, isNaval: false, attackDamage: 12 }, + ]; + + const { groundUnits, airCombatUnits } = separateAirAndGround(units); + expect(groundUnits).toEqual([1, 3]); + expect(airCombatUnits).toEqual([2, 4]); + }); + + it('should handle all-ground army', () => { + const units: MockUnitForSeparation[] = [ + { id: 1, isFlying: false, isWorker: false, isNaval: false, attackDamage: 10 }, + { id: 2, isFlying: false, isWorker: false, isNaval: false, attackDamage: 15 }, + ]; + + const { groundUnits, airCombatUnits } = separateAirAndGround(units); + expect(groundUnits).toEqual([1, 2]); + expect(airCombatUnits).toEqual([]); + }); + + it('should handle all-air army', () => { + const units: MockUnitForSeparation[] = [ + { id: 1, isFlying: true, isWorker: false, isNaval: false, attackDamage: 10 }, + { id: 2, isFlying: true, isWorker: false, isNaval: false, attackDamage: 15 }, + ]; + + const { groundUnits, airCombatUnits } = separateAirAndGround(units); + expect(groundUnits).toEqual([]); + expect(airCombatUnits).toEqual([1, 2]); + }); + + it('should use air as main force when no ground units exist', () => { + const groundUnits: number[] = []; + const armyUnits = [1, 2, 3]; // All units (air-only army) + + const mainArmyUnits = groundUnits.length > 0 ? groundUnits : armyUnits; + expect(mainArmyUnits).toEqual(armyUnits); + }); + + it('should use ground as main force when ground units exist', () => { + const groundUnits = [1, 3]; + const armyUnits = [1, 2, 3, 4]; + + const mainArmyUnits = groundUnits.length > 0 ? groundUnits : armyUnits; + expect(mainArmyUnits).toEqual(groundUnits); + }); + }); + + describe('air flank positioning', () => { + const AIR_FLANK_OFFSET = 20; + + function calculateFlankTarget( + basePos: { x: number; y: number }, + attackTarget: { x: number; y: number }, + mapWidth: number, + mapHeight: number + ): { x: number; y: number } { + const dx = attackTarget.x - basePos.x; + const dy = attackTarget.y - basePos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist === 0) return attackTarget; + + const normX = dx / dist; + const normY = dy / dist; + const perpX = -normY; + const perpY = normX; + + return { + x: Math.max(5, Math.min(mapWidth - 5, attackTarget.x + perpX * AIR_FLANK_OFFSET)), + y: Math.max(5, Math.min(mapHeight - 5, attackTarget.y + perpY * AIR_FLANK_OFFSET)), + }; + } + + it('should position air units perpendicular to attack vector', () => { + const base = { x: 50, y: 50 }; + const target = { x: 150, y: 50 }; // Due east + + const flank = calculateFlankTarget(base, target, 200, 200); + + // Attack is east, so flank should be north or south + expect(flank.x).toBeCloseTo(150, 0); // Same x as target + expect(Math.abs(flank.y - 50)).toBeCloseTo(AIR_FLANK_OFFSET, 0); // Offset in y + }); + + it('should clamp flank position to map bounds', () => { + const base = { x: 50, y: 50 }; + const target = { x: 150, y: 5 }; // Near top edge + + const flank = calculateFlankTarget(base, target, 200, 200); + + // Should be clamped to at least 5 from edges + expect(flank.x).toBeGreaterThanOrEqual(5); + expect(flank.x).toBeLessThanOrEqual(195); + expect(flank.y).toBeGreaterThanOrEqual(5); + expect(flank.y).toBeLessThanOrEqual(195); + }); + + it('should handle diagonal attack vector', () => { + const base = { x: 20, y: 20 }; + const target = { x: 120, y: 120 }; // Northeast + + const flank = calculateFlankTarget(base, target, 200, 200); + + // For a 45-degree attack, flank should be offset perpendicular + const dx = flank.x - target.x; + const dy = flank.y - target.y; + const flankDist = Math.sqrt(dx * dx + dy * dy); + expect(flankDist).toBeCloseTo(AIR_FLANK_OFFSET, 0); + }); + }); + + describe('improved valkyrie transform decisions', () => { + function shouldTransformValkyrie( + isInFighterMode: boolean, + nearbyAirEnemies: number, + nearbyGroundEnemies: number, + airThreatScore: number, + groundThreatScore: number + ): { shouldTransform: boolean; targetMode: string } { + let shouldTransform = false; + let targetMode = ''; + + if (isInFighterMode) { + if (nearbyGroundEnemies > 0 && nearbyAirEnemies === 0) { + shouldTransform = true; + targetMode = 'assault'; + } else if (nearbyGroundEnemies > 0 && groundThreatScore > airThreatScore * 1.5) { + shouldTransform = true; + targetMode = 'assault'; + } + } else { + if (nearbyAirEnemies > 0 && nearbyGroundEnemies === 0) { + shouldTransform = true; + targetMode = 'fighter'; + } else if (nearbyAirEnemies > 0 && airThreatScore > groundThreatScore * 1.5) { + shouldTransform = true; + targetMode = 'fighter'; + } + } + + return { shouldTransform, targetMode }; + } + + it('should switch to assault when only ground enemies present (fighter mode)', () => { + const result = shouldTransformValkyrie(true, 0, 3, 0, 50); + expect(result.shouldTransform).toBe(true); + expect(result.targetMode).toBe('assault'); + }); + + it('should switch to assault when ground threat significantly outweighs air (1.5x)', () => { + const result = shouldTransformValkyrie(true, 1, 3, 10, 20); + expect(result.shouldTransform).toBe(true); + expect(result.targetMode).toBe('assault'); + }); + + it('should NOT switch when air and ground threats are balanced', () => { + const result = shouldTransformValkyrie(true, 2, 2, 15, 15); + expect(result.shouldTransform).toBe(false); + }); + + it('should switch to fighter when only air enemies present (assault mode)', () => { + const result = shouldTransformValkyrie(false, 3, 0, 50, 0); + expect(result.shouldTransform).toBe(true); + expect(result.targetMode).toBe('fighter'); + }); + + it('should switch to fighter when air threat significantly outweighs ground (1.5x)', () => { + const result = shouldTransformValkyrie(false, 2, 1, 20, 10); + expect(result.shouldTransform).toBe(true); + expect(result.targetMode).toBe('fighter'); + }); + + it('should NOT switch from assault when threats are balanced', () => { + const result = shouldTransformValkyrie(false, 2, 2, 15, 15); + expect(result.shouldTransform).toBe(false); + }); + }); + + describe('support air unit filtering', () => { + interface MockSupportUnit { + id: number; + isFlying: boolean; + isWorker: boolean; + attackDamage: number; + isDead: boolean; + } + + function getSupportAirUnits(units: MockSupportUnit[], playerId: string): number[] { + return units + .filter(u => !u.isDead && u.isFlying && !u.isWorker && u.attackDamage === 0) + .map(u => u.id); + } + + it('should return Lifter-type units (flying, no attack)', () => { + const units: MockSupportUnit[] = [ + { id: 1, isFlying: true, isWorker: false, attackDamage: 0, isDead: false }, // Lifter + { id: 2, isFlying: true, isWorker: false, attackDamage: 15, isDead: false }, // Valkyrie + { id: 3, isFlying: false, isWorker: false, attackDamage: 0, isDead: false }, // Ground support + ]; + + expect(getSupportAirUnits(units, 'p1')).toEqual([1]); + }); + + it('should exclude dead units', () => { + const units: MockSupportUnit[] = [ + { id: 1, isFlying: true, isWorker: false, attackDamage: 0, isDead: true }, + ]; + + expect(getSupportAirUnits(units, 'p1')).toEqual([]); + }); + + it('should return empty for combat-only air force', () => { + const units: MockSupportUnit[] = [ + { id: 1, isFlying: true, isWorker: false, attackDamage: 15, isDead: false }, + { id: 2, isFlying: true, isWorker: false, attackDamage: 10, isDead: false }, + ]; + + expect(getSupportAirUnits(units, 'p1')).toEqual([]); + }); + }); + + describe('air scout preference', () => { + interface MockScoutUnit { + id: number; + isFlying: boolean; + isWorker: boolean; + unitId: string; + state: string; + } + + function getScoutUnit( + units: MockScoutUnit[], + preferredTypes: Set + ): number | null { + // First: idle flying units + for (const u of units) { + if (u.isFlying && !u.isWorker && u.state === 'idle') return u.id; + } + // Second: preferred ground types + for (const u of units) { + if (preferredTypes.has(u.unitId)) return u.id; + } + // Third: idle worker + for (const u of units) { + if (u.isWorker && u.state === 'idle') return u.id; + } + return null; + } + + it('should prefer idle flying units over ground scouts', () => { + const units: MockScoutUnit[] = [ + { id: 1, isFlying: false, isWorker: false, unitId: 'vanguard', state: 'idle' }, + { id: 2, isFlying: true, isWorker: false, unitId: 'specter', state: 'idle' }, + ]; + + expect(getScoutUnit(units, new Set(['vanguard']))).toBe(2); + }); + + it('should skip non-idle flying units', () => { + const units: MockScoutUnit[] = [ + { id: 1, isFlying: true, isWorker: false, unitId: 'specter', state: 'attacking' }, + { id: 2, isFlying: false, isWorker: false, unitId: 'vanguard', state: 'idle' }, + ]; + + expect(getScoutUnit(units, new Set(['vanguard']))).toBe(2); + }); + + it('should fall back to idle worker when no other options', () => { + const units: MockScoutUnit[] = [ + { id: 1, isFlying: false, isWorker: false, unitId: 'breacher', state: 'idle' }, + { id: 2, isFlying: false, isWorker: true, unitId: 'fabricator', state: 'idle' }, + ]; + + expect(getScoutUnit(units, new Set(['vanguard']))).toBe(2); + }); + + it('should return null when no suitable scout exists', () => { + const units: MockScoutUnit[] = [ + { id: 1, isFlying: false, isWorker: false, unitId: 'breacher', state: 'attacking' }, + ]; + + expect(getScoutUnit(units, new Set(['vanguard']))).toBe(null); + }); + }); });