Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/architecture/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
84 changes: 81 additions & 3 deletions src/engine/systems/CombatSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>({
cooldown: 15, // Every 15 ticks (~750ms)
maxEntries: 500,
});

// Cache current targets to avoid re-searching
private readonly targetCache = new ThrottledCache<number, number>({
cooldown: 10, // TARGET_CACHE_DURATION - cache valid for 10 ticks (~0.5 sec)
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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>('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 =
Expand Down Expand Up @@ -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>('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,
Expand Down
79 changes: 78 additions & 1 deletion src/engine/systems/ai/AITacticsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>('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>('Selectable')!;
const unit = entity.get<Unit>('Unit')!;
const transform = entity.get<Transform>('Transform')!;
const health = entity.get<Health>('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 ===

/**
Expand Down
Loading