- Replace socket.io relay with Colyseus 0.15 authoritative server - GameRoom with GameState schema (players, units, resources) - Pure TS services: CombatResolver, EconomyService, PathfindingService, UnitManager - POST /api/create-room → 4-char invite code - React/MUI LobbyScreen: Create (shows code + START GAME) / Join by code - ColyseusClient: joinOrCreate/join by room type = invite code - Nginx: static assets direct, all else proxied to Colyseus (WS upgrade) - Content-hashed JS bundles for Cloudflare cache-busting - 1-player lobbies: START GAME button bypasses 2-player wait
301 lines
8.3 KiB
JavaScript
301 lines
8.3 KiB
JavaScript
/**
|
|
* Unit Entity Unit Tests
|
|
*/
|
|
jest.mock('Systems/EntityStateMachine', () => ({
|
|
forEntity: jest.fn(() => ({
|
|
tick: jest.fn(),
|
|
send: jest.fn(),
|
|
destroy: jest.fn(),
|
|
getState: jest.fn(() => 'IDLING')
|
|
}))
|
|
}));
|
|
|
|
import Unit from '../src/entities/Unit';
|
|
import EntityStateMachine from 'Systems/EntityStateMachine';
|
|
|
|
const createMockScene = () => ({
|
|
add: {
|
|
existing: jest.fn()
|
|
},
|
|
physics: {
|
|
world: {
|
|
enableBody: jest.fn()
|
|
}
|
|
},
|
|
interface: {
|
|
generateWorldXY: jest.fn(tile => ({ x: tile.x * 64, y: tile.y * 64 }))
|
|
},
|
|
orchestrator: {
|
|
systems: {
|
|
EntityStateMachine,
|
|
combat: { fireProjectile: jest.fn() },
|
|
pathfinding: { findPath: jest.fn() },
|
|
selection: { add: jest.fn() }
|
|
}
|
|
},
|
|
events: {
|
|
emit: jest.fn()
|
|
},
|
|
tweens: {
|
|
// Fire onUpdate immediately so selection tests see setTint called
|
|
addCounter: jest.fn(config => {
|
|
const tween = { getValue: () => 200, stop: jest.fn() };
|
|
if (config.onUpdate) config.onUpdate(tween);
|
|
return tween;
|
|
})
|
|
}
|
|
});
|
|
|
|
describe('Unit', () => {
|
|
let scene;
|
|
let unit;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
scene = createMockScene();
|
|
// Start unit at tile (0,0) = world (0,0) so distance calculations are simple
|
|
unit = new Unit(scene, 'tank_texture', { x: 0, y: 0 }, {
|
|
maxHp: 100,
|
|
armor: 5,
|
|
playerId: 'player1',
|
|
team: 'good',
|
|
weaponRange: 200,
|
|
damage: 25
|
|
});
|
|
});
|
|
|
|
describe('Component Access', () => {
|
|
it('should have health component', () => {
|
|
const health = unit.getComponent('health');
|
|
|
|
expect(health.maxHp).toBe(100);
|
|
expect(health.current).toBe(100);
|
|
expect(health.armor).toBe(5);
|
|
});
|
|
|
|
it('should have owner component', () => {
|
|
const owner = unit.getComponent('owner');
|
|
|
|
expect(owner.playerId).toBe('player1');
|
|
expect(owner.team).toBe('good');
|
|
});
|
|
|
|
it('should have combat component', () => {
|
|
const combat = unit.getComponent('combat');
|
|
|
|
expect(combat.weaponRange).toBe(200);
|
|
expect(combat.damage).toBe(25);
|
|
});
|
|
|
|
it('should update component with setComponent', () => {
|
|
unit.setComponent('health', { current: 50 });
|
|
|
|
const health = unit.getComponent('health');
|
|
expect(health.current).toBe(50);
|
|
expect(health.maxHp).toBe(100); // Unchanged
|
|
});
|
|
});
|
|
|
|
describe('Damage System', () => {
|
|
it('should apply damage with armor reduction', () => {
|
|
const damageTaken = unit.damage(30, 'rifle');
|
|
|
|
// effectiveArmor = 5 * (1-0) = 5, finalDamage = max(1, 30-5) = 25
|
|
expect(damageTaken).toBe(25);
|
|
expect(unit.getComponent('health').current).toBe(75);
|
|
});
|
|
|
|
it('should apply minimum 1 damage', () => {
|
|
const damageTaken = unit.damage(2, 'rifle');
|
|
|
|
expect(damageTaken).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('should emit unit:damaged event', () => {
|
|
unit.damage(20);
|
|
|
|
expect(scene.events.emit).toHaveBeenCalledWith(
|
|
'unit:damaged',
|
|
expect.objectContaining({
|
|
unit: unit,
|
|
amount: expect.any(Number),
|
|
remaining: expect.any(Number)
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should mark unit as dead when health reaches 0', () => {
|
|
unit.getComponent('health').current = 5;
|
|
unit.damage(10);
|
|
|
|
expect(unit.dead).toBe(true);
|
|
});
|
|
|
|
it('should not damage if already dead', () => {
|
|
unit.dead = true;
|
|
const damageTaken = unit.damage(50);
|
|
|
|
expect(damageTaken).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Heal System', () => {
|
|
it('should heal unit', () => {
|
|
// damage(30): armor 5, AP 0 → effectiveArmor = 5, finalDamage = max(1, 25) = 25
|
|
// health: 100 → 75
|
|
unit.damage(30);
|
|
const healed = unit.heal(20);
|
|
|
|
expect(healed).toBe(20);
|
|
// 75 + 20 = 95 (capped at maxHp: 100, so stays at 95)
|
|
expect(unit.getComponent('health').current).toBe(95);
|
|
});
|
|
|
|
it('should not exceed max HP', () => {
|
|
const healed = unit.heal(50);
|
|
|
|
expect(unit.getComponent('health').current).toBe(100); // Capped at max
|
|
});
|
|
});
|
|
|
|
describe('Combat', () => {
|
|
let target;
|
|
|
|
beforeEach(() => {
|
|
target = {
|
|
x: 150,
|
|
y: 0,
|
|
isDead: jest.fn(() => false),
|
|
getData: jest.fn(() => ({ playerId: 'enemy' }))
|
|
};
|
|
});
|
|
|
|
it('should return true when target in range', () => {
|
|
// unit at (0,0), target at (150,0) → distance 150 ≤ 200 weaponRange
|
|
expect(unit.canHitBody(target)).toBe(true);
|
|
});
|
|
|
|
it('should return false when target out of range', () => {
|
|
target.x = 300; // Beyond 200 range
|
|
expect(unit.canHitBody(target)).toBe(false);
|
|
});
|
|
|
|
it('should return false when target is dead', () => {
|
|
target.isDead = jest.fn(() => true);
|
|
expect(unit.canHitBody(target)).toBe(false);
|
|
});
|
|
|
|
it('should attack target when in range', () => {
|
|
const result = unit.attackTarget(target);
|
|
|
|
expect(result).toBe(true);
|
|
expect(scene.orchestrator.systems.combat.fireProjectile).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not attack when target out of range', () => {
|
|
target.x = 300;
|
|
const result = unit.attackTarget(target);
|
|
|
|
expect(result).toBe(false);
|
|
expect(scene.orchestrator.systems.combat.fireProjectile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should respect fire rate', () => {
|
|
unit.attackTarget(target);
|
|
const result2 = unit.attackTarget(target);
|
|
|
|
// Second attack should fail due to fire rate cooldown
|
|
expect(result2).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Selection', () => {
|
|
it('should select unit', () => {
|
|
unit.select();
|
|
|
|
expect(unit.getData('selected')).toBe(true);
|
|
expect(scene.tweens.addCounter).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should unselect unit', () => {
|
|
unit.select();
|
|
unit.unSelect();
|
|
|
|
expect(unit.getData('selected')).toBe(false);
|
|
});
|
|
|
|
it('should tint based on team', () => {
|
|
// Team 'good' → isEnemy = false → setTint with (0, 200, 0, 255)
|
|
unit.select();
|
|
|
|
expect(unit.setTint).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Movement', () => {
|
|
it('should move to tile', () => {
|
|
const result = unit.moveToTile({ x: 10, y: 10 });
|
|
|
|
expect(result).toBe(true);
|
|
expect(unit.setPosition).toHaveBeenCalledWith(640, 640); // 10 * 64
|
|
});
|
|
|
|
it('should set target tile data', () => {
|
|
unit.moveToTile({ x: 10, y: 10 });
|
|
|
|
expect(unit.getData('targetTile')).toEqual({ x: 10, y: 10 });
|
|
});
|
|
|
|
it('should orient to target', () => {
|
|
// unit at (0,0), target at (0, 100) → BELOW → direction = SOUTH (180°)
|
|
// RadToDeg normalizes to [0,360), 180° is EAST or SOUTH?
|
|
// getDirection: degrees >= 180 && < 270 → 'SOUTH' → setFlipX(true)
|
|
const target = { x: 0, y: 100 };
|
|
unit.orientToTarget(target);
|
|
|
|
// atan2(100, 0) = π/2 → 90° → 'EAST' range (90-180)
|
|
// Actually: atan2(y2-y1, x2-x1) = atan2(100, 0) = π/2 = 90°
|
|
// 90° is in range [90, 180) → 'EAST' → shouldFlip = true
|
|
expect(unit.setFlipX).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|
|
|
|
describe('State Machine', () => {
|
|
it('should initialize state machine', () => {
|
|
expect(unit.stateMachine).toBeDefined();
|
|
// Constructor calls EntityStateMachine.forEntity (module-level mock, not scene)
|
|
expect(EntityStateMachine.forEntity).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should tick state machine in preUpdate', () => {
|
|
const tickSpy = jest.spyOn(unit.stateMachine, 'tick');
|
|
|
|
unit.preUpdate(Date.now(), 16);
|
|
|
|
expect(tickSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Death', () => {
|
|
it('should trigger death when health reaches 0', () => {
|
|
const dieSpy = jest.spyOn(unit.stateMachine, 'send');
|
|
|
|
unit.getComponent('health').current = 5;
|
|
unit.damage(10);
|
|
|
|
expect(dieSpy).toHaveBeenCalledWith('DIE');
|
|
expect(scene.events.emit).toHaveBeenCalledWith('unit:dying', expect.anything());
|
|
});
|
|
|
|
it('should cleanup on destroy', () => {
|
|
unit.pulse = { stop: jest.fn() };
|
|
const destroySpy = jest.spyOn(unit.stateMachine, 'destroy');
|
|
|
|
unit.destroy();
|
|
|
|
expect(unit.pulse.stop).toHaveBeenCalled();
|
|
expect(destroySpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|