- build.sh: Remove old bundle tag before injecting hashed version - index.html: Remove duplicate script tag from template - docker-compose.yml: Fix network name (hermes-net, not litellm_hermes-net) - Deployment verified: HTTPS 200 via Cloudflare + Traefik
440 lines
16 KiB
JavaScript
440 lines
16 KiB
JavaScript
/**
|
|
* Slice 2 — Enemy entity tests
|
|
*
|
|
* Tests for Enemy class (AI behavior, movement, pattern delegation,
|
|
* periscope fairness) and spawnZone wave generation.
|
|
*
|
|
* RED phase: Enemy.js does not exist yet — every test should fail on import.
|
|
*/
|
|
// Using Jest globals (describe, it, expect, jest, beforeEach, beforeAll) — no import needed
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// We import the enemies data — this already exists and is tested
|
|
// ---------------------------------------------------------------------------
|
|
import { enemies } from '../src/data/enemies.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dynamic import — will fail until Enemy.js is created
|
|
// ---------------------------------------------------------------------------
|
|
let Enemy, spawnZone;
|
|
|
|
describe('Enemy', () => {
|
|
beforeAll(async () => {
|
|
const mod = await import('../src/game/entities/Enemy.js');
|
|
Enemy = mod.Enemy;
|
|
spawnZone = mod.spawnZone;
|
|
});
|
|
|
|
// =========================================================================
|
|
// Construction
|
|
// =========================================================================
|
|
describe('construction', () => {
|
|
it('creates a Type 59 (heavy) enemy from enemies.js data', () => {
|
|
const scene = {};
|
|
const e = new Enemy(scene, 'type59', 400, 300);
|
|
|
|
expect(e.typeKey).toBe('type59');
|
|
expect(e.hp).toBe(300);
|
|
expect(e.maxHp).toBe(300);
|
|
expect(e.speed).toBe(60);
|
|
expect(e.armor).toBe(120);
|
|
expect(e.patterns).toEqual(['tank_destroyer_beam']);
|
|
expect(e.x).toBe(400);
|
|
expect(e.y).toBe(300);
|
|
expect(e.active).toBe(true);
|
|
});
|
|
|
|
it('creates a Type 62 (light) enemy from enemies.js data', () => {
|
|
const scene = {};
|
|
const e = new Enemy(scene, 'type62', 100, 200);
|
|
|
|
expect(e.typeKey).toBe('type62');
|
|
expect(e.hp).toBe(100);
|
|
expect(e.speed).toBe(140);
|
|
expect(e.armor).toBe(40);
|
|
expect(e.patterns).toEqual(['infantry_wall']);
|
|
});
|
|
|
|
it('creates an artillery emplacement — static, 0 speed', () => {
|
|
const scene = {};
|
|
const e = new Enemy(scene, 'artillery_emplacement', 600, 150);
|
|
|
|
expect(e.typeKey).toBe('artillery_emplacement');
|
|
expect(e.hp).toBe(80);
|
|
expect(e.speed).toBe(0);
|
|
});
|
|
|
|
it('creates a helicopter gunship — fast, 200px/s', () => {
|
|
const scene = {};
|
|
const e = new Enemy(scene, 'helicopter_gunship', 200, 100);
|
|
|
|
expect(e.typeKey).toBe('helicopter_gunship');
|
|
expect(e.hp).toBe(150);
|
|
expect(e.speed).toBe(200);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Sprite creation — colored rectangle placeholders
|
|
// =========================================================================
|
|
describe('sprite creation', () => {
|
|
it('creates a Phaser rectangle sprite with correct color and dimensions', () => {
|
|
const mockSprite = { setDepth: jest.fn() };
|
|
const mockScene = {
|
|
add: {
|
|
rectangle: jest.fn().mockReturnValue(mockSprite),
|
|
},
|
|
};
|
|
const e = new Enemy(mockScene, 'type59', 400, 300);
|
|
|
|
expect(mockScene.add.rectangle).toHaveBeenCalledWith(400, 300, 24, 36, '#ff0000');
|
|
expect(e.sprite).toBe(mockSprite);
|
|
expect(e.sprite.setDepth).toHaveBeenCalledWith(50);
|
|
});
|
|
|
|
it('Type 59 creates red (#ff0000) sprite', () => {
|
|
const mockSprite = { setDepth: jest.fn() };
|
|
const mockScene = { add: { rectangle: jest.fn().mockReturnValue(mockSprite) } };
|
|
const e = new Enemy(mockScene, 'type59', 0, 0);
|
|
expect(e.spriteColor).toBe('#ff0000');
|
|
});
|
|
|
|
it('Type 62 creates orange (#ff8c00) sprite', () => {
|
|
const mockSprite = { setDepth: jest.fn() };
|
|
const mockScene = { add: { rectangle: jest.fn().mockReturnValue(mockSprite) } };
|
|
const e = new Enemy(mockScene, 'type62', 0, 0);
|
|
expect(e.spriteColor).toBe('#ff8c00');
|
|
});
|
|
|
|
it('Artillery creates gray (#808080) sprite', () => {
|
|
const mockSprite = { setDepth: jest.fn() };
|
|
const mockScene = { add: { rectangle: jest.fn().mockReturnValue(mockSprite) } };
|
|
const e = new Enemy(mockScene, 'artillery_emplacement', 0, 0);
|
|
expect(e.spriteColor).toBe('#808080');
|
|
});
|
|
|
|
it('Helicopter creates white (#ffffff) sprite', () => {
|
|
const mockSprite = { setDepth: jest.fn() };
|
|
const mockScene = { add: { rectangle: jest.fn().mockReturnValue(mockSprite) } };
|
|
const e = new Enemy(mockScene, 'helicopter_gunship', 0, 0);
|
|
expect(e.spriteColor).toBe('#ffffff');
|
|
});
|
|
|
|
it('does not crash when scene has no add.rectangle (bare object)', () => {
|
|
// Existing tests pass {} — guarded against crash
|
|
expect(() => new Enemy({}, 'type59', 400, 300)).not.toThrow();
|
|
const e = new Enemy({}, 'type59', 400, 300);
|
|
expect(e.spriteColor).toBe('#ff0000');
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Type 59 AI — slow, frontal, stays in front of player
|
|
// =========================================================================
|
|
describe('Type 59 movement AI', () => {
|
|
it('moves toward the front of the player at 60px/s', () => {
|
|
const e = new Enemy({}, 'type59', 800, 300);
|
|
// Player at (400, 300), angle 90° (facing up — forward is north)
|
|
// "Front" of player = player's forward direction
|
|
// Enemy should move toward a point in front of the player
|
|
const playerX = 400, playerY = 300, playerAngle = 90;
|
|
|
|
e.update(1000, playerX, playerY, playerAngle, 5000);
|
|
|
|
// Enemy should have moved ~60px toward the front-of-player target
|
|
// Front of player at angle 90°: (400, 300 - someDistance)
|
|
const dist = Math.sqrt((e.x - 800) ** 2 + (e.y - 300) ** 2);
|
|
expect(dist).toBeCloseTo(60, -1); // within ~10px tolerance
|
|
});
|
|
|
|
it('stays in front of player — target is player forward offset', () => {
|
|
const e = new Enemy({}, 'type59', 400, 500);
|
|
// Player at (400, 500), facing up (angle -90 or 90)
|
|
const px = 400, py = 500, pa = 90;
|
|
|
|
e.update(1000, px, py, pa, 5000);
|
|
|
|
// Enemy started AT player position. It should move toward "front" of player
|
|
// which is north of the player. y should decrease.
|
|
expect(e.y).toBeLessThan(500);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Type 62 AI — fast flanker, gets behind player
|
|
// =========================================================================
|
|
describe('Type 62 movement AI', () => {
|
|
it('flanks — tries to get behind the player using angle calculation', () => {
|
|
const e = new Enemy({}, 'type62', 400, 200);
|
|
const px = 400, py = 300, pa = 90; // player facing up
|
|
|
|
e.update(1000, px, py, pa, 5000);
|
|
|
|
// "Behind" = opposite of player facing direction = (400, 300 + offset)
|
|
// Enemy at (400, 200) should move toward behind the player; y should increase
|
|
expect(e.y).toBeGreaterThan(200);
|
|
});
|
|
|
|
it('moves at 140px/s', () => {
|
|
const e = new Enemy({}, 'type62', 0, 300);
|
|
const px = 400, py = 300, pa = 0;
|
|
|
|
e.update(1000, px, py, pa, 5000);
|
|
|
|
const dist = Math.sqrt((e.x - 0) ** 2 + (e.y - 300) ** 2);
|
|
expect(dist).toBeCloseTo(140, -1);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Artillery — static, timed pattern firing
|
|
// =========================================================================
|
|
describe('Artillery AI', () => {
|
|
it('is static — does not move', () => {
|
|
const e = new Enemy({}, 'artillery_emplacement', 500, 300);
|
|
const origX = e.x, origY = e.y;
|
|
|
|
e.update(1000, 400, 300, 0, 5000);
|
|
expect(e.x).toBe(origX);
|
|
expect(e.y).toBe(origY);
|
|
});
|
|
|
|
it('fires Artillery Ring pattern at timed intervals (3-5s)', () => {
|
|
const scene = {
|
|
patternManager: { trigger: jest.fn().mockReturnValue({ count: 1 }) },
|
|
};
|
|
const e = new Enemy(scene, 'artillery_emplacement', 500, 300);
|
|
|
|
// Update for 3 seconds — should fire once
|
|
e.update(3000, 400, 300, 0, 3000);
|
|
expect(scene.patternManager.trigger).toHaveBeenCalledTimes(1);
|
|
expect(scene.patternManager.trigger).toHaveBeenCalledWith(
|
|
'artillery_ring',
|
|
expect.objectContaining({ x: 500, y: 300 })
|
|
);
|
|
});
|
|
|
|
it('does not fire before minimum interval of 3s', () => {
|
|
const scene = {
|
|
patternManager: { trigger: jest.fn().mockReturnValue({ count: 1 }) },
|
|
};
|
|
const e = new Enemy(scene, 'artillery_emplacement', 500, 300);
|
|
|
|
e.update(2000, 400, 300, 0, 2000);
|
|
// 2s < 3s — should NOT fire yet
|
|
expect(scene.patternManager.trigger).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fires repeatedly, each at 3-5s intervals', () => {
|
|
const scene = {
|
|
patternManager: { trigger: jest.fn().mockReturnValue({ count: 1 }) },
|
|
};
|
|
const e = new Enemy(scene, 'artillery_emplacement', 500, 300);
|
|
|
|
// Run for 10 seconds — should fire 2-3 times (at 3-5s intervals)
|
|
e.update(10000, 400, 300, 0, 10000);
|
|
const calls = scene.patternManager.trigger.mock.calls.length;
|
|
expect(calls).toBeGreaterThanOrEqual(2);
|
|
expect(calls).toBeLessThanOrEqual(3);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Helicopter — fast sweeping attack runs
|
|
// =========================================================================
|
|
describe('Helicopter AI', () => {
|
|
it('moves at 200px/s', () => {
|
|
const e = new Enemy({}, 'helicopter_gunship', 0, 300);
|
|
const px = 400, py = 300, pa = 0;
|
|
|
|
e.update(1000, px, py, pa, 5000);
|
|
|
|
const dist = Math.sqrt((e.x - 0) ** 2 + (e.y - 300) ** 2);
|
|
expect(dist).toBeCloseTo(200, -1);
|
|
});
|
|
|
|
it('performs sweeping attack runs — moves across the player position', () => {
|
|
const e = new Enemy({}, 'helicopter_gunship', 100, 100);
|
|
// Player at (400, 300). Heli should sweep toward/over player.
|
|
const px = 400, py = 300, pa = 0;
|
|
|
|
e.update(1000, px, py, pa, 5000);
|
|
|
|
// Should have moved generally toward the player area
|
|
const dxAfter = Math.abs(e.x - px);
|
|
const dxBefore = Math.abs(100 - px);
|
|
expect(dxAfter).toBeLessThan(dxBefore); // closer to player x
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// executePattern — delegates to scene.patternManager
|
|
// =========================================================================
|
|
describe('executePattern', () => {
|
|
it('delegates to scene.patternManager.trigger with position', () => {
|
|
const scene = {
|
|
patternManager: {
|
|
trigger: jest.fn().mockReturnValue({ count: 40, projectiles: [] }),
|
|
},
|
|
};
|
|
const e = new Enemy(scene, 'type59', 400, 300);
|
|
|
|
const result = e.executePattern('tank_destroyer_beam');
|
|
|
|
expect(scene.patternManager.trigger).toHaveBeenCalledWith(
|
|
'tank_destroyer_beam',
|
|
expect.objectContaining({ x: 400, y: 300 })
|
|
);
|
|
expect(result).toEqual({ count: 40, projectiles: [] });
|
|
});
|
|
|
|
it('returns null if no patternManager on scene', () => {
|
|
const e = new Enemy({}, 'type59', 400, 300);
|
|
const result = e.executePattern('tank_destroyer_beam');
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Periscope fairness — off-screen accuracy penalty
|
|
// =========================================================================
|
|
describe('periscope fairness', () => {
|
|
it('returns full accuracy (1.0) when enemy is on-screen', () => {
|
|
const e = new Enemy({}, 'type59', 400, 300);
|
|
// Camera centered at (400, 300), viewport 640x480
|
|
const accuracy = e.getAccuracy(400, 300, 640, 480);
|
|
expect(accuracy).toBe(1.0);
|
|
});
|
|
|
|
it('returns reduced accuracy (-40%, i.e. 0.6) when enemy is off-screen', () => {
|
|
const e = new Enemy({}, 'type59', 2000, 1500);
|
|
// Camera at (400, 300), viewport 640x480 — enemy far off-screen
|
|
const accuracy = e.getAccuracy(400, 300, 640, 480);
|
|
expect(accuracy).toBeCloseTo(0.6, 1);
|
|
});
|
|
|
|
it('correctly identifies off-screen on each edge', () => {
|
|
const w = 640, h = 480;
|
|
|
|
// Left edge
|
|
const e1 = new Enemy({}, 'type62', -100, 300);
|
|
expect(e1.isOffScreen(320, 300, w, h)).toBe(true);
|
|
|
|
// Right edge
|
|
const e2 = new Enemy({}, 'type62', 1000, 300);
|
|
expect(e2.isOffScreen(320, 300, w, h)).toBe(true);
|
|
|
|
// Top edge
|
|
const e3 = new Enemy({}, 'type62', 320, -100);
|
|
expect(e3.isOffScreen(320, 300, w, h)).toBe(true);
|
|
|
|
// Bottom edge
|
|
const e4 = new Enemy({}, 'type62', 320, 600);
|
|
expect(e4.isOffScreen(320, 300, w, h)).toBe(true);
|
|
|
|
// On-screen
|
|
const e5 = new Enemy({}, 'type62', 320, 300);
|
|
expect(e5.isOffScreen(320, 300, w, h)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// takeDamage
|
|
// =========================================================================
|
|
describe('takeDamage', () => {
|
|
it('reduces HP by damage amount', () => {
|
|
const e = new Enemy({}, 'type59', 400, 300);
|
|
e.takeDamage(50);
|
|
expect(e.hp).toBe(250);
|
|
});
|
|
|
|
it('sets active=false when HP reaches 0', () => {
|
|
const e = new Enemy({}, 'type59', 400, 300); // 300 HP
|
|
e.takeDamage(300);
|
|
expect(e.hp).toBe(0);
|
|
expect(e.active).toBe(false);
|
|
});
|
|
|
|
it('clamps HP at 0 (no negative values)', () => {
|
|
const e = new Enemy({}, 'type59', 400, 300);
|
|
e.takeDamage(400);
|
|
expect(e.hp).toBe(0);
|
|
});
|
|
|
|
it('destroys sprite when enemy dies', () => {
|
|
const mockSprite = { setDepth: jest.fn(), destroy: jest.fn() };
|
|
const scene = {
|
|
add: { rectangle: jest.fn().mockReturnValue(mockSprite) },
|
|
};
|
|
const e = new Enemy(scene, 'type59', 400, 300);
|
|
|
|
// Deal lethal damage
|
|
e.takeDamage(300);
|
|
|
|
expect(e.active).toBe(false);
|
|
expect(mockSprite.destroy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// spawnZone
|
|
// =========================================================================
|
|
describe('spawnZone', () => {
|
|
it('creates a wave of enemies for a valid zone', () => {
|
|
const scene = { enemies: [] };
|
|
const wave = spawnZone(scene, 2, 400, 300);
|
|
|
|
expect(Array.isArray(wave)).toBe(true);
|
|
expect(wave.length).toBeGreaterThan(0);
|
|
// All enemies should be Enemy instances
|
|
wave.forEach(e => {
|
|
expect(e).toBeInstanceOf(Enemy);
|
|
});
|
|
});
|
|
|
|
it('includes enemies whose zone matches the zoneId', () => {
|
|
const scene = { enemies: [] };
|
|
const wave = spawnZone(scene, 2, 400, 300);
|
|
|
|
// Zone 2 includes: type59 (zones [2,3]) and helicopter_gunship (zones [2,3])
|
|
const typeKeys = wave.map(e => e.typeKey);
|
|
expect(typeKeys).toContain('type59');
|
|
expect(typeKeys).toContain('helicopter_gunship');
|
|
// type62 has zones [1,2,3] — also included
|
|
expect(typeKeys).toContain('type62');
|
|
});
|
|
|
|
it('zone=all (artillery) appears even in unmatched zones', () => {
|
|
const scene = { enemies: [] };
|
|
const wave = spawnZone(scene, 99, 400, 300);
|
|
|
|
// Artillery has zone='all' — it always spawns
|
|
const typeKeys = wave.map(e => e.typeKey);
|
|
expect(typeKeys).toContain('artillery_emplacement');
|
|
// But no other enemy types in zone 99
|
|
expect(typeKeys).not.toContain('type59');
|
|
expect(typeKeys).not.toContain('type62');
|
|
expect(typeKeys).not.toContain('helicopter_gunship');
|
|
});
|
|
|
|
it('artillery (zone=all) appears in every zone', () => {
|
|
const scene = { enemies: [] };
|
|
const wave = spawnZone(scene, 1, 400, 300);
|
|
|
|
const typeKeys = wave.map(e => e.typeKey);
|
|
expect(typeKeys).toContain('artillery_emplacement');
|
|
});
|
|
|
|
it('spawns enemies near the player position', () => {
|
|
const scene = { enemies: [] };
|
|
const wave = spawnZone(scene, 2, 400, 300);
|
|
|
|
wave.forEach(e => {
|
|
// Enemies should spawn within a reasonable radius of the player
|
|
const dist = Math.sqrt((e.x - 400) ** 2 + (e.y - 300) ** 2);
|
|
expect(dist).toBeLessThan(800); // reasonable spawn radius
|
|
});
|
|
});
|
|
});
|
|
});
|