From d28d4cf9f8ed60d2be6a6592368e2dc78a635970 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 03:52:33 +0000 Subject: [PATCH] fix: prevent AI armies from getting permanently stuck at destroyed enemy bases Four fixes for AI combat behavior in FFA games: 1. Hunt mode stuck detection: armies that have had no combat for 300 ticks (~15s) during hunt mode now force-disengage and return to base, instead of sitting at destroyed enemy bases forever. 2. At-target dead zone: completely idle units (assault mode already timed out) near a target building are no longer skipped by the re-command system. Previously these units fell into a dead zone where CombatSystem skipped them (not in hot cell) AND AITacticsManager skipped them (within at-target threshold), leaving them permanently stuck. 3. Threat retarget range: units attacking buildings now detect approaching enemy combat units at sight range instead of attack range, so armies won't ignore enemies walking right past them. 4. Moving units scan for threats: all moving units now check for enemies within attack range (immediate threats only), so armies don't walk past enemies in their face without reacting. https://claude.ai/code/session_01GvKdDRj8RdBUFmnvyPSYJK --- src/engine/systems/CombatSystem.ts | 23 +++- src/engine/systems/ai/AITacticsManager.ts | 35 +++-- .../systems/ai/AIGameCompletion.test.ts | 120 ++++++++++++++++++ tests/engine/systems/combatSystem.test.ts | 74 +++++++++++ 4 files changed, 236 insertions(+), 16 deletions(-) diff --git a/src/engine/systems/CombatSystem.ts b/src/engine/systems/CombatSystem.ts index cf0154cb..ac46a9d2 100644 --- a/src/engine/systems/CombatSystem.ts +++ b/src/engine/systems/CombatSystem.ts @@ -656,7 +656,7 @@ 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 && @@ -664,9 +664,9 @@ export class CombatSystem extends System { 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 @@ -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); } @@ -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)) { @@ -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( @@ -1327,10 +1333,13 @@ export class CombatSystem extends System { const selfSelectable = selfEntity.get('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, diff --git a/src/engine/systems/ai/AITacticsManager.ts b/src/engine/systems/ai/AITacticsManager.ts index 20148761..f70cbd9d 100644 --- a/src/engine/systems/ai/AITacticsManager.ts +++ b/src/engine/systems/ai/AITacticsManager.ts @@ -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 @@ -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; @@ -1315,7 +1330,6 @@ export class AITacticsManager { ); } } - // When inHuntMode: never disengage, keep marching toward target } /** @@ -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'); if (transform) { const dx = transform.x - attackTarget.x; diff --git a/tests/engine/systems/ai/AIGameCompletion.test.ts b/tests/engine/systems/ai/AIGameCompletion.test.ts index 0e2bfdee..31c8cc0a 100644 --- a/tests/engine/systems/ai/AIGameCompletion.test.ts +++ b/tests/engine/systems/ai/AIGameCompletion.test.ts @@ -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); + }); + }); }); diff --git a/tests/engine/systems/combatSystem.test.ts b/tests/engine/systems/combatSystem.test.ts index 6f5eebe4..0d0a99f4 100644 --- a/tests/engine/systems/combatSystem.test.ts +++ b/tests/engine/systems/combatSystem.test.ts @@ -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) + }); + }); });