diff --git a/src/engine/systems/CombatSystem.ts b/src/engine/systems/CombatSystem.ts index fc8db719..2dee6339 100644 --- a/src/engine/systems/CombatSystem.ts +++ b/src/engine/systems/CombatSystem.ts @@ -768,12 +768,21 @@ export class CombatSystem extends System { // Resume attack-move to destination unit.state = 'attackmoving'; unit.targetEntityId = null; + } else if (unit.isInAssaultMode && unit.assaultDestination) { + // RTS-STYLE: Resume attack-moving toward assault destination + // This is the SC2 behavior: after killing a target, units continue + // toward the a-move destination, engaging enemies along the way. + // Without this, units go idle and get stranded far from the fight. + unit.targetEntityId = null; + unit.targetX = unit.assaultDestination.x; + unit.targetY = unit.assaultDestination.y; + unit.state = 'attackmoving'; + unit.path = []; + unit.pathIndex = 0; } else if (unit.isInAssaultMode) { - // RTS-STYLE: Assault mode units stay in assault mode, ready to scan for new targets - // They go "idle" but with assault mode flag still set, so they keep scanning + // Assault mode but no destination - stay idle and scan for new targets unit.targetEntityId = null; unit.state = 'idle'; - // Don't clear assault mode - unit will immediately scan for new targets next tick } else if (!unit.executeNextCommand()) { unit.clearTarget(); } @@ -786,7 +795,7 @@ export class CombatSystem extends System { const targetBuilding = targetEntity.get('Building'); if (!targetTransform || !targetHealth || targetHealth.isDead()) { - // Target dead + // Target dead - this is the primary kill path (units marked dead, not destroyed) if (isAttackingWhileMoving) { // Attack-while-moving units just clear target and keep moving unit.targetEntityId = null; @@ -794,11 +803,19 @@ export class CombatSystem extends System { // Resume attack-move to destination unit.state = 'attackmoving'; unit.targetEntityId = null; + } else if (unit.isInAssaultMode && unit.assaultDestination) { + // RTS-STYLE: Resume attack-moving toward assault destination after kill. + // Without this, units go idle and get stranded far from the fight. + unit.targetEntityId = null; + unit.targetX = unit.assaultDestination.x; + unit.targetY = unit.assaultDestination.y; + unit.state = 'attackmoving'; + unit.path = []; + unit.pathIndex = 0; } else if (unit.isInAssaultMode) { - // RTS-STYLE: Assault mode units stay aggressive and keep scanning + // Assault mode but no destination - stay idle and scan for new targets unit.targetEntityId = null; unit.state = 'idle'; - // Assault mode preserved - unit will scan for new targets } else if (!unit.executeNextCommand()) { unit.clearTarget(); } @@ -820,8 +837,16 @@ export class CombatSystem extends System { } else if (unit.targetX !== null && unit.targetY !== null) { unit.state = 'attackmoving'; unit.targetEntityId = null; + } else if (unit.isInAssaultMode && unit.assaultDestination) { + // RTS-STYLE: Resume attack-moving toward assault destination + unit.targetEntityId = null; + unit.targetX = unit.assaultDestination.x; + unit.targetY = unit.assaultDestination.y; + unit.state = 'attackmoving'; + unit.path = []; + unit.pathIndex = 0; } else if (unit.isInAssaultMode) { - // RTS-STYLE: Stay aggressive, find a target we CAN attack + // Assault mode but no destination - stay idle and scan unit.targetEntityId = null; unit.state = 'idle'; } else if (!unit.executeNextCommand()) { diff --git a/tests/engine/systems/combatSystem.test.ts b/tests/engine/systems/combatSystem.test.ts index 3566171e..a8b86bc1 100644 --- a/tests/engine/systems/combatSystem.test.ts +++ b/tests/engine/systems/combatSystem.test.ts @@ -528,4 +528,172 @@ describe('CombatSystem', () => { expect(damage).toBe(1); }); }); + + describe('target death state transitions', () => { + /** + * Replicates the state transition logic from CombatSystem when a target dies. + * This is the isDead() code path (primary kill path - units marked dead, not destroyed). + * Returns the new state and target info for the attacking unit. + */ + interface UnitState { + state: string; + targetEntityId: number | null; + targetX: number | null; + targetY: number | null; + isInAssaultMode: boolean; + assaultDestination: { x: number; y: number } | null; + path: number[]; + pathIndex: number; + } + + function resolveTargetDeath( + unit: UnitState, + isAttackingWhileMoving: boolean + ): UnitState { + const result = { ...unit, path: [...unit.path] }; + + if (isAttackingWhileMoving) { + result.targetEntityId = null; + } else if (result.targetX !== null && result.targetY !== null) { + result.state = 'attackmoving'; + result.targetEntityId = null; + } else if (result.isInAssaultMode && result.assaultDestination) { + result.targetEntityId = null; + result.targetX = result.assaultDestination.x; + result.targetY = result.assaultDestination.y; + result.state = 'attackmoving'; + result.path = []; + result.pathIndex = 0; + } else if (result.isInAssaultMode) { + result.targetEntityId = null; + result.state = 'idle'; + } else { + // Fallback: clearTarget + result.targetEntityId = null; + result.targetX = null; + result.targetY = null; + result.state = 'idle'; + } + + return result; + } + + it('attack-while-moving units just clear target and keep moving', () => { + const unit: UnitState = { + state: 'moving', + targetEntityId: 42, + targetX: 100, + targetY: 200, + isInAssaultMode: false, + assaultDestination: null, + path: [1, 2, 3], + pathIndex: 1, + }; + + const result = resolveTargetDeath(unit, true); + expect(result.targetEntityId).toBeNull(); + expect(result.state).toBe('moving'); // Unchanged + expect(result.targetX).toBe(100); // Preserved + expect(result.targetY).toBe(200); // Preserved + }); + + it('units with targetX/Y resume attack-moving to destination', () => { + const unit: UnitState = { + state: 'attacking', + targetEntityId: 42, + targetX: 100, + targetY: 200, + isInAssaultMode: true, + assaultDestination: { x: 150, y: 250 }, + path: [1, 2], + pathIndex: 0, + }; + + const result = resolveTargetDeath(unit, false); + expect(result.state).toBe('attackmoving'); + expect(result.targetEntityId).toBeNull(); + expect(result.targetX).toBe(100); // Uses saved targetX, not assaultDestination + expect(result.targetY).toBe(200); + }); + + it('assault mode units with assaultDestination resume attack-moving (no targetX/Y)', () => { + const unit: UnitState = { + state: 'attacking', + targetEntityId: 42, + targetX: null, + targetY: null, + isInAssaultMode: true, + assaultDestination: { x: 150, y: 250 }, + path: [1, 2, 3], + pathIndex: 2, + }; + + const result = resolveTargetDeath(unit, false); + expect(result.state).toBe('attackmoving'); + expect(result.targetEntityId).toBeNull(); + expect(result.targetX).toBe(150); // From assaultDestination + expect(result.targetY).toBe(250); + expect(result.path).toEqual([]); // Path cleared for re-pathing + expect(result.pathIndex).toBe(0); + }); + + it('assault mode units without assaultDestination go idle', () => { + const unit: UnitState = { + state: 'attacking', + targetEntityId: 42, + targetX: null, + targetY: null, + isInAssaultMode: true, + assaultDestination: null, + path: [], + pathIndex: 0, + }; + + const result = resolveTargetDeath(unit, false); + expect(result.state).toBe('idle'); + expect(result.targetEntityId).toBeNull(); + }); + + it('non-assault units with no destination go idle (fallback)', () => { + const unit: UnitState = { + state: 'attacking', + targetEntityId: 42, + targetX: null, + targetY: null, + isInAssaultMode: false, + assaultDestination: null, + path: [], + pathIndex: 0, + }; + + const result = resolveTargetDeath(unit, false); + expect(result.state).toBe('idle'); + expect(result.targetEntityId).toBeNull(); + expect(result.targetX).toBeNull(); + expect(result.targetY).toBeNull(); + }); + + it('assault destination resume prevents unit stranding after arrival + kill', () => { + // Scenario: Unit attack-moves, arrives at destination (targetX/Y cleared by handleArrival), + // engages an enemy, kills it. Without the fix, unit goes idle with no movement target. + // With the fix, unit resumes attack-moving toward assaultDestination. + const unit: UnitState = { + state: 'attacking', + targetEntityId: 99, + targetX: null, // Cleared by handleArrival + targetY: null, + isInAssaultMode: true, + assaultDestination: { x: 50, y: 75 }, // Original attack-move destination + path: [], + pathIndex: 0, + }; + + const result = resolveTargetDeath(unit, false); + // Unit should resume attack-moving, NOT go idle + expect(result.state).toBe('attackmoving'); + expect(result.targetX).toBe(50); + expect(result.targetY).toBe(75); + expect(result.targetEntityId).toBeNull(); + }); + }); });