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
23 changes: 16 additions & 7 deletions src/engine/systems/CombatSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,17 +656,17 @@ export class CombatSystem extends System {
if (!transform || !unit || !health || health.isDead()) continue;

// Auto-acquire targets for units that need them
// canAttackWhileMoving units also acquire targets while moving
// Moving units check attack range only (don't chase at sight range during move orders)
// RTS-STYLE: Assault mode units ALWAYS need to acquire targets when idle
const needsTarget =
unit.targetEntityId === null &&
(unit.state === 'idle' ||
unit.state === 'patrolling' ||
unit.state === 'attackmoving' ||
unit.state === 'attacking' ||
unit.state === 'moving' || // All moving units check for immediate threats
unit.isHoldingPosition ||
unit.isInAssaultMode || // RTS-STYLE: Assault mode units always scan
(unit.canAttackWhileMoving && unit.state === 'moving'));
unit.isInAssaultMode); // RTS-STYLE: Assault mode units always scan

if (needsTarget) {
// PERF: Use hot cell check instead of expensive spatial query for idle units
Expand Down Expand Up @@ -720,13 +720,19 @@ export class CombatSystem extends System {
// This prevents the "blind zone" between attackRange (5-6) and sightRange (24-30)
// where units can see enemies but won't engage them
target = this.findBestTargetSpatial(attacker.id, transform, unit);
} else if (unit.state === 'moving') {
// Moving units only engage enemies within attack range (immediate threats).
// Don't chase at sight range — that would disrupt movement orders.
// This prevents units from walking right past enemies in their face.
target = this.findImmediateAttackTarget(attacker.id, transform, unit, currentTick);
} else if (unit.isHoldingPosition) {
// Holding position units only attack within attack range
target = this.findImmediateAttackTarget(attacker.id, transform, unit, currentTick);
}

// If no immediate target found, use throttled search within sight range
if (target === null && !unit.isHoldingPosition) {
// (skip for moving/holding units — they only react to immediate threats)
if (target === null && !unit.isHoldingPosition && unit.state !== 'moving') {
target = this.getTargetThrottled(attacker.id, transform, unit, currentTick);
}

Expand Down Expand Up @@ -806,7 +812,7 @@ export class CombatSystem extends System {
}

// Threat-based retarget: units attacking buildings should periodically check
// for higher-priority combat unit threats within attack range.
// for higher-priority combat unit threats within sight 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)) {
Expand Down Expand Up @@ -1314,7 +1320,7 @@ export class CombatSystem extends System {

/**
* Find a higher-priority combat unit threat when currently attacking a building.
* Searches within attack range for enemy combat units (not buildings).
* Searches within sight range for enemy combat units (not buildings).
* Returns the entity ID of the best threat, or null if no threats found.
*/
private findThreatRetarget(
Expand All @@ -1327,10 +1333,13 @@ export class CombatSystem extends System {
const selfSelectable = selfEntity.get<Selectable>('Selectable');
if (!selfSelectable) return null;

// Use sight range so units attacking buildings detect approaching threats early,
// not just enemies already in attack range. Prevents armies from ignoring enemies
// walking right past them while they're focused on a structure.
const result = findBestTargetShared(this.world, {
x: selfTransform.x,
y: selfTransform.y,
range: selfUnit.attackRange,
range: selfUnit.sightRange,
attackerPlayerId: selfSelectable.playerId,
attackerTeamId: selfSelectable.teamId,
attackRange: selfUnit.attackRange,
Expand Down
35 changes: 26 additions & 9 deletions src/engine/systems/ai/AITacticsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ const HUNT_MODE_BUILDING_THRESHOLD = 3; // Enter hunt mode when enemy has <= 3 b
const COMMITMENT_SWITCH_SCORE_MULTIPLIER = 1.5; // New target must score 1.5x higher to switch
const COMMITMENT_NEAR_ELIMINATION_SCORE_FLOOR = 0.05; // Near-dead enemies almost never abandoned

// Hunt mode stuck detection: disengage even in hunt mode after prolonged non-engagement.
// 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

// 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
Expand Down Expand Up @@ -1298,14 +1302,25 @@ export class AITacticsManager {
this.isEngaged.set(ai.playerId, false);
debugAI.log(`[AITactics] ${ai.playerId}: Enemy eliminated, returning units to base`);
}
} else if (!engaged && !inHuntMode) {
// Disengage timeout -- but never disengage during hunt mode
// Use lastEngagedTick (when engagement was last true), not lastEngagementCheck
// (which just tracks when we last ran the check and resets every 10 ticks)
} else if (!engaged) {
const lastEngaged =
this.lastEngagedTick.get(ai.playerId) ?? ai.activeAttackOperation?.startTick ?? currentTick;
const disengagedDuration = currentTick - lastEngaged;
if (disengagedDuration > 100) {

if (inHuntMode) {
// Hunt mode stuck detection: if army has had zero combat for an extended period,
// the target is likely unreachable or already destroyed by another player.
// Force disengage to prevent armies sitting at destroyed bases forever.
if (disengagedDuration > HUNT_MODE_STUCK_DISENGAGE_TICKS) {
this.returnUnitsToBase(ai, armyUnits, currentTick);
ai.state = 'building';
ai.activeAttackOperation = null;
this.isEngaged.set(ai.playerId, false);
debugAI.log(
`[AITactics] ${ai.playerId}: Hunt mode stuck for ${disengagedDuration} ticks with no combat, returning to build`
);
}
} else if (disengagedDuration > 100) {
this.returnUnitsToBase(ai, armyUnits, currentTick);
ai.state = 'building';
ai.activeAttackOperation = null;
Expand All @@ -1315,7 +1330,6 @@ export class AITacticsManager {
);
}
}
// When inHuntMode: never disengage, keep marching toward target
}

/**
Expand Down Expand Up @@ -1717,9 +1731,12 @@ export class AITacticsManager {
!unit.isHoldingPosition;

if (isIdleAssault || isCompletelyIdle) {
// Don't re-command units already at the target — this prevents the
// re-command → assaultIdleTicks reset cycle that kept units permanently stuck
if (attackTarget) {
// For units STILL in assault mode, skip if near target to avoid resetting
// assaultIdleTicks (which prevents CombatSystem's timeout from firing).
// For completely idle units (assault mode already timed out), ALWAYS include
// them — they're in a dead zone where CombatSystem skips them (not in hot cell)
// and they need an explicit ATTACK command to re-engage the target building.
if (isIdleAssault && attackTarget) {
const transform = entity.get<Transform>('Transform');
if (transform) {
const dx = transform.x - attackTarget.x;
Expand Down
120 changes: 120 additions & 0 deletions tests/engine/systems/ai/AIGameCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -980,4 +980,124 @@ describe('AI Game Completion', () => {
expect(result).toBe(true); // justDefended bypasses cooldown
});
});

describe('Hunt mode stuck detection', () => {
// Mirror of hunt mode stuck disengage constant from AITacticsManager
const HUNT_MODE_STUCK_DISENGAGE_TICKS = 300;

/**
* Mirror of the disengage decision logic.
* Returns true if the army should return to base.
*/
function shouldDisengage(
engaged: boolean,
inHuntMode: boolean,
disengagedDuration: number
): boolean {
if (engaged) return false;

if (inHuntMode) {
// Hunt mode stuck detection: force disengage after prolonged non-engagement
return disengagedDuration > HUNT_MODE_STUCK_DISENGAGE_TICKS;
}

// Normal disengage: 100 ticks without combat
return disengagedDuration > 100;
}

it('allows normal disengage after 100 ticks when not in hunt mode', () => {
expect(shouldDisengage(false, false, 150)).toBe(true);
});

it('does NOT disengage when engaged', () => {
expect(shouldDisengage(true, false, 500)).toBe(false);
expect(shouldDisengage(true, true, 500)).toBe(false);
});

it('does NOT disengage within normal timeout', () => {
expect(shouldDisengage(false, false, 50)).toBe(false);
});

it('forces disengage in hunt mode after stuck timeout', () => {
// Army has been idle for 300+ ticks during hunt mode → must disengage
// This prevents armies sitting at destroyed bases forever
expect(shouldDisengage(false, true, 350)).toBe(true);
});

it('does NOT disengage in hunt mode before stuck timeout', () => {
// Hunt mode should persist for a reasonable duration to allow pathfinding
expect(shouldDisengage(false, true, 150)).toBe(false);
expect(shouldDisengage(false, true, 250)).toBe(false);
});

it('hunt mode stuck timeout is longer than normal disengage', () => {
// Normal disengage at 100 ticks. Hunt mode should allow more time
// before giving up (target may be far away, pathfinding takes time).
expect(HUNT_MODE_STUCK_DISENGAGE_TICKS).toBeGreaterThan(100);
});

it('handles the FFA scenario: army at destroyed base with remote enemy buildings', () => {
// Scenario: AI army destroys enemy base at position A.
// Enemy has 2 buildings left at remote position B.
// Army can't reach B (pathfinding blocked / too far).
// Without fix: army sits at A forever because hunt mode blocks disengage.
// With fix: after 300 ticks of no combat, army returns to base.
const primaryRelation = createRelation({ buildingCount: 2 });
const inHuntMode =
primaryRelation.buildingCount > 0 &&
primaryRelation.buildingCount <= HUNT_MODE_BUILDING_THRESHOLD;

expect(inHuntMode).toBe(true);
// Army has had no combat for 400 ticks
expect(shouldDisengage(false, inHuntMode, 400)).toBe(true);
});
});

describe('At-target idle unit re-commanding', () => {
const AT_TARGET_THRESHOLD = 8;

/**
* Mirror of getIdleAssaultUnits at-target skip logic.
* Returns true if a unit should be included in re-command list.
*/
function shouldRecommandUnit(
isIdleAssault: boolean,
isCompletelyIdle: boolean,
distanceToTarget: number
): boolean {
if (!isIdleAssault && !isCompletelyIdle) return false;

// Only skip assault-mode units near the target (to preserve assault idle timeout).
// Completely idle units (assault mode already timed out) must always be re-commanded
// to escape the dead zone where CombatSystem also skips them.
if (isIdleAssault && distanceToTarget < AT_TARGET_THRESHOLD) {
return false;
}

return true;
}

it('skips assault-mode units near target to preserve idle timeout', () => {
expect(shouldRecommandUnit(true, false, 5)).toBe(false);
});

it('includes assault-mode units far from target', () => {
expect(shouldRecommandUnit(true, false, 20)).toBe(true);
});

it('always includes completely idle units even near target', () => {
// These units already timed out of assault mode. CombatSystem skips them
// (not in hot cell / combat zone), so they MUST be re-commanded by the AI.
expect(shouldRecommandUnit(false, true, 3)).toBe(true);
expect(shouldRecommandUnit(false, true, 7)).toBe(true);
});

it('includes completely idle units far from target', () => {
expect(shouldRecommandUnit(false, true, 50)).toBe(true);
});

it('skips units that are neither idle-assault nor completely-idle', () => {
expect(shouldRecommandUnit(false, false, 5)).toBe(false);
});
});
});
74 changes: 74 additions & 0 deletions tests/engine/systems/combatSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,4 +1040,78 @@ describe('CombatSystem', () => {
expect(destroyed).toEqual([1]);
});
});

