Files
restitution/tests/unit/CombatSystem.test.js
kaykayyali 8fc45968b5 M2.3 + M2.4 + TeamManager integration: projectile sprites, death handling, build menu, production panel, building placer
- ProjectileSprite.js: physics arcade sprite, faction tint, off-screen culling
- CombatSystem: refactored enemy selection to use TeamManager instead of legacy containers
- Death handling: DYING alpha tween (500ms), smoke puff (300ms), unit:killed event, cleanup
- TeamManager: centralized team registry replacing goodGuys/badGuys containers
- HealthBarSystem, ResourceBar, CaptureProgressUI, BuildMenu, BuildingPlacer, BuildingRenderer, ProductionPanel
- Map_Player: wired new subsystems, removed legacy container creation
- Tests: ProjectileSprite (4), DeathHandling (13), CombatSystem updated

47 tests passed at dev time (M2.3), 158/158 at dev time (M2.4)
2026-06-01 05:18:33 +00:00

682 lines
23 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),
},
Vector2: class {
constructor(x, y) { this.x = x; this.y = y; }
},
DegToRad: jest.fn((deg) => deg * Math.PI / 180),
RadToDeg: jest.fn((rad) => rad * 180 / Math.PI),
},
Physics: {
Arcade: {
DYNAMIC_BODY: 0,
Sprite: class MockArcadeSprite {
constructor(scene, x, y, texture) {
this.scene = scene;
this.x = x;
this.y = y;
this.texture = { key: texture };
this.active = true;
this.body = {
velocity: { x: 0, y: 0 },
allowGravity: false,
setSize: jest.fn(),
setOffset: jest.fn(),
setVelocity: jest.fn(),
};
this._data = {};
this.setData = jest.fn((k, v) => { this._data[k] = v; });
this.getData = jest.fn((k) => this._data[k] ?? null);
this.setRotation = jest.fn();
this.setDepth = jest.fn();
this.setTint = jest.fn();
this.clearTint = jest.fn();
this.destroy = jest.fn();
}
},
},
},
Display: {
Color: {
GetColor32: jest.fn(() => 0xffff00),
},
},
GameObjects: {
Sprite: class {},
Rectangle: class {
constructor(scene, x, y, w, h, color) {
this.x = x; this.y = y; this.width = w; this.height = h; this.fillColor = color;
this.active = true;
this.body = { velocity: { x: 0, y: 0 }, allowGravity: false };
this._data = {};
this.setData = jest.fn((k, v) => { this._data[k] = v; });
this.getData = jest.fn((k) => this._data[k] ?? null);
this.setDepth = jest.fn();
this.setRotation = jest.fn();
this.destroy = jest.fn();
}
},
Graphics: class {},
Container: class {
constructor() { this.list = []; this._data = {}; }
add(item) { this.list.push(item); }
getAll() { return this.list; }
setName() { return this; }
},
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;
let mockTeamManager;
beforeEach(() => {
jest.clearAllMocks();
mockScene = {
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
physics: {
add: { group: jest.fn(() => ({ getChildren: () => [], create: jest.fn(), add: jest.fn().mockImplementation((sprite) => sprite) })) },
world: { enableBody: jest.fn() },
overlap: jest.fn(() => false),
velocityFromAngle: mockVelocityFromAngle,
},
add: {
existing: jest.fn(),
rectangle: jest.fn(() => {
const proj = {
setDepth: jest.fn(),
setData: jest.fn(),
getData: jest.fn(),
setRotation: jest.fn(),
body: { velocity: { x: 0, y: 0 }, allowGravity: true },
};
return proj;
}),
},
textures: { exists: jest.fn(() => false) },
tweens: { addCounter: jest.fn(() => ({ stop: jest.fn() })) },
};
mockTeamManager = {
getEntityTeam: jest.fn(() => 'team-a'),
getAllUnitsGrouped: jest.fn(() => new Map()),
isEnemy: jest.fn(() => false),
getTeams: jest.fn(() => [])
};
combat = new CombatSystem(mockScene, mockTeamManager);
});
// ── 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('teamManager is stored', () => {
expect(combat.teamManager).toBe(mockTeamManager);
});
});
// ── 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('uses per-entity weaponRange from components.combat', () => {
const close = mockEntity(140, 100, { containerName: 'Bad Guys' });
const far = mockEntity(300, 100, { containerName: 'Bad Guys' });
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
mockTeamManager.getEntityTeam.mockImplementation((e) => e.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good');
mockTeamManager.isEnemy.mockImplementation((a, b) => {
const ta = a.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
const tb = b.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
return ta !== tb;
});
// entity with custom range 150: should acquire close target at distance ~40,
// but NOT far target at distance ~200
const entity = mockEntity(100, 100, {
components: { combat: { weaponRange: 150 } },
getEnemyContainer: () => ({
list: [close, far],
getAll: () => [close, far],
}),
});
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map([['bad', new Set([close, far])], ['good', new Set([entity])]]));
const result = combat.acquireTarget(entity);
expect(result).toBe(close);
// entity with custom range 20: neither target is in range
const entityShort = mockEntity(100, 100, {
components: { combat: { weaponRange: 20 } },
getEnemyContainer: () => ({
list: [close, far],
getAll: () => [close, far],
}),
});
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map([['bad', new Set([close, far])], ['good', new Set([entityShort])]]));
expect(combat.acquireTarget(entityShort)).toBeNull();
combat.hasLineOfSight = originalLos;
});
test('falls back to components.combat.range when weaponRange absent', () => {
const target = mockEntity(120, 100, { containerName: 'Bad Guys' });
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
mockTeamManager.getEntityTeam.mockImplementation((e) => e.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good');
mockTeamManager.isEnemy.mockImplementation((a, b) => {
const ta = a.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
const tb = b.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
return ta !== tb;
});
const entity = mockEntity(100, 100, {
components: { combat: { range: 200 } },
getEnemyContainer: () => ({
list: [target],
getAll: () => [target],
}),
});
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map([['bad', new Set([target])], ['good', new Set([entity])]]));
const result = combat.acquireTarget(entity);
expect(result).toBe(target);
combat.hasLineOfSight = originalLos;
});
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);
mockTeamManager.getEntityTeam.mockImplementation((e) => e.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good');
mockTeamManager.isEnemy.mockImplementation((a, b) => {
const ta = a.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
const tb = b.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
return ta !== tb;
});
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({
list: [target1, target2],
getAll: () => [target1, target2],
}),
});
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map([['bad', new Set([target1, target2])], ['good', new Set([entity])]]));
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);
mockTeamManager.getEntityTeam.mockImplementation((e) => e.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good');
mockTeamManager.isEnemy.mockImplementation((a, b) => {
const ta = a.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
const tb = b.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
return ta !== tb;
});
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map([['bad', new Set([target])], ['good', new Set([entity])]]));
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);
mockTeamManager.getEntityTeam.mockImplementation((e) => e.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good');
mockTeamManager.isEnemy.mockImplementation((a, b) => {
const ta = a.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
const tb = b.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
return ta !== tb;
});
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map([['bad', new Set([strong, weak])], ['good', new Set([entity])]]));
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,
});
// attacker and target are on different containers, but mockTeamManager.isEnemy returns false
// so canHit returns friendly_fire. Override for this test.
mockTeamManager.isEnemy.mockReturnValue(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
mockTeamManager.isEnemy.mockReturnValue(true);
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 };
// Suppress random crit so result is deterministic
const origRandom = Math.random;
Math.random = jest.fn(() => 1);
const dealt = combat.applyDamage(entity, 20);
// effectiveArmor = 5 * (1 - 0) = 5; damage = max(1, round(20 - 5)) = 15
expect(dealt).toBe(15);
Math.random = origRandom;
});
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 ProjectileSprite and stashes data', () => {
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.existing).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();
});
});
// ── 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();
});
test('auto-engage finds target and fires projectile', () => {
const enemy = mockEntity(120, 100, { containerName: 'Bad Guys' });
const attacker = mockEntity(100, 100, {
components: {
combat: {
canFire: () => true,
damage: 10,
damageType: 'rifle',
fireRate: 1000,
recordFire: jest.fn(),
},
},
getEnemyContainer: () => ({
list: [enemy],
getAll: () => [enemy],
}),
});
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
mockTeamManager.getEntityTeam.mockImplementation((e) => e.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good');
mockTeamManager.isEnemy.mockImplementation((a, b) => {
const ta = a.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
const tb = b.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
return ta !== tb;
});
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map([['bad', new Set([enemy])], ['good', new Set([attacker])]]));
// Mock fireProjectile so we can spy on it
const fireSpy = jest.fn();
combat.fireProjectile = fireSpy;
combat.update(1000, 16);
expect(fireSpy).toHaveBeenCalledWith(
attacker,
enemy,
expect.objectContaining({ damage: 10, damageType: 'rifle' }),
);
expect(attacker.components.combat.recordFire).toHaveBeenCalledWith(1000);
combat.hasLineOfSight = originalLos;
});
test('auto-engage no-op when container is empty', () => {
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map());
const fireSpy = jest.fn();
combat.fireProjectile = fireSpy;
combat.update(1000, 16);
expect(fireSpy).not.toHaveBeenCalled();
});
test('auto-engage respects per-entity range config', () => {
const farEnemy = mockEntity(300, 100, { containerName: 'Bad Guys' });
const attacker = mockEntity(100, 100, {
components: {
combat: {
weaponRange: 150,
canFire: () => true,
damage: 10,
damageType: 'rifle',
recordFire: jest.fn(),
},
},
getEnemyContainer: () => ({
list: [farEnemy],
getAll: () => [farEnemy],
}),
});
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
mockTeamManager.getEntityTeam.mockImplementation((e) => e.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good');
mockTeamManager.isEnemy.mockImplementation((a, b) => {
const ta = a.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
const tb = b.parentContainer?.name === 'Bad Guys' ? 'bad' : 'good';
return ta !== tb;
});
mockTeamManager.getAllUnitsGrouped.mockReturnValue(new Map([['bad', new Set([farEnemy])], ['good', new Set([attacker])]]));
const fireSpy = jest.fn();
combat.fireProjectile = fireSpy;
combat.update(1000, 16);
// farEnemy at ~200px should be out of 150px range
expect(fireSpy).not.toHaveBeenCalled();
combat.hasLineOfSight = originalLos;
});
});
// ── 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);
});
});
});