Files
restitution/tests/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

339 lines
10 KiB
JavaScript

/**
* CombatSystem Unit Tests
*/
// Mock Phaser
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 = texture;
this.active = true;
this.visible = 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.setTint = jest.fn();
this.clearTint = jest.fn();
this.setRotation = jest.fn();
this.setDepth = jest.fn();
this.destroy = jest.fn();
this.emit = jest.fn();
}
preUpdate() {}
},
},
},
Display: {
Color: {
GetColor32: jest.fn(() => 0xffff00),
},
},
GameObjects: {
Sprite: class {},
Rectangle: class {},
Graphics: class {},
Container: class {},
Zone: class {},
},
}));
import CombatSystem from '../src/systems/CombatSystem';
const createMockScene = () => ({
physics: {
add: {
group: jest.fn(() => ({
create: jest.fn(),
killAndHide: jest.fn(),
add: jest.fn().mockImplementation((sprite) => sprite),
getChildren: jest.fn(() => []),
}))
},
overlap: jest.fn(),
world: { enableBody: jest.fn() },
velocityFromAngle: jest.fn(),
},
events: {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
add: {
existing: jest.fn(),
sprite: jest.fn(),
rectangle: jest.fn(() => ({
setDepth: jest.fn(),
setData: jest.fn(),
getData: jest.fn(),
setRotation: jest.fn(),
body: { velocity: { x: 0, y: 0 }, allowGravity: true, setVelocity: jest.fn() },
})),
},
textures: { exists: jest.fn(() => false) },
tweens: { addCounter: jest.fn(() => ({ stop: jest.fn() })) },
});
/**
* Helper to build a minimal entity that passes all CombatSystem guards.
*/
function entity(opts = {}) {
const team = opts.team ?? 'team-a';
const e = {
x: opts.x ?? 0,
y: opts.y ?? 0,
dead: opts.dead ?? false,
rotation: opts.rotation ?? 0,
getData: jest.fn((key) => {
if (key === 'health') return opts.health ?? 100;
if (key === 'armor') return opts.armor ?? 1;
if (key === 'teamId') return team;
return undefined;
}),
setData: jest.fn(),
emit: jest.fn(),
isDead: jest.fn(() => opts.dead ?? false),
body: { center: { x: opts.x ?? 0, y: opts.y ?? 0 } },
parentContainer: { name: team },
getEnemyContainer: jest.fn(() => ({
list: opts.enemies ?? [],
getAll: jest.fn(() => (opts.enemies ?? []).filter(e => !e.dead)),
})),
};
return e;
}
describe('CombatSystem', () => {
let scene;
let combat;
let mockTeamManager;
beforeEach(() => {
scene = createMockScene();
mockTeamManager = {
getEntityTeam: jest.fn((e) => e.getData?.('teamId') ?? null),
getAllUnitsGrouped: jest.fn(() => new Map()),
isEnemy: jest.fn((a, b) => {
const ta = a.getData?.('teamId') ?? a.parentContainer?.name;
const tb = b.getData?.('teamId') ?? b.parentContainer?.name;
return ta !== tb;
}),
getTeams: jest.fn(() => []),
};
combat = new CombatSystem(scene, mockTeamManager);
});
describe('acquireTarget', () => {
it('should return null when no enemies in range', () => {
const e = entity({ x: 0, y: 0, enemies: [] });
const target = combat.acquireTarget(e, { maxRange: 200 });
expect(target).toBeNull();
});
it('should return closest enemy when multiple in range', () => {
const enemy1 = entity({ x: 100, y: 0, team: 'bad-guys' });
const enemy2 = entity({ x: 50, y: 0, team: 'bad-guys' });
const e = entity({ x: 0, y: 0, team: 'good-guys', enemies: [enemy1, enemy2] });
// Populate TeamManager
mockTeamManager.getAllUnitsGrouped.mockReturnValue(
new Map([
['good-guys', new Set([e])],
['bad-guys', new Set([enemy1, enemy2])],
])
);
// Override hasLineOfSight to always pass
combat.hasLineOfSight = jest.fn(() => true);
const target = combat.acquireTarget(e, { maxRange: 200, priority: 'closest' });
expect(target).toBe(enemy2);
});
it('should filter out dead enemies', () => {
const deadEnemy = entity({ x: 50, y: 0, team: 'bad-guys', dead: true });
const liveEnemy = entity({ x: 100, y: 0, team: 'bad-guys' });
const e = entity({ x: 0, y: 0, team: 'good-guys', enemies: [deadEnemy, liveEnemy] });
mockTeamManager.getAllUnitsGrouped.mockReturnValue(
new Map([
['good-guys', new Set([e])],
['bad-guys', new Set([deadEnemy, liveEnemy])],
])
);
combat.hasLineOfSight = jest.fn(() => true);
const target = combat.acquireTarget(e, { maxRange: 200 });
expect(target).toBe(liveEnemy);
});
});
describe('canHit', () => {
let attacker, target;
beforeEach(() => {
attacker = entity({ x: 0, y: 0, team: 'good-guys' });
target = entity({ x: 100, y: 0, team: 'bad-guys' });
});
it('should return false for friendly fire', () => {
target.parentContainer.name = 'good-guys';
target.getData.mockImplementation((key) => {
if (key === 'health') return 100;
if (key === 'armor') return 1;
if (key === 'teamId') return 'good-guys';
return undefined;
});
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(false);
expect(result.reason).toBe('friendly_fire');
});
it('should return false for dead target', () => {
target.dead = true;
target.isDead = jest.fn(() => true);
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(false);
expect(result.reason).toBe('target_dead');
});
it('should return false when out of range', () => {
target.x = 500; // Beyond default 200 range
combat.hasLineOfSight = jest.fn(() => false);
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(false);
expect(result.reason).toBe('out_of_range');
});
it('should return true when all conditions met', () => {
combat.hasLineOfSight = jest.fn(() => true);
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(true);
});
});
describe('applyDamage', () => {
it('should apply damage with armor reduction', () => {
const e = entity({ health: 100, armor: 5 });
// Override getData for health to return expected structure
e.getData = jest.fn((key) => {
if (key === 'health') return 100;
if (key === 'armor') return 5;
return undefined;
});
const damage = combat.applyDamage(e, 20, 'rifle');
expect(damage).toBeLessThanOrEqual(16); // 20 - (5 * 0.9) = 15.5 → round to 16 (no crit)
expect(e.setData).toHaveBeenCalledWith('health', expect.any(Number));
});
it('should apply minimum 1 damage', () => {
const e = entity({ health: 100, armor: 50 });
e.getData = jest.fn((key) => {
if (key === 'health') return 100;
if (key === 'armor') return 50;
return undefined;
});
const damage = combat.applyDamage(e, 10, 'rifle');
expect(damage).toBeGreaterThanOrEqual(1);
});
it('should apply critical hit multiplier', () => {
const e = entity({ health: 100, armor: 0 });
e.getData = jest.fn((key) => {
if (key === 'health') return 100;
if (key === 'armor') return 0;
return undefined;
});
// Mock crit roll to succeed
combat.damageModifiers = {
rifle: { armorPiercing: 0, critChance: 1.0, critMultiplier: 2.0 },
};
const damage = combat.applyDamage(e, 20, 'rifle');
expect(damage).toBe(40); // 20 * 2.0
});
});
describe('fireProjectile', () => {
it('creates a ProjectileSprite and adds it to the group', () => {
const attacker = entity({ x: 0, y: 0, team: 'good-guys' });
const target = entity({ x: 100, y: 0, team: 'bad-guys' });
combat.hasLineOfSight = jest.fn(() => true);
const p = combat.fireProjectile(attacker, target);
expect(p).not.toBeNull();
expect(p.texture).toBe('__WHITE');
expect(p.setTint).toHaveBeenCalledWith(0x0000ff); // blue for good guys
});
it('tints enemy projectiles red', () => {
const attacker = entity({ x: 0, y: 0, team: 'bad-guys' });
const target = entity({ x: 100, y: 0, team: 'good-guys' });
combat.hasLineOfSight = jest.fn(() => true);
const p = combat.fireProjectile(attacker, target);
expect(p.setTint).toHaveBeenCalledWith(0xff0000);
});
it('emits combat:projectileHit on _onHit', () => {
const attacker = entity({ x: 0, y: 0, team: 'good-guys' });
const target = entity({ x: 100, y: 0, team: 'bad-guys', health: 100, armor: 0 });
target.getData = jest.fn((key) => (key === 'health' ? 100 : (key === 'armor' ? 0 : undefined)));
combat.damageModifiers = {
rifle: { armorPiercing: 0, critChance: 0, critMultiplier: 1.5 },
};
const p = combat.fireProjectile(attacker, target);
combat._onHit(p, target);
expect(target.setData).toHaveBeenCalledWith('health', expect.any(Number));
expect(scene.events.emit).toHaveBeenCalledWith(
'combat:projectileHit',
expect.objectContaining({ attacker, target }),
);
expect(p.destroy).toHaveBeenCalled();
});
});
});