describe('Target acquisition state coverage', () => {
/**
* Mirror of CombatSystem needsTarget logic.
* Tests which unit states allow target acquisition.
*/
function needsTarget(
targetEntityId: number | null,
state: string,
isHoldingPosition: boolean,
isInAssaultMode: boolean
): boolean {
return (
targetEntityId === null &&
(state === 'idle' ||
state === 'patrolling' ||
state === 'attackmoving' ||
state === 'attacking' ||
state === 'moving' || // All moving units check for immediate threats
isHoldingPosition ||
isInAssaultMode)
);
}

it('moving units can acquire targets', () => {
// Prevents units from walking past enemies in their face
expect(needsTarget(null, 'moving', false, false)).toBe(true);
});

it('moving units with existing target do not re-acquire', () => {
expect(needsTarget(42, 'moving', false, false)).toBe(false);
});

it('idle units can acquire targets', () => {
expect(needsTarget(null, 'idle', false, false)).toBe(true);
});

it('attackmoving units can acquire targets', () => {
expect(needsTarget(null, 'attackmoving', false, false)).toBe(true);
});

it('assault mode units can acquire targets regardless of state', () => {
expect(needsTarget(null, 'idle', false, true)).toBe(true);
});

it('gathering units cannot acquire targets', () => {
expect(needsTarget(null, 'gathering', false, false)).toBe(false);
});

it('building units cannot acquire targets', () => {
expect(needsTarget(null, 'building', false, false)).toBe(false);
});
});

describe('Threat retarget range', () => {
it('threat retarget uses sight range for building-attacking units', () => {
// Units attacking buildings should detect approaching combat threats
// at sight range, not just attack range. This prevents enemies from
// walking right past the army unchallenged.
const attackRange = 6;
const sightRange = 24;

// The retarget search should use sightRange, not attackRange
// Verify the design: sight range is significantly larger than attack range
expect(sightRange).toBeGreaterThan(attackRange * 2);

// With sight-range retarget, an enemy at distance 15 would be detected
const enemyDistance = 15;
expect(enemyDistance).toBeLessThan(sightRange);
expect(enemyDistance).toBeGreaterThan(attackRange);
// Before the fix, this enemy would NOT trigger retargeting (was using attackRange)
// After the fix, it WILL trigger retargeting (now using sightRange)
});
});
});
Loading