Files
iron-requiem/tests/slice2_enemy.test.js
Kay Kayyali 4bef8e66df
Some checks failed
Iron Requiem CI/CD / test (push) Failing after 11s
Iron Requiem CI/CD / deploy (push) Has been skipped
Build & Deploy / build-and-deploy (push) Has been cancelled
Fix Recovery Phase 4: Single bundle script, correct Traefik network
- 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
2026-05-24 04:30:06 +00:00

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
});
});
});
});