- 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
442 lines
14 KiB
JavaScript
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);
|
|
});
|
|
});
|