diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index fb6044d1..d09e0d7a 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -2709,6 +2709,16 @@ Once an attack launches, `activeAttackOperation` persists on the AIPlayer. The s This prevents the previous bug where the attack cooldown caused the AI to flip between `'attacking'` and `'building'` every few seconds, recalling its army mid-march. +#### Near-Army Threat Detection (FFA) + +During active attack operations, the re-command loop checks for enemy combat units near the army centroid via `findNearbyArmyThreat()`. If 2+ enemy combat units from any player are detected within 25 units, idle assault units are redirected toward the threat via ATTACK_MOVE instead of being sent back to the committed enemy's buildings. This gives SC2-style behavior where armies engage nearby threats before resuming structure destruction. + +#### Combat Threat Retarget + +Units attacking buildings periodically re-evaluate for higher-priority combat unit threats within attack range (every 15 ticks via `findThreatRetarget()`). If an enemy combat unit is in weapon range while the unit is hitting a building, it switches targets. This prevents the scenario where two armies stand beside each other ignoring one another because each unit has a building `targetEntityId` locked in. Assault mode and attack-move destinations are preserved through target switches. + +The engagement buffer during attack-move marches is set to `attackRange + 5`, allowing units to break formation and engage enemies within a moderate distance during approach. + #### Reactive Defense System Two-layer defense system inspired by SC2's region-based defense: diff --git a/src/engine/systems/CombatSystem.ts b/src/engine/systems/CombatSystem.ts index e35be0ed..cf0154cb 100644 --- a/src/engine/systems/CombatSystem.ts +++ b/src/engine/systems/CombatSystem.ts @@ -96,6 +96,13 @@ export class CombatSystem extends System { maxEntries: 1000, }); + // Threat retarget throttle - periodically re-evaluate targets for units attacking buildings. + // SC2-style: armies always prioritize nearby combat unit threats over structures. + private readonly threatRetargetThrottle = new ThrottledCache({ + cooldown: 15, // Every 15 ticks (~750ms) + maxEntries: 500, + }); + // Cache current targets to avoid re-searching private readonly targetCache = new ThrottledCache({ cooldown: 10, // TARGET_CACHE_DURATION - cache valid for 10 ticks (~0.5 sec) @@ -503,6 +510,7 @@ export class CombatSystem extends System { // PERF: Clean up combat zone tracking this.combatAwareUnits.delete(data.entityId); this.combatZoneCheckTick.delete(data.entityId); + this.threatRetargetThrottle.delete(data.entityId); } private handleAttackCommand(command: { @@ -751,9 +759,9 @@ export class CombatSystem extends System { } else { distToTarget = transform.distanceTo(candidateTransform); } - // Engagement buffer: switch to attacking when within attack range + 3 - // This gives the unit ~1 second to close while still maintaining formation for most of the march - if (distToTarget > unit.attackRange + 3) { + // Engagement buffer: switch to attacking when within attack range + 5 + // Wider buffer ensures units respond to nearby threats during march + if (distToTarget > unit.attackRange + 5) { target = null; } } @@ -797,6 +805,40 @@ export class CombatSystem extends System { } } + // Threat-based retarget: units attacking buildings should periodically check + // for higher-priority combat unit threats within attack range. + // SC2-style: armies always prioritize nearby combat threats over structures. + if (unit.targetEntityId !== null && unit.state === 'attacking') { + if (this.threatRetargetThrottle.canExecute(attacker.id, currentTick)) { + this.threatRetargetThrottle.markExecuted(attacker.id, currentTick); + const currentTargetEntity = this.world.getEntity(unit.targetEntityId); + if (currentTargetEntity) { + const isTargetBuilding = currentTargetEntity.get('Building') !== null; + if (isTargetBuilding) { + const betterTarget = this.findThreatRetarget(attacker.id, transform, unit); + if (betterTarget !== null) { + const savedAssaultDest = unit.assaultDestination; + const wasInAssaultMode = unit.isInAssaultMode; + const savedTargetX = unit.targetX; + const savedTargetY = unit.targetY; + + unit.setAttackTarget(betterTarget); + + if (wasInAssaultMode && savedAssaultDest) { + unit.assaultDestination = savedAssaultDest; + unit.isInAssaultMode = true; + unit.assaultIdleTicks = 0; + } + if (savedTargetX !== null && savedTargetY !== null) { + unit.targetX = savedTargetX; + unit.targetY = savedTargetY; + } + } + } + } + } + } + // Process attacks for units in attacking state // Also process for canAttackWhileMoving units that are moving/attackmoving with a target const canProcessAttack = @@ -1270,6 +1312,42 @@ export class CombatSystem extends System { return result?.entityId ?? null; } + /** + * Find a higher-priority combat unit threat when currently attacking a building. + * Searches within attack range for enemy combat units (not buildings). + * Returns the entity ID of the best threat, or null if no threats found. + */ + private findThreatRetarget( + selfId: number, + selfTransform: Transform, + selfUnit: Unit + ): number | null { + const selfEntity = this.world.getEntity(selfId); + if (!validateEntityAlive(selfEntity, selfId, 'CombatSystem:findThreatRetarget')) return null; + const selfSelectable = selfEntity.get('Selectable'); + if (!selfSelectable) return null; + + const result = findBestTargetShared(this.world, { + x: selfTransform.x, + y: selfTransform.y, + range: selfUnit.attackRange, + attackerPlayerId: selfSelectable.playerId, + attackerTeamId: selfSelectable.teamId, + attackRange: selfUnit.attackRange, + canAttackAir: selfUnit.canAttackAir, + canAttackGround: selfUnit.canAttackGround, + canAttackNaval: selfUnit.canAttackNaval, + includeBuildingsInSearch: false, + attackerVisualRadius: AssetManager.getCachedVisualRadius( + selfUnit.unitId, + selfUnit.collisionRadius + ), + excludeEntityId: selfId, + }); + + return result?.entityId ?? null; + } + private performAttack( attackerId: number, attacker: Unit, diff --git a/src/engine/systems/ai/AITacticsManager.ts b/src/engine/systems/ai/AITacticsManager.ts index f626d4bc..a4a5e430 100644 --- a/src/engine/systems/ai/AITacticsManager.ts +++ b/src/engine/systems/ai/AITacticsManager.ts @@ -1192,7 +1192,25 @@ export class AITacticsManager { const idleAssaultUnits = this.getIdleAssaultUnits(ai.playerId, armyUnits); if (idleAssaultUnits.length > 0) { - if (attackTarget.entityId !== undefined) { + // 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); + + if (nearbyThreat) { + // Enemy combat units detected near our army - engage them first + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'ATTACK_MOVE', + entityIds: idleAssaultUnits, + targetPosition: nearbyThreat, + }; + this.game.issueAICommand(command); + debugAI.log( + `[AITactics] ${ai.playerId}: Redirecting ${idleAssaultUnits.length} idle units to nearby threat at (${nearbyThreat.x.toFixed(0)}, ${nearbyThreat.y.toFixed(0)})` + ); + } else if (attackTarget.entityId !== undefined) { + // No nearby threats - continue attacking the building. // Entity-targeted attack: units go directly to 'attacking' state with // the building as their target, bypassing the attack-move engagement // buffer. This prevents the idle→attackmove→idle cycle that caused @@ -1556,6 +1574,65 @@ export class AITacticsManager { ai.state = 'building'; } + // === Near-Army Threat Detection === + + /** + * Find enemy combat units near the army centroid during attack operations. + * Detects third-party threats (e.g., AI 3's army near AI 1's army while both + * attack AI 2's base). Returns position of the nearest threat. + */ + private findNearbyArmyThreat(ai: AIPlayer, armyUnits: number[]): { x: number; y: number } | null { + 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 null; + + const centroidX = sumX / count; + const centroidY = sumY / count; + + const THREAT_DETECTION_RADIUS = 25; + const units = this.coordinator.getCachedUnitsWithTransform(); + + let nearestThreat: { x: number; y: number; distance: number } | null = null; + let threatCount = 0; + + 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 === ai.playerId) continue; + if (!isEnemy(ai.playerId, ai.teamId, selectable.playerId, selectable.teamId)) continue; + if (health.isDead()) continue; + if (unit.isWorker) continue; + if (unit.attackDamage === 0) continue; + + const dx = transform.x - centroidX; + const dy = transform.y - centroidY; + const dist = deterministicMagnitude(dx, dy); + if (dist > THREAT_DETECTION_RADIUS) continue; + + threatCount++; + if (!nearestThreat || dist < nearestThreat.distance) { + nearestThreat = { x: transform.x, y: transform.y, distance: dist }; + } + } + + // Only respond to meaningful threats (2+ enemy combat units) + if (threatCount < 2) return null; + return nearestThreat ? { x: nearestThreat.x, y: nearestThreat.y } : null; + } + // === Engagement Tracking === /**