- 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
444 lines
14 KiB
JavaScript
444 lines
14 KiB
JavaScript
/**
|
|
* CombatSystem.test.js — Tests for acquireTarget, canHit, applyDamage, and projectile logic.
|
|
*/
|
|
|
|
// Mock Phaser
|
|
const mockOverlap = jest.fn();
|
|
const mockVelocityFromAngle = jest.fn();
|
|
|
|
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,
|
|
},
|
|
},
|
|
Display: {
|
|
Color: {
|
|
GetColor32: jest.fn(() => 0xffff00),
|
|
},
|
|
},
|
|
GameObjects: {
|
|
Sprite: class {},
|
|
Rectangle: class {},
|
|
Graphics: class {},
|
|
Container: class {},
|
|
Zone: class {},
|
|
},
|
|
Geom: {
|
|
Rectangle: class {
|
|
constructor(x, y, w, h) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.width = w;
|
|
this.height = h;
|
|
}
|
|
},
|
|
},
|
|
}));
|
|
|
|
import CombatSystem from 'Systems/CombatSystem.js';
|
|
|
|
// Helper to create a mock entity
|
|
function mockEntity(x, y, overrides = {}) {
|
|
const entity = {
|
|
x,
|
|
y,
|
|
rotation: 0,
|
|
active: true,
|
|
dead: false,
|
|
body: {
|
|
center: { x, y },
|
|
velocity: { x: 0, y: 0 },
|
|
allowGravity: false,
|
|
},
|
|
parentContainer: {
|
|
name: overrides.containerName || 'Good Guys',
|
|
},
|
|
getData: jest.fn((key) => {
|
|
if (key === 'health') return 100;
|
|
if (key === 'armor') return 1;
|
|
return undefined;
|
|
}),
|
|
setData: jest.fn(),
|
|
emit: jest.fn(),
|
|
select: jest.fn(),
|
|
unSelect: jest.fn(),
|
|
isDead: jest.fn(() => false),
|
|
handleDeath: jest.fn(),
|
|
handleTakeDamage: jest.fn(),
|
|
getEnemyContainer: jest.fn(),
|
|
...overrides,
|
|
};
|
|
|
|
// Handle data store
|
|
const dataStore = { health: 100, armor: 1, ...overrides._data };
|
|
|
|
entity.getData.mockImplementation((key) => {
|
|
if (key === 'health') return entity._data?.health ?? dataStore.health ?? 100;
|
|
if (key === 'armor') return entity._data?.armor ?? dataStore.armor ?? 1;
|
|
return entity._data?.[key] ?? dataStore[key];
|
|
});
|
|
|
|
entity.setData.mockImplementation((key, value) => {
|
|
if (!entity._data) entity._data = { ...dataStore };
|
|
entity._data[key] = value;
|
|
});
|
|
|
|
entity._data = { ...dataStore };
|
|
|
|
return entity;
|
|
}
|
|
|
|
describe('CombatSystem', () => {
|
|
let combat;
|
|
let mockScene;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
mockScene = {
|
|
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
|
|
physics: {
|
|
add: { group: jest.fn(() => ({ getChildren: () => [], create: jest.fn(), add: jest.fn().mockImplementation((sprite) => sprite) })) },
|
|
world: { enableBody: jest.fn() },
|
|
overlap: jest.fn(() => false),
|
|
velocityFromAngle: mockVelocityFromAngle,
|
|
},
|
|
add: {
|
|
rectangle: jest.fn(() => {
|
|
const proj = {
|
|
setDepth: jest.fn(),
|
|
setData: jest.fn(),
|
|
getData: jest.fn(),
|
|
setRotation: jest.fn(),
|
|
body: { velocity: { x: 0, y: 0 }, allowGravity: true },
|
|
};
|
|
return proj;
|
|
}),
|
|
},
|
|
textures: { exists: jest.fn(() => false) },
|
|
tweens: { addCounter: jest.fn(() => ({ stop: jest.fn() })) },
|
|
};
|
|
|
|
combat = new CombatSystem(mockScene);
|
|
});
|
|
|
|
// ── constructor ─────────────────────────────────────────────────
|
|
describe('constructor', () => {
|
|
test('initializes projectiles group and damage modifiers', () => {
|
|
expect(combat.projectiles).toBeDefined();
|
|
expect(combat.damageModifiers).toBeDefined();
|
|
expect(combat.damageModifiers.default).toBeDefined();
|
|
expect(combat.damageModifiers.rifle).toBeDefined();
|
|
expect(combat.damageModifiers.cannon).toBeDefined();
|
|
expect(combat.damageModifiers.tank_cannon).toBeDefined();
|
|
});
|
|
|
|
test('_goodGuys and _enemies start null', () => {
|
|
expect(combat._goodGuys).toBeNull();
|
|
expect(combat._enemies).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── acquireTarget ───────────────────────────────────────────────
|
|
describe('acquireTarget', () => {
|
|
let friendlies, enemies;
|
|
|
|
beforeEach(() => {
|
|
friendlies = { name: 'Good Guys', list: [], getAll: jest.fn(() => []) };
|
|
enemies = { name: 'Bad Guys', list: [], getAll: jest.fn(() => []) };
|
|
});
|
|
|
|
test('returns null when enemy container has no units', () => {
|
|
const entity = mockEntity(100, 100, {
|
|
getEnemyContainer: () => ({ list: [], getAll: () => [] }),
|
|
});
|
|
|
|
expect(combat.acquireTarget(entity)).toBeNull();
|
|
});
|
|
|
|
test('returns null when all enemies are dead', () => {
|
|
const entity = mockEntity(100, 100, {
|
|
getEnemyContainer: () => ({
|
|
list: [{ dead: true }],
|
|
getAll: () => [],
|
|
}),
|
|
});
|
|
|
|
expect(combat.acquireTarget(entity)).toBeNull();
|
|
});
|
|
|
|
test('finds closest enemy within range', () => {
|
|
const target1 = mockEntity(120, 100, { containerName: 'Bad Guys' });
|
|
const target2 = mockEntity(200, 100, { containerName: 'Bad Guys' });
|
|
|
|
// Mock LoS to always return true for this test
|
|
const originalLos = combat.hasLineOfSight;
|
|
combat.hasLineOfSight = jest.fn(() => true);
|
|
|
|
const entity = mockEntity(100, 100, {
|
|
getEnemyContainer: () => ({
|
|
list: [target1, target2],
|
|
getAll: () => [target1, target2],
|
|
}),
|
|
});
|
|
|
|
const result = combat.acquireTarget(entity);
|
|
expect(result).toBe(target1); // closest
|
|
|
|
combat.hasLineOfSight = originalLos;
|
|
});
|
|
|
|
test('filters by fov cone', () => {
|
|
const target = mockEntity(200, 100, { containerName: 'Bad Guys' });
|
|
|
|
const entity = mockEntity(100, 100, {
|
|
rotation: 0,
|
|
getEnemyContainer: () => ({
|
|
list: [target],
|
|
getAll: () => [target],
|
|
}),
|
|
});
|
|
|
|
// With narrow FOV, entity facing 0 and target straight ahead should work
|
|
const originalLos = combat.hasLineOfSight;
|
|
combat.hasLineOfSight = jest.fn(() => true);
|
|
|
|
const result = combat.acquireTarget(entity, { fov: 90 });
|
|
expect(result).toBe(target);
|
|
|
|
combat.hasLineOfSight = originalLos;
|
|
});
|
|
|
|
test('prioritizes weakest when specified', () => {
|
|
const strong = mockEntity(120, 100, { containerName: 'Bad Guys' });
|
|
const weak = mockEntity(115, 100, { containerName: 'Bad Guys' });
|
|
|
|
strong._data = { health: 80 };
|
|
weak._data = { health: 20 };
|
|
|
|
const entity = mockEntity(100, 100, {
|
|
getEnemyContainer: () => ({
|
|
list: [strong, weak],
|
|
getAll: () => [strong, weak],
|
|
}),
|
|
});
|
|
|
|
const originalLos = combat.hasLineOfSight;
|
|
combat.hasLineOfSight = jest.fn(() => true);
|
|
|
|
const result = combat.acquireTarget(entity, { priority: 'weakest' });
|
|
expect(result).toBe(weak);
|
|
|
|
combat.hasLineOfSight = originalLos;
|
|
});
|
|
|
|
test('returns null for null enemy container', () => {
|
|
const entity = mockEntity(100, 100, { getEnemyContainer: () => null });
|
|
expect(combat.acquireTarget(entity)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── canHit ──────────────────────────────────────────────────────
|
|
describe('canHit', () => {
|
|
test('returns false for null entities', () => {
|
|
expect(combat.canHit(null, mockEntity(0, 0))).toEqual({ canHit: false, reason: 'invalid_entities' });
|
|
});
|
|
|
|
test('returns false for friendly fire (same container)', () => {
|
|
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
|
|
const target = mockEntity(10, 10, { containerName: 'Good Guys' });
|
|
expect(combat.canHit(attacker, target)).toEqual({ canHit: false, reason: 'friendly_fire' });
|
|
});
|
|
|
|
test('returns false for dead target', () => {
|
|
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
|
|
const target = mockEntity(10, 10, {
|
|
containerName: 'Bad Guys',
|
|
dead: true,
|
|
});
|
|
expect(combat.canHit(attacker, target)).toEqual({ canHit: false, reason: 'target_dead' });
|
|
});
|
|
|
|
test('returns false when target is out of range', () => {
|
|
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
|
|
const target = mockEntity(2000, 2000, { containerName: 'Bad Guys' });
|
|
// distance ~2828, default range 150
|
|
|
|
const originalLos = combat.hasLineOfSight;
|
|
combat.hasLineOfSight = jest.fn(() => false);
|
|
|
|
const result = combat.canHit(attacker, target);
|
|
expect(result.canHit).toBe(false);
|
|
expect(result.reason).toBe('out_of_range');
|
|
|
|
combat.hasLineOfSight = originalLos;
|
|
});
|
|
});
|
|
|
|
// ── applyDamage ─────────────────────────────────────────────────
|
|
describe('applyDamage', () => {
|
|
test('deals damage reducing health', () => {
|
|
const entity = mockEntity(0, 0);
|
|
entity._data = { health: 100, armor: 1 };
|
|
|
|
const dealt = combat.applyDamage(entity, 20);
|
|
expect(dealt).toBeGreaterThan(0);
|
|
expect(entity.emit).toHaveBeenCalledWith('combat:damaged', expect.any(Object));
|
|
});
|
|
|
|
test('returns 0 for dead entity', () => {
|
|
const entity = mockEntity(0, 0);
|
|
entity.dead = true;
|
|
|
|
expect(combat.applyDamage(entity, 20)).toBe(0);
|
|
});
|
|
|
|
test('armor reduces damage taken', () => {
|
|
const entity = mockEntity(0, 0);
|
|
entity._data = { health: 100, armor: 5 };
|
|
|
|
const dealt = combat.applyDamage(entity, 20);
|
|
// effectiveArmor = 5 * (1 - 0) = 5; damage = max(1, round(20 - 5)) = 15
|
|
expect(dealt).toBe(15);
|
|
});
|
|
|
|
test('deals at least 1 damage', () => {
|
|
const entity = mockEntity(0, 0);
|
|
entity._data = { health: 100, armor: 1000 };
|
|
|
|
const dealt = combat.applyDamage(entity, 1);
|
|
expect(dealt).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('calls handleDeath when health drops to 0', () => {
|
|
const entity = mockEntity(0, 0);
|
|
entity._data = { health: 100, armor: 1 };
|
|
entity.handleDeath = jest.fn();
|
|
|
|
combat.applyDamage(entity, 999);
|
|
expect(entity.handleDeath).toHaveBeenCalled();
|
|
});
|
|
|
|
test('emits combat:unitDamaged on scene', () => {
|
|
const entity = mockEntity(0, 0);
|
|
entity._data = { health: 100, armor: 1 };
|
|
|
|
combat.applyDamage(entity, 10);
|
|
expect(mockScene.events.emit).toHaveBeenCalledWith('combat:unitDamaged', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
// ── fireProjectile & projectile management ──────────────────────
|
|
describe('fireProjectile', () => {
|
|
test('returns null for invalid entities', () => {
|
|
expect(combat.fireProjectile(null, mockEntity(0, 0))).toBeNull();
|
|
});
|
|
|
|
test('creates a fallback rectangle when no sprite texture', () => {
|
|
const attacker = mockEntity(0, 0);
|
|
const target = mockEntity(100, 0, { containerName: 'Bad Guys' });
|
|
|
|
const proj = combat.fireProjectile(attacker, target);
|
|
expect(proj).toBeDefined();
|
|
expect(mockScene.add.rectangle).toHaveBeenCalled();
|
|
});
|
|
|
|
test('sets projectile data (damage, damageType, attacker, target)', () => {
|
|
const attacker = mockEntity(0, 0);
|
|
const target = mockEntity(100, 0, { containerName: 'Bad Guys' });
|
|
|
|
const proj = combat.fireProjectile(attacker, target, { damageType: 'cannon' });
|
|
expect(proj).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── registerUnitContainers ──────────────────────────────────────
|
|
describe('registerUnitContainers', () => {
|
|
test('stores goodGuys and enemies references', () => {
|
|
const goodGuys = { name: 'Good Guys' };
|
|
const enemies = { name: 'Bad Guys' };
|
|
|
|
combat.registerUnitContainers(goodGuys, enemies);
|
|
|
|
expect(combat._goodGuys).toBe(goodGuys);
|
|
expect(combat._enemies).toBe(enemies);
|
|
});
|
|
});
|
|
|
|
// ── update loop ─────────────────────────────────────────────────
|
|
describe('update', () => {
|
|
test('handles empty projectile group', () => {
|
|
expect(() => combat.update(0, 16)).not.toThrow();
|
|
});
|
|
|
|
test('destroys expired projectiles', () => {
|
|
const destroySpy = jest.fn();
|
|
const expired = {
|
|
active: true,
|
|
getData: jest.fn((key) => {
|
|
if (key === 'elapsed') return 5000;
|
|
if (key === 'lifespan') return 4000;
|
|
return null;
|
|
}),
|
|
setData: jest.fn(),
|
|
destroy: destroySpy,
|
|
};
|
|
|
|
combat.projectiles = {
|
|
getChildren: () => [expired],
|
|
};
|
|
|
|
combat.update(0, 16);
|
|
expect(destroySpy).toHaveBeenCalled();
|
|
});
|
|
|
|
test('destroys inactive projectiles', () => {
|
|
const destroySpy = jest.fn();
|
|
const inactive = {
|
|
active: false,
|
|
getData: jest.fn(),
|
|
setData: jest.fn(),
|
|
destroy: destroySpy,
|
|
};
|
|
|
|
combat.projectiles = {
|
|
getChildren: () => [inactive],
|
|
};
|
|
|
|
combat.update(0, 16);
|
|
expect(destroySpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── hasLineOfSight ──────────────────────────────────────────────
|
|
describe('hasLineOfSight', () => {
|
|
test('returns true when no rockLayer', () => {
|
|
combat.scene.rockLayer = undefined;
|
|
expect(combat.hasLineOfSight({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe(true);
|
|
});
|
|
|
|
test('returns true when worldToTileXY returns null', () => {
|
|
combat.scene.rockLayer = {
|
|
worldToTileXY: () => null,
|
|
};
|
|
expect(combat.hasLineOfSight({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe(true);
|
|
});
|
|
});
|
|
});
|