Files
restitution/test/systems/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

350 lines
11 KiB
JavaScript

/**
* CombatSystem.test.js — Multi-team CombatSystem tests.
* Tests use Jest globals (jest, describe, test, expect, beforeEach).
* File: test/systems/CombatSystem.test.js
*/
// ------------------------------------------------------------------
// Phaser mock (scoped to this test file)
// ------------------------------------------------------------------
jest.mock('phaser', () => ({
Math: {
Distance: {
Between: jest.fn((x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)),
BetweenPoints: jest.fn((a, b) => Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)),
},
Angle: {
Between: jest.fn(() => 0),
BetweenPoints: jest.fn(() => 0),
Wrap: jest.fn((angle) => angle),
},
Vector2: class MockVector2 {
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.setTint = jest.fn();
this.clearTint = jest.fn();
this.setDepth = jest.fn();
this.setRotation = jest.fn();
this.destroy = jest.fn();
this.emit = 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, 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.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;
}
},
},
}));
// ------------------------------------------------------------------
// Imports
// ------------------------------------------------------------------
import CombatSystem from 'Systems/CombatSystem';
import TeamManager from 'Systems/TeamManager';
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function createMockScene() {
return {
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
physics: {
add: {
group: jest.fn(() => ({
create: jest.fn(),
killAndHide: jest.fn(),
add: jest.fn().mockImplementation((sprite) => sprite),
getChildren: jest.fn(() => []),
})),
},
overlap: jest.fn(() => false),
world: { enableBody: jest.fn() },
velocityFromAngle: 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() },
destroy: jest.fn(),
})),
},
textures: { exists: jest.fn(() => false) },
tweens: { addCounter: jest.fn(() => ({ stop: jest.fn() })) },
cameras: { main: { worldView: { x: 0, y: 0, width: 800, height: 600 } } },
};
}
// Minimal mock entity (not a full Phaser sprite) — used for units.
function createMockUnit(x, y, teamId = null, overrides = {}) {
const data = {};
const unit = {
x,
y,
rotation: 0,
active: true,
dead: false,
body: {
center: { x, y },
velocity: { x: 0, y: 0 },
allowGravity: false,
},
getData: jest.fn((key) => data[key] ?? null),
setData: jest.fn((key, value) => { data[key] = value; }),
emit: jest.fn(),
isDead: jest.fn(() => false),
handleDeath: jest.fn(),
handleTakeDamage: jest.fn(),
components: {
combat: {
canFire: () => true,
damage: 10,
damageType: 'rifle',
weaponRange: 200,
fireRate: 1000,
recordFire: jest.fn(),
},
},
...overrides,
};
if (teamId !== null) unit.setData('teamId', teamId);
return unit;
}
// ------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------
describe('CombatSystem (multi-team)', () => {
let scene;
let teamManager;
let combat;
beforeEach(() => {
jest.clearAllMocks();
scene = createMockScene();
teamManager = new TeamManager(scene);
teamManager.createTeam('team-A', 0x1d7196, 'Alpha');
teamManager.createTeam('team-B', 0xd94f4f, 'Bravo');
teamManager.createTeam('team-C', 0x2ecc71, 'Charlie');
combat = new CombatSystem(scene, teamManager);
// Stub LoS so range checks succeed
combat.hasLineOfSight = jest.fn(() => true);
});
afterEach(() => {
// Clean up refs
combat = null;
teamManager = null;
});
// ── Test 1: Constructor accepts TeamManager, not containers ────
test('constructor accepts TeamManager, not containers', () => {
expect(combat.teamManager).toBe(teamManager);
expect(combat.scene).toBe(scene);
expect(combat.projectiles).toBeDefined();
expect(combat.damageModifiers).toBeDefined();
// Old fields must be absent
expect(combat._goodGuys).toBeUndefined();
expect(combat._enemies).toBeUndefined();
});
// ── Test 2: registerUnitContainers removed from public API ────────
test('registerUnitContainers removed from public API', () => {
expect(typeof combat.registerUnitContainers).toBe('undefined');
});
// ── Test 3: _processCombatGroup iterates all team groups ────────
test('_processCombatGroup iterates all team groups', () => {
const uA = createMockUnit(100, 100, 'team-A');
const uB = createMockUnit(200, 100, 'team-B');
const uC = createMockUnit(300, 100, 'team-C');
teamManager.addUnit(uA, 'team-A');
teamManager.addUnit(uB, 'team-B');
teamManager.addUnit(uC, 'team-C');
const fireSpy = jest.fn();
combat.fireProjectile = fireSpy;
combat.update(1000, 16);
const calls = fireSpy.mock.calls;
expect(calls.length).toBeGreaterThan(0);
});
// ── Test 4: _checkOverlap checks all teams ──────────────────────
test('_checkOverlap checks all teams', () => {
const uA = createMockUnit(0, 0, 'team-A');
const uB = createMockUnit(0, 0, 'team-B');
const uC = createMockUnit(0, 0, 'team-C');
teamManager.addUnit(uA, 'team-A');
teamManager.addUnit(uB, 'team-B');
teamManager.addUnit(uC, 'team-C');
// Projectile fired by uA
const proj = combat.fireProjectile(uA, uB);
expect(proj).toBeDefined();
// Cause overlap against uB
scene.physics.overlap.mockImplementation((p, unit) => unit === uB || unit === uC);
const onHitSpy = jest.spyOn(combat, '_onHit');
combat._checkOverlap(proj);
// Should have hit either uB or uC (enemies of A)
expect(onHitSpy).toHaveBeenCalled();
onHitSpy.mockRestore();
});
// ── Test 5: acquireTarget returns enemy unit from any team ────────
test('acquireTarget returns enemy unit from any team', () => {
const uA = createMockUnit(100, 100, 'team-A');
const uB = createMockUnit(150, 100, 'team-B');
const uC = createMockUnit(200, 100, 'team-C');
teamManager.addUnit(uA, 'team-A');
teamManager.addUnit(uB, 'team-B');
teamManager.addUnit(uC, 'team-C');
// uA looking for enemies → should find uB (closest)
const target = combat.acquireTarget(uA, { maxRange: 300 });
expect(target).not.toBeNull();
// It should be either uB or uC, but NOT uA
expect(target).not.toBe(uA);
// Verify it is an enemy (different team)
expect(teamManager.isEnemy(uA, target)).toBe(true);
});
// ── Test 6: Friendly fire prevented by team check ─────────────────
test('friendly fire prevented by team check in canHit', () => {
const uA1 = createMockUnit(0, 0, 'team-A');
const uA2 = createMockUnit(10, 0, 'team-A');
const uB = createMockUnit(100, 0, 'team-B');
teamManager.addUnit(uA1, 'team-A');
teamManager.addUnit(uA2, 'team-A');
teamManager.addUnit(uB, 'team-B');
const hitSame = combat.canHit(uA1, uA2, 200);
expect(hitSame.canHit).toBe(false);
expect(hitSame.reason).toBe('friendly_fire');
const hitEnemy = combat.canHit(uA1, uB, 200);
// Should pass (we stubbed LoS, but range should also pass)
combat.hasLineOfSight = jest.fn(() => true);
const hitEnemy2 = combat.canHit(uA1, uB, 200);
expect(hitEnemy2.canHit).toBe(true);
});
// ── Test 7: Projectile from team-A hits team-B and team-C units ───
test('projectile from team-A hits team-B and team-C units', () => {
const uA = createMockUnit(0, 0, 'team-A');
const uB = createMockUnit(0, 0, 'team-B');
const uC = createMockUnit(0, 0, 'team-C');
teamManager.addUnit(uA, 'team-A');
teamManager.addUnit(uB, 'team-B');
teamManager.addUnit(uC, 'team-C');
const proj = combat.fireProjectile(uA, uB);
// Overlap with B first
scene.physics.overlap.mockImplementation((p, unit) => unit === uB);
const onHitSpy = jest.spyOn(combat, '_onHit');
combat._checkOverlap(proj);
expect(onHitSpy).toHaveBeenCalledWith(proj, uB);
onHitSpy.mockRestore();
// Overlap with C
const proj2 = combat.fireProjectile(uA, uC);
scene.physics.overlap.mockImplementation((p, unit) => unit === uC);
const onHitSpy2 = jest.spyOn(combat, '_onHit');
combat._checkOverlap(proj2);
expect(onHitSpy2).toHaveBeenCalledWith(proj2, uC);
onHitSpy2.mockRestore();
});
// ── Test 8: Projectile from team-A does NOT hit team-A units ─────
test('projectile from team-A does NOT hit team-A units', () => {
const uA1 = createMockUnit(0, 0, 'team-A');
const uA2 = createMockUnit(0, 0, 'team-A');
teamManager.addUnit(uA1, 'team-A');
teamManager.addUnit(uA2, 'team-A');
const proj = combat.fireProjectile(uA1, uA2);
// Overlap would trigger, but friendly check should skip
scene.physics.overlap.mockImplementation(() => true);
const onHitSpy = jest.spyOn(combat, '_onHit');
combat._checkOverlap(proj);
// Should not process a hit because uA2 is same team as attacker
const friendlyFireCall = onHitSpy.mock.calls.find((call) => call[1] === uA2);
expect(friendlyFireCall).toBeUndefined();
onHitSpy.mockRestore();
});
});