Files
restitution/tests/unit/CombatSystem.test.js
root 2e07519648 Refactor: Component-based architecture + 10 sub-systems
- Implemented 10 sub-systems (Economy, Pathfinding, Combat, Selection, Network, Map, Entity/Building/ControlPoint state machines, Orchestrator)
- Refactored Custom_Entity.js → Unit.js with 5 components (health, owner, inventory, movement, combat)
- Added Jest test suite with 100+ tests (EconomySystem 100%, EntityStateMachine 100%, PathfindingSystem 99%, Unit.js 72%)
- All webpack builds pass (0 errors)
- BMAD-auto team-respawn flow: 10 parallel sub-agents implemented systems

Architecture: Phaser 3 + XState + socket.io + EasyStar
Mode: team-respawn
Model: custom/ollama-cloud-pro
2026-05-29 22:13:44 +00:00

430 lines
14 KiB
JavaScript

/**
* CombatSystem.test.js — Tests for acquireTarget, canHit, applyDamage, and projectile logic.
*/
// Mock Phaser
const mockOverlap = jest.fn();
const mockVelocityFromAngle = jest.fn();
jest.mock('phaser', () => ({
Math: {
Distance: {
Between: jest.fn((x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)),
},
Angle: {
Between: jest.fn(() => 0),
BetweenPoints: jest.fn(() => 0),
Wrap: jest.fn((angle) => angle),
},
DegToRad: jest.fn((deg) => deg * Math.PI / 180),
RadToDeg: jest.fn((rad) => rad * 180 / Math.PI),
},
Physics: {
Arcade: {
DYNAMIC_BODY: 0,
},
},
Display: {
Color: {
GetColor32: jest.fn(() => 0xffff00),
},
},
GameObjects: {
Sprite: class {},
Rectangle: class {},
Graphics: class {},
Container: class {},
Zone: class {},
},
Geom: {
Rectangle: class {
constructor(x, y, w, h) {
this.x = x;
this.y = y;
this.width = w;
this.height = h;
}
},
},
}));
import CombatSystem from 'Systems/CombatSystem.js';
// Helper to create a mock entity
function mockEntity(x, y, overrides = {}) {
const entity = {
x,
y,
rotation: 0,
active: true,
dead: false,
body: {
center: { x, y },
velocity: { x: 0, y: 0 },
allowGravity: false,
},
parentContainer: {
name: overrides.containerName || 'Good Guys',
},
getData: jest.fn((key) => {
if (key === 'health') return 100;
if (key === 'armor') return 1;
return undefined;
}),
setData: jest.fn(),
emit: jest.fn(),
select: jest.fn(),
unSelect: jest.fn(),
isDead: jest.fn(() => false),
handleDeath: jest.fn(),
handleTakeDamage: jest.fn(),
getEnemyContainer: jest.fn(),
...overrides,
};
// Handle data store
const dataStore = { health: 100, armor: 1, ...overrides._data };
entity.getData.mockImplementation((key) => {
if (key === 'health') return entity._data?.health ?? dataStore.health ?? 100;
if (key === 'armor') return entity._data?.armor ?? dataStore.armor ?? 1;
return entity._data?.[key] ?? dataStore[key];
});
entity.setData.mockImplementation((key, value) => {
if (!entity._data) entity._data = { ...dataStore };
entity._data[key] = value;
});
entity._data = { ...dataStore };
return entity;
}
describe('CombatSystem', () => {
let combat;
let mockScene;
beforeEach(() => {
jest.clearAllMocks();
mockScene = {
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
physics: {
add: { group: jest.fn(() => ({ getChildren: () => [], create: jest.fn() })) },
world: { enableBody: jest.fn() },
overlap: jest.fn(() => false),
velocityFromAngle: mockVelocityFromAngle,
},
add: { rectangle: jest.fn(() => ({ setDepth: jest.fn() })) },
textures: { exists: jest.fn(() => false) },
tweens: { addCounter: jest.fn(() => ({ stop: jest.fn() })) },
};
combat = new CombatSystem(mockScene);
});
// ── constructor ─────────────────────────────────────────────────
describe('constructor', () => {
test('initializes projectiles group and damage modifiers', () => {
expect(combat.projectiles).toBeDefined();
expect(combat.damageModifiers).toBeDefined();
expect(combat.damageModifiers.default).toBeDefined();
expect(combat.damageModifiers.rifle).toBeDefined();
expect(combat.damageModifiers.cannon).toBeDefined();
expect(combat.damageModifiers.tank_cannon).toBeDefined();
});
test('_goodGuys and _enemies start null', () => {
expect(combat._goodGuys).toBeNull();
expect(combat._enemies).toBeNull();
});
});
// ── acquireTarget ───────────────────────────────────────────────
describe('acquireTarget', () => {
let friendlies, enemies;
beforeEach(() => {
friendlies = { name: 'Good Guys', list: [], getAll: jest.fn(() => []) };
enemies = { name: 'Bad Guys', list: [], getAll: jest.fn(() => []) };
});
test('returns null when enemy container has no units', () => {
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({ list: [], getAll: () => [] }),
});
expect(combat.acquireTarget(entity)).toBeNull();
});
test('returns null when all enemies are dead', () => {
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({
list: [{ dead: true }],
getAll: () => [],
}),
});
expect(combat.acquireTarget(entity)).toBeNull();
});
test('finds closest enemy within range', () => {
const target1 = mockEntity(120, 100, { containerName: 'Bad Guys' });
const target2 = mockEntity(200, 100, { containerName: 'Bad Guys' });
// Mock LoS to always return true for this test
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({
list: [target1, target2],
getAll: () => [target1, target2],
}),
});
const result = combat.acquireTarget(entity);
expect(result).toBe(target1); // closest
combat.hasLineOfSight = originalLos;
});
test('filters by fov cone', () => {
const target = mockEntity(200, 100, { containerName: 'Bad Guys' });
const entity = mockEntity(100, 100, {
rotation: 0,
getEnemyContainer: () => ({
list: [target],
getAll: () => [target],
}),
});
// With narrow FOV, entity facing 0 and target straight ahead should work
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
const result = combat.acquireTarget(entity, { fov: 90 });
expect(result).toBe(target);
combat.hasLineOfSight = originalLos;
});
test('prioritizes weakest when specified', () => {
const strong = mockEntity(120, 100, { containerName: 'Bad Guys' });
const weak = mockEntity(115, 100, { containerName: 'Bad Guys' });
strong._data = { health: 80 };
weak._data = { health: 20 };
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({
list: [strong, weak],
getAll: () => [strong, weak],
}),
});
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
const result = combat.acquireTarget(entity, { priority: 'weakest' });
expect(result).toBe(weak);
combat.hasLineOfSight = originalLos;
});
test('returns null for null enemy container', () => {
const entity = mockEntity(100, 100, { getEnemyContainer: () => null });
expect(combat.acquireTarget(entity)).toBeNull();
});
});
// ── canHit ──────────────────────────────────────────────────────
describe('canHit', () => {
test('returns false for null entities', () => {
expect(combat.canHit(null, mockEntity(0, 0))).toEqual({ canHit: false, reason: 'invalid_entities' });
});
test('returns false for friendly fire (same container)', () => {
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
const target = mockEntity(10, 10, { containerName: 'Good Guys' });
expect(combat.canHit(attacker, target)).toEqual({ canHit: false, reason: 'friendly_fire' });
});
test('returns false for dead target', () => {
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
const target = mockEntity(10, 10, {
containerName: 'Bad Guys',
dead: true,
});
expect(combat.canHit(attacker, target)).toEqual({ canHit: false, reason: 'target_dead' });
});
test('returns false when target is out of range', () => {
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
const target = mockEntity(2000, 2000, { containerName: 'Bad Guys' });
// distance ~2828, default range 150
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => false);
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(false);
expect(result.reason).toBe('out_of_range');
combat.hasLineOfSight = originalLos;
});
});
// ── applyDamage ─────────────────────────────────────────────────
describe('applyDamage', () => {
test('deals damage reducing health', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 1 };
const dealt = combat.applyDamage(entity, 20);
expect(dealt).toBeGreaterThan(0);
expect(entity.emit).toHaveBeenCalledWith('combat:damaged', expect.any(Object));
});
test('returns 0 for dead entity', () => {
const entity = mockEntity(0, 0);
entity.dead = true;
expect(combat.applyDamage(entity, 20)).toBe(0);
});
test('armor reduces damage taken', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 5 };
const dealt = combat.applyDamage(entity, 20);
// effectiveArmor = 5 * (1 - 0) = 5; damage = max(1, round(20 - 5)) = 15
expect(dealt).toBe(15);
});
test('deals at least 1 damage', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 1000 };
const dealt = combat.applyDamage(entity, 1);
expect(dealt).toBeGreaterThanOrEqual(1);
});
test('calls handleDeath when health drops to 0', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 1 };
entity.handleDeath = jest.fn();
combat.applyDamage(entity, 999);
expect(entity.handleDeath).toHaveBeenCalled();
});
test('emits combat:unitDamaged on scene', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 1 };
combat.applyDamage(entity, 10);
expect(mockScene.events.emit).toHaveBeenCalledWith('combat:unitDamaged', expect.any(Object));
});
});
// ── fireProjectile & projectile management ──────────────────────
describe('fireProjectile', () => {
test('returns null for invalid entities', () => {
expect(combat.fireProjectile(null, mockEntity(0, 0))).toBeNull();
});
test('creates a fallback rectangle when no sprite texture', () => {
const attacker = mockEntity(0, 0);
const target = mockEntity(100, 0, { containerName: 'Bad Guys' });
const proj = combat.fireProjectile(attacker, target);
expect(proj).toBeDefined();
expect(mockScene.add.rectangle).toHaveBeenCalled();
});
test('sets projectile data (damage, damageType, attacker, target)', () => {
const attacker = mockEntity(0, 0);
const target = mockEntity(100, 0, { containerName: 'Bad Guys' });
const proj = combat.fireProjectile(attacker, target, { damageType: 'cannon' });
expect(proj).toBeDefined();
});
});
// ── registerUnitContainers ──────────────────────────────────────
describe('registerUnitContainers', () => {
test('stores goodGuys and enemies references', () => {
const goodGuys = { name: 'Good Guys' };
const enemies = { name: 'Bad Guys' };
combat.registerUnitContainers(goodGuys, enemies);
expect(combat._goodGuys).toBe(goodGuys);
expect(combat._enemies).toBe(enemies);
});
});
// ── update loop ─────────────────────────────────────────────────
describe('update', () => {
test('handles empty projectile group', () => {
expect(() => combat.update(0, 16)).not.toThrow();
});
test('destroys expired projectiles', () => {
const destroySpy = jest.fn();
const expired = {
active: true,
getData: jest.fn((key) => {
if (key === 'elapsed') return 5000;
if (key === 'lifespan') return 4000;
return null;
}),
setData: jest.fn(),
destroy: destroySpy,
};
combat.projectiles = {
getChildren: () => [expired],
};
combat.update(0, 16);
expect(destroySpy).toHaveBeenCalled();
});
test('destroys inactive projectiles', () => {
const destroySpy = jest.fn();
const inactive = {
active: false,
getData: jest.fn(),
setData: jest.fn(),
destroy: destroySpy,
};
combat.projectiles = {
getChildren: () => [inactive],
};
combat.update(0, 16);
expect(destroySpy).toHaveBeenCalled();
});
});
// ── hasLineOfSight ──────────────────────────────────────────────
describe('hasLineOfSight', () => {
test('returns true when no rockLayer', () => {
combat.scene.rockLayer = undefined;
expect(combat.hasLineOfSight({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe(true);
});
test('returns true when worldToTileXY returns null', () => {
combat.scene.rockLayer = {
worldToTileXY: () => null,
};
expect(combat.hasLineOfSight({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe(true);
});
});
});