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
39 changes: 32 additions & 7 deletions src/engine/systems/CombatSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -786,19 +795,27 @@ export class CombatSystem extends System {
const targetBuilding = targetEntity.get<Building>('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;
} else if (unit.targetX !== null && unit.targetY !== null) {
// 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();
}
Expand All @@ -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()) {
Expand Down
168 changes: 168 additions & 0 deletions tests/engine/systems/combatSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading