- 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)
350 lines
11 KiB
JavaScript
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();
|
|
});
|
|
});
|