Files
iron-requiem/tests/slice2_patternmanager.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

442 lines
14 KiB
JavaScript

/**
* Slice 2 — PatternManager: full bullet hell orchestration tests.
*
* Requirements per Gitea issue #41:
* 1. Arcade.Group pooling (max 200)
* 2. 200+ projectiles at 60fps
* 3. trigger(patternId, origin, options) with timing choreography
* 4. Zone governor: Z1:2, Z2:3, Z3:4
* 5. Offset >=0.5s between overlapping pattern starts
* 6. Every pattern has provable safe zone (documented)
* 7. Reads from src/data/patterns.js
* 8. killProjectile / outOfBounds recycle to pool
*
* TDD: RED phase — all tests should FAIL before implementation.
*/
// Using Jest globals (describe, it, expect, beforeEach, afterEach, jest) — no import needed
// ---------------------------------------------------------------------------
// Mock Phaser Arcade.Group — isolated, no side-effects
// ---------------------------------------------------------------------------
class MockArcadeGroup {
constructor(scene, config = {}) {
this.scene = scene;
this.maxSize = config.maxSize != null ? config.maxSize : -1;
this.children = [];
}
create(x, y, key) {
if (this.maxSize > 0 && this.children.length >= this.maxSize) return null;
const sprite = {
x, y,
active: true,
visible: true,
body: { velocity: { x: 0, y: 0 }, enable: true },
key: key || 'projectile',
};
this.children.push(sprite);
return sprite;
}
getFirstDead(createIfNull, x, y, key) {
const dead = this.children.find(c => !c.active && !c.visible);
if (dead) {
dead.active = true;
dead.visible = true;
dead.body.enable = true;
dead.x = x;
dead.y = y;
return dead;
}
if (createIfNull) {
if (this.maxSize > 0 && this.children.length >= this.maxSize) return null;
return this.create(x, y, key);
}
return null;
}
killAndHide(sprite) {
sprite.active = false;
sprite.visible = false;
}
getTotalUsed() {
return this.children.filter(c => c.active).length;
}
getLength() {
return this.children.length;
}
}
jest.mock('phaser', () => ({
default: {
Physics: {
Arcade: {
Group: MockArcadeGroup,
},
},
},
}));
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
describe('Slice 2 — PatternManager orchestration', () => {
let PatternManager;
let patternsModule;
beforeAll(async () => {
const mod = await import('../src/game/systems/PatternManager.js');
PatternManager = mod.PatternManager;
patternsModule = await import('../src/data/patterns.js');
});
function createMockScene() {
const group = new MockArcadeGroup(null, { maxSize: 200 });
return {
physics: {
add: {
group: jest.fn(() => group),
},
},
};
}
beforeEach(() => {
jest.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
jest.setSystemTime(0);
});
afterEach(() => {
jest.useRealTimers();
});
// =======================================================================
// 1. Construction & pooling (reqs 1, 8)
// =======================================================================
it('creates Arcade.Group with maxSize 200', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
expect(pm.group).toBeDefined();
expect(pm.group.maxSize).toBe(200);
});
it('killProjectile deactivates and returns sprite to pool', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const result = pm.trigger('infantry_wall', { x: 100, y: 100 });
expect(result.count).toBeGreaterThan(0);
const sprite = result.projectiles[0];
expect(sprite.active).toBe(true);
pm.killProjectile(sprite);
expect(sprite.active).toBe(false);
expect(sprite.visible).toBe(false);
expect(sprite.body.enable).toBe(false);
});
it('outOfBounds marks projectile inactive for recycling', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const result = pm.trigger('infantry_wall', { x: 100, y: 100 });
const sprite = result.projectiles[0];
expect(sprite.active).toBe(true);
pm.outOfBounds(sprite);
expect(sprite.active).toBe(false);
expect(sprite.visible).toBe(false);
expect(sprite.body.enable).toBe(false);
});
// =======================================================================
// 2. trigger() — timing choreography (req 3)
// =======================================================================
it('trigger returns null for unknown patternId', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
expect(pm.trigger('not_a_pattern', { x: 0, y: 0 })).toBeNull();
});
it('trigger spawns infantry_wall with correct projectile count', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const result = pm.trigger('infantry_wall', { x: 400, y: 300 });
expect(result.count).toBe(40);
});
it('trigger accepts options { zone, delay, direction }', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const result = pm.trigger('infantry_wall', { x: 200, y: 100 }, {
zone: 1,
delay: 300,
direction: 'right-to-left',
});
// With delay set, projectiles are deferred
expect(result.delayed).toBe(true);
expect(result.patternId).toBe('infantry_wall');
// Advance time past delay
jest.advanceTimersByTime(300);
expect(result.fired).toBe(true);
expect(result.count).toBe(40);
expect(result.projectiles[0].body.velocity.x).toBeLessThan(0);
});
it('trigger with delay option defers projectile spawning', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const result = pm.trigger('infantry_wall', { x: 200, y: 100 }, { delay: 1000 });
// Pre-delay: no projectiles yet
expect(result.delayed).toBe(true);
expect(result.count).toBe(0);
expect(result.fired).toBe(false);
// Advance past delay
jest.advanceTimersByTime(1000);
expect(result.fired).toBe(true);
expect(result.count).toBe(40);
});
// =======================================================================
// 3. Zone governor (req 4)
// =======================================================================
it('zone governor enforces Z1 max 2 active patterns', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const r1 = pm.trigger('infantry_wall', { x: 100, y: 100 }, { zone: 1 });
const r2 = pm.trigger('infantry_wall', { x: 200, y: 200 }, { zone: 1 });
expect(r1.rejected).toBeFalsy();
expect(r2.rejected).toBeFalsy();
const r3 = pm.trigger('infantry_wall', { x: 300, y: 300 }, { zone: 1 });
expect(r3.rejected).toBe(true);
expect(r3.reason).toMatch(/capacity/);
});
it('zone governor enforces Z2 max 3 active patterns', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
for (let i = 0; i < 3; i++) {
const r = pm.trigger('infantry_wall', { x: 100 + i * 50, y: 100 }, { zone: 2 });
expect(r.rejected).toBeFalsy();
}
const rejected = pm.trigger('infantry_wall', { x: 400, y: 100 }, { zone: 2 });
expect(rejected.rejected).toBe(true);
});
it('zone governor enforces Z3 max 4 active patterns', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
for (let i = 0; i < 4; i++) {
const r = pm.trigger('infantry_wall', { x: 100 + i * 50, y: 100 }, { zone: 3 });
expect(r.rejected).toBeFalsy();
}
const rejected = pm.trigger('infantry_wall', { x: 500, y: 100 }, { zone: 3 });
expect(rejected.rejected).toBe(true);
});
it('zone governor releases slot when all projectiles are recycled', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const r1 = pm.trigger('infantry_wall', { x: 100, y: 100 }, { zone: 1 });
const r2 = pm.trigger('infantry_wall', { x: 200, y: 200 }, { zone: 1 });
expect(pm.trigger('infantry_wall', { x: 300, y: 300 }, { zone: 1 }).rejected).toBe(true);
for (const p of r1.projectiles) {
pm.killProjectile(p);
}
const r3 = pm.trigger('infantry_wall', { x: 300, y: 300 }, { zone: 1 });
expect(r3.rejected).toBeFalsy();
});
// =======================================================================
// 4. Stagger / overlap prevention — uses faked Date + setTimeout (req 5)
// =======================================================================
it('enforces >=0.5s offset between overlapping pattern starts', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
// Use a controllable clock — bypass Date.now() weirdness with fake timers
let clock = 0;
pm._now = () => clock;
// First trigger at t=0
const r1 = pm.trigger('infantry_wall', { x: 100, y: 100 }, { zone: 1 });
expect(r1.staggered).toBeFalsy();
expect(r1.fired).toBe(true);
// Advance 200ms, trigger again — within the 500ms stagger window
clock = 200;
const r2 = pm.trigger('infantry_wall', { x: 200, y: 200 }, { zone: 1 });
expect(r2.staggered).toBe(true);
expect(r2.delayed).toBe(true);
expect(r2.fired).toBe(false);
// Advance past the timeout — r2 fires
jest.advanceTimersByTime(300);
expect(r2.fired).toBe(true);
expect(r2.count).toBe(40);
});
it('stagger does not apply across different zones', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const r1 = pm.trigger('infantry_wall', { x: 100, y: 100 }, { zone: 1 });
expect(r1.staggered).toBeFalsy();
jest.advanceTimersByTime(100);
const r2 = pm.trigger('infantry_wall', { x: 200, y: 100 }, { zone: 2 });
expect(r2.staggered).toBeFalsy();
});
// =======================================================================
// 5. Performance — 200+ projectiles (req 2)
// =======================================================================
it('can have 200 active projectiles on screen', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
let totalActive = 0;
for (let i = 0; i < 5; i++) {
const r = pm.trigger('infantry_wall', {
x: 100 + i * 100,
y: 100 + i * 30,
}); // no zone = no governor
if (r && !r.rejected) {
totalActive += r.count;
}
}
expect(totalActive).toBeGreaterThanOrEqual(200);
expect(pm.group.getTotalUsed()).toBeGreaterThanOrEqual(200);
expect(pm.group.getLength()).toBeLessThanOrEqual(200);
});
it('pool never exceeds maxSize 200 under extreme spawning', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
for (let wave = 0; wave < 50; wave++) {
const r = pm.trigger('infantry_wall', {
x: 100 + (wave % 10) * 50,
y: 100 + (wave % 5) * 40,
}, { zone: 3 });
if (r && r.projectiles) {
for (let i = 0; i < Math.min(20, r.projectiles.length); i++) {
pm.killProjectile(r.projectiles[i]);
}
}
}
expect(pm.group.getLength()).toBeLessThanOrEqual(200);
});
// =======================================================================
// 6. Safe zone verification — every pattern has provable safe zone (req 6)
// =======================================================================
it('infantry_wall pattern has documented safe zone', () => {
const pattern = patternsModule.patterns.infantry_wall;
expect(pattern).toBeDefined();
expect(pattern.safeZone).toBeDefined();
expect(typeof pattern.safeZone).toBe('string');
expect(pattern.safeZone.length).toBeGreaterThan(10);
});
it('tank_destroyer_beam pattern has documented safe zone', () => {
const pattern = patternsModule.patterns.tank_destroyer_beam;
expect(pattern).toBeDefined();
expect(pattern.safeZone).toBeDefined();
expect(typeof pattern.safeZone).toBe('string');
expect(pattern.safeZone.length).toBeGreaterThan(10);
});
it('artillery_ring pattern has documented safe zone', () => {
const pattern = patternsModule.patterns.artillery_ring;
expect(pattern).toBeDefined();
expect(pattern.safeZone).toBeDefined();
expect(typeof pattern.safeZone).toBe('string');
expect(pattern.safeZone.length).toBeGreaterThan(10);
});
it('heli_sweep pattern has documented safe zone', () => {
const pattern = patternsModule.patterns.heli_sweep;
expect(pattern).toBeDefined();
expect(pattern.safeZone).toBeDefined();
expect(typeof pattern.safeZone).toBe('string');
expect(pattern.safeZone.length).toBeGreaterThan(10);
});
// =======================================================================
// 7. trigger result structure (req 3)
// =======================================================================
it('trigger result includes patternId, count, projectiles, timing metadata', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const result = pm.trigger('infantry_wall', { x: 400, y: 300 });
expect(result.patternId).toBe('infantry_wall');
expect(result.count).toBe(40);
expect(Array.isArray(result.projectiles)).toBe(true);
expect(result.staggered).toBeDefined();
expect(result.rejected).toBeDefined();
});
it('telegraph fires with correct timing from pattern definition', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
pm.trigger('infantry_wall', { x: 400, y: 300 });
expect(pm._telegraphFired).toBe(true);
expect(pm._spawnDelay).toBe(1500);
});
// =======================================================================
// 8. killProjectile and outOfBounds produce recyclable sprites (req 8)
// =======================================================================
it('killProjectile and outOfBounds both produce recyclable sprites', () => {
const scene = createMockScene();
const pm = new PatternManager(scene);
const r1 = pm.trigger('infantry_wall', { x: 100, y: 100 });
pm.killProjectile(r1.projectiles[0]);
expect(r1.projectiles[0].active).toBe(false);
expect(r1.projectiles[0].visible).toBe(false);
pm.outOfBounds(r1.projectiles[1]);
expect(r1.projectiles[1].active).toBe(false);
expect(r1.projectiles[1].visible).toBe(false);
const recycled = pm.group.getFirstDead(true, 500, 500, 'projectile');
expect(recycled).toBeDefined();
expect(recycled.x).toBe(500);
expect(recycled.y).toBe(500);
expect(recycled.active).toBe(true);
expect(recycled.visible).toBe(true);
});
});