- Implemented 10 sub-systems (Economy, Pathfinding, Combat, Selection, Network, Map, Entity/Building/ControlPoint state machines, Orchestrator) - Refactored Custom_Entity.js → Unit.js with 5 components (health, owner, inventory, movement, combat) - Added Jest test suite with 100+ tests (EconomySystem 100%, EntityStateMachine 100%, PathfindingSystem 99%, Unit.js 72%) - All webpack builds pass (0 errors) - BMAD-auto team-respawn flow: 10 parallel sub-agents implemented systems Architecture: Phaser 3 + XState + socket.io + EasyStar Mode: team-respawn Model: custom/ollama-cloud-pro
170 lines
4.6 KiB
JavaScript
170 lines
4.6 KiB
JavaScript
/**
|
|
* CombatSystem Unit Tests
|
|
*/
|
|
import CombatSystem from '../src/systems/CombatSystem';
|
|
|
|
const createMockScene = () => ({
|
|
physics: {
|
|
add: {
|
|
group: jest.fn(() => ({
|
|
create: jest.fn(),
|
|
killAndHide: jest.fn()
|
|
}))
|
|
},
|
|
overlap: jest.fn()
|
|
},
|
|
events: {
|
|
emit: jest.fn()
|
|
},
|
|
add: {
|
|
sprite: jest.fn()
|
|
}
|
|
});
|
|
|
|
describe('CombatSystem', () => {
|
|
let scene;
|
|
let combat;
|
|
|
|
beforeEach(() => {
|
|
scene = createMockScene();
|
|
combat = new CombatSystem(scene);
|
|
});
|
|
|
|
describe('acquireTarget', () => {
|
|
it('should return null when no enemies in range', () => {
|
|
const entity = { x: 0, y: 0, getData: jest.fn(() => []) };
|
|
const target = combat.acquireTarget(entity, { maxRange: 200 });
|
|
|
|
expect(target).toBeNull();
|
|
});
|
|
|
|
it('should return closest enemy when multiple in range', () => {
|
|
const enemy1 = { x: 100, y: 0, isDead: jest.fn(() => false) };
|
|
const enemy2 = { x: 50, y: 0, isDead: jest.fn(() => false) };
|
|
|
|
combat.enemies = [enemy1, enemy2];
|
|
|
|
const entity = { x: 0, y: 0, getData: jest.fn(() => combat.enemies) };
|
|
const target = combat.acquireTarget(entity, { maxRange: 200, priority: 'closest' });
|
|
|
|
expect(target).toBe(enemy2); // Closer enemy
|
|
});
|
|
|
|
it('should filter out dead enemies', () => {
|
|
const deadEnemy = { x: 50, y: 0, isDead: jest.fn(() => true) };
|
|
const liveEnemy = { x: 100, y: 0, isDead: jest.fn(() => false) };
|
|
|
|
combat.enemies = [deadEnemy, liveEnemy];
|
|
|
|
const entity = { x: 0, y: 0, getData: jest.fn(() => combat.enemies) };
|
|
const target = combat.acquireTarget(entity, { maxRange: 200 });
|
|
|
|
expect(target).toBe(liveEnemy);
|
|
});
|
|
});
|
|
|
|
describe('canHit', () => {
|
|
let attacker, target;
|
|
|
|
beforeEach(() => {
|
|
attacker = {
|
|
x: 0,
|
|
y: 0,
|
|
getData: jest.fn(key => {
|
|
if (key === 'owner') return { playerId: 'player1' };
|
|
return null;
|
|
})
|
|
};
|
|
target = {
|
|
x: 100,
|
|
y: 0,
|
|
isDead: jest.fn(() => false),
|
|
getData: jest.fn(key => {
|
|
if (key === 'owner') return { playerId: 'player2' };
|
|
return null;
|
|
})
|
|
};
|
|
});
|
|
|
|
it('should return false for friendly fire', () => {
|
|
attacker.getData = jest.fn(() => ({ playerId: 'player1' }));
|
|
target.getData = jest.fn(() => ({ playerId: 'player1' }));
|
|
|
|
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.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
|
|
|
|
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 entity = {
|
|
getData: jest.fn(key => {
|
|
if (key === 'health') return { maxHp: 100, current: 100, armor: 5 };
|
|
return null;
|
|
}),
|
|
setData: jest.fn()
|
|
};
|
|
|
|
const damage = combat.applyDamage(entity, 20, 'rifle');
|
|
|
|
expect(damage).toBeLessThanOrEqual(15); // 20 - 5 armor
|
|
expect(entity.setData).toHaveBeenCalledWith('health', expect.any(Number));
|
|
});
|
|
|
|
it('should apply minimum 1 damage', () => {
|
|
const entity = {
|
|
getData: jest.fn(key => ({ maxHp: 100, current: 100, armor: 50 })),
|
|
setData: jest.fn()
|
|
};
|
|
|
|
const damage = combat.applyDamage(entity, 10, 'rifle');
|
|
|
|
expect(damage).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('should apply critical hit multiplier', () => {
|
|
const entity = {
|
|
getData: jest.fn(key => ({ maxHp: 100, current: 100, armor: 0 })),
|
|
setData: jest.fn()
|
|
};
|
|
|
|
// Mock crit roll to succeed
|
|
combat.damageModifiers = {
|
|
rifle: { critChance: 1.0, critMultiplier: 2.0 } // Always crit
|
|
};
|
|
|
|
const damage = combat.applyDamage(entity, 20, 'rifle');
|
|
|
|
expect(damage).toBe(40); // 20 * 2.0 crit multiplier
|
|
});
|
|
});
|
|
});
|