- 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)
682 lines
23 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|