import { describe, expect, it } from 'vitest'; import { ENEMY_TYPES } from '../../app/static/js/config.js'; import { attackStep, broodStep, createAttackState, createBrain, flankVelocity, leaperStep, swarmVelocity, updateStrafe, volleyAngles, } from '../../app/static/js/logic/enemyAi.js'; import { degToRad, distance, length } from '../../app/static/js/logic/math.js'; import { createRng } from '../../app/static/js/logic/rng.js'; const player = { x: 0, y: 0 }; describe('createBrain', () => { it('verzögert den ersten Angriff und ist reproduzierbar', () => { const a = createBrain(createRng(1), 1000, { firstAttackDelayMs: 800 }); const b = createBrain(createRng(1), 1000, { firstAttackDelayMs: 800 }); expect(a).toEqual(b); expect(a.attack.nextAttackAt).toBeGreaterThanOrEqual(1400); expect(a.attack.nextAttackAt).toBeLessThan(2200); }); }); describe('swarmVelocity', () => { const type = ENEMY_TYPES.krabbler; it('steuert aus der Distanz den eigenen Umzingelungsplatz an', () => { const brain = { slotAngle: Math.PI / 2 }; // Platz unterhalb des Spielers const v = swarmVelocity(brain, { x: 800, y: 0 }, player, 100, type); // Ziel ist (0, encircleRadius): nach links UND nach unten. expect(v.x).toBeLessThan(0); expect(v.y).toBeGreaterThan(0); }); it('greift in Reichweite direkt an', () => { const v = swarmVelocity({ slotAngle: Math.PI / 2 }, { x: 100, y: 0 }, player, 100, type); expect(v.x).toBeCloseTo(-100); expect(v.y).toBeCloseTo(0); }); }); describe('leaperStep', () => { const { leap } = ENEMY_TYPES.sprungkralle; it('verfolgt außerhalb der Sprungreichweite', () => { const brain = createBrain(createRng(1), 0); const r = leaperStep(brain, { x: 1000, y: 0 }, player, 0, 120, leap); expect(r.telegraph).toBe(false); expect(r.velocity.x).toBeCloseTo(-120); }); it('durchläuft Ansetzen → Sprung → Abklingzeit', () => { const brain = createBrain(createRng(1), 0); const self = { x: 200, y: 0 }; const windup = leaperStep(brain, self, player, 0, 120, leap); expect(windup).toMatchObject({ telegraph: true, leaping: false, velocity: { x: 0, y: 0 } }); // Spieler bewegt sich während des Ansetzens: Die Sprungrichtung bleibt fest. const moved = { x: 0, y: 500 }; const jump = leaperStep(brain, self, moved, leap.windupMs, 120, leap); expect(jump.leaping).toBe(true); expect(jump.velocity.x).toBeCloseTo(-leap.speed); expect(jump.velocity.y).toBeCloseTo(0); const landed = leaperStep(brain, self, player, leap.windupMs + leap.durationMs, 120, leap); expect(landed.leaping).toBe(false); expect(landed.telegraph).toBe(false); // Während der Abklingzeit kein neuer Sprung, obwohl in Reichweite. const cooling = leaperStep(brain, self, player, leap.windupMs + leap.durationMs + 10, 120, leap); expect(cooling.telegraph).toBe(false); const again = leaperStep(brain, self, player, leap.windupMs + leap.durationMs + leap.cooldownMs, 120, leap); expect(again.telegraph).toBe(true); }); }); describe('flankVelocity', () => { it('schwenkt seitlich ein statt frontal anzulaufen', () => { const type = ENEMY_TYPES.blechsoldat; const self = { x: 600, y: 0 }; const left = flankVelocity({ flankSide: 1 }, self, player, 80, type); const right = flankVelocity({ flankSide: -1 }, self, player, 80, type); expect(Math.sign(left.y)).not.toBe(Math.sign(right.y)); expect(length(left)).toBeCloseTo(80); }); it('hält an der Flankenposition Abstand zum Spieler', () => { const type = ENEMY_TYPES.blechsoldat; const angle = degToRad(type.flankAngleDeg); // Genau an der Flankenposition relativ zu sich selbst gibt es keinen festen Punkt, // daher: sehr nah am Spieler → muss zurückweichen oder seitlich ausweichen. const self = { x: Math.cos(angle) * 100, y: Math.sin(angle) * 100 }; const v = flankVelocity({ flankSide: 1 }, self, player, 80, type); expect(distance({ x: self.x + v.x, y: self.y + v.y }, player)).toBeGreaterThanOrEqual(distance(self, player) - 1); }); }); describe('updateStrafe', () => { it('wechselt die Richtung nach Ablauf des Intervalls', () => { const brain = { strafeDirection: 1, nextStrafeSwitchAt: 100 }; updateStrafe(brain, 50, { min: 1000, max: 1000 }, createRng(1)); expect(brain.strafeDirection).toBe(1); updateStrafe(brain, 100, { min: 1000, max: 1000 }, createRng(1)); expect(brain.strafeDirection).toBe(-1); expect(brain.nextStrafeSwitchAt).toBe(1100); }); }); describe('attackStep', () => { const attack = { cooldownMs: 1000, burst: 3, burstIntervalMs: 100, maxDistance: 400 }; it('feuert nicht außerhalb der Reichweite', () => { const state = createAttackState(0); expect(attackStep(state, 0, 401, attack)).toBe(false); }); it('feuert eine Salve mit Abständen und wartet dann die Abklingzeit ab', () => { const state = createAttackState(0); const shots = []; for (let t = 0; t <= 1200; t += 50) { if (attackStep(state, t, 100, attack)) shots.push(t); } expect(shots).toEqual([0, 100, 200, 1000, 1100, 1200]); }); it('respektiert die Verzögerung des ersten Angriffs', () => { const state = createAttackState(500); expect(attackStep(state, 499, 100, attack)).toBe(false); expect(attackStep(state, 500, 100, attack)).toBe(true); }); }); describe('volleyAngles', () => { it('liefert bei einem Projektil genau die Zielrichtung', () => { expect(volleyAngles(1.2, 1, 40)).toEqual([1.2]); }); it('verteilt einen Fächer symmetrisch um die Zielrichtung', () => { const angles = volleyAngles(0, 5, 40); expect(angles).toHaveLength(5); expect(angles[0]).toBeCloseTo(-degToRad(20)); expect(angles[2]).toBeCloseTo(0); expect(angles[4]).toBeCloseTo(degToRad(20)); }); }); describe('broodStep', () => { const brood = { intervalMs: 1000, spawnCount: 3, maxChildren: 5 }; it('brütet im Takt und respektiert die Obergrenze', () => { const brain = { nextBroodAt: 0 }; expect(broodStep(brain, 0, 0, brood)).toBe(3); expect(broodStep(brain, 500, 3, brood)).toBe(0); expect(broodStep(brain, 1000, 3, brood)).toBe(2); expect(broodStep(brain, 2000, 5, brood)).toBe(0); expect(broodStep(brain, 3000, 9, brood)).toBe(0); }); });