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) + }); + }); });