import { describe, expect, it } from 'vitest';
import { ENEMY_KINDS, withDefaults } from '../enemies';
import { EnemyBrain, NOISE_HEAR_LEVEL, type EnemySenses } from '../brain';
/** Тик ИИ на 1/60 с; возвращает последнее намерение. */
const run = (b: EnemyBrain, seconds: number, senses: EnemySenses): ReturnType<EnemyBrain['update']> => {
let intent: ReturnType<EnemyBrain['update']> = { type: 'idle' };
for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses);
return intent;
};
/** Прогон с ловлей конкретного намерения (strike/shoot выдаются одним тиком). */
const sawIntent = (b: EnemyBrain, seconds: number, senses: EnemySenses, type: string): boolean => {
for (let i = 0; i < Math.round(seconds * 60); i++) {
if (b.update(1 / 60, senses).type === type) return true;
}
return false;
};
const away: EnemySenses = { dist: 3.1, dirToPlayer: { x: 0, z: 1 } };
const close: EnemySenses = { dist: 0.25, dirToPlayer: { x: 1, z: 0 } };
/** Разбудить и дождаться погони (спит → rise 0.6 с → chase). */
const wakeUp = (b: EnemyBrain, senses: EnemySenses): void => {
b.hearLoud();
run(b, 0.7, senses);
};
describe('EnemyBrain (ползун)', () => {
it('стартует спящим в пепле и не двигается', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
expect(b.asleep).toBe(true);
expect(b.update(1 / 60, close).type).toBe('idle');
});
it('гулкий звук поднимает: rise → chase', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
b.hearLoud();
expect(b.state).toBe('rise');
run(b, 0.7, away);
expect(b.state).toBe('chase');
// Вдали от героя — движение к нему
expect(run(b, 0.05, away)).toEqual({ type: 'move', dir: { x: 0, z: 1 } });
});
it('в радиусе атаки бьёт контактом по кулдауну', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
wakeUp(b, away);
expect(sawIntent(b, 0.1, close, 'strike')).toBe(true);
// Кулдаун: в следующее окно удара нет, после — снова есть
expect(sawIntent(b, 0.1, close, 'strike')).toBe(false);
run(b, ENEMY_KINDS.crawler.attackCooldown, away);
expect(sawIntent(b, 0.1, close, 'strike')).toBe(true);
});
it('урон станит и возвращает в погоню', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
wakeUp(b, away);
b.hurt();
expect(b.state).toBe('hurt');
expect(b.update(1 / 60, away).type).toBe('idle');
run(b, 0.3, away);
expect(b.state).toBe('chase');
});
it('резонанс усыпляет на заданное время, потом просыпается', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
wakeUp(b, away);
b.putToSleep(4);
expect(b.asleep).toBe(true);
expect(run(b, 2, away).type).toBe('idle');
expect(b.asleep).toBe(true);
run(b, 2.2, away);
expect(b.state).toBe('rise'); // проснулся — сначала подъём
run(b, 0.7, away);
expect(b.state).toBe('chase');
expect(b.asleep).toBe(false);
});
it('смерть окончательна: повторный die возвращает false', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
expect(b.die()).toBe(true);
expect(b.dead).toBe(true);
expect(b.die()).toBe(false);
// Урон и сон мёртвого не трогают
b.hurt();
b.putToSleep(4);
expect(b.dead).toBe(true);
});
});
describe('EnemyBrain (тяжёлый)', () => {
it('перед ударом есть замах 0.6 с', () => {
const b = new EnemyBrain(ENEMY_KINDS.heavy);
wakeUp(b, away);
// Сразу — только замах, урона нет
run(b, 0.05, close);
expect(b.state).toBe('windup');
run(b, 0.3, close);
expect(b.state).toBe('windup');
// Через 0.6 с замаха — удар и фаза атаки
run(b, 0.35, close);
expect(b.state).toBe('attack');
expect(sawIntent(b, 0.05, close, 'strike')).toBe(false); // уже прошёл
});
});
describe('EnemyBrain (плевун)', () => {
it('сближение — отходит, издали — идёт ближе', () => {
const b = new EnemyBrain(ENEMY_KINDS.spitter);
wakeUp(b, { dist: 3.1, dirToPlayer: { x: 1, z: 0 } });
expect(b.state).toBe('chase');
// Слишком близко к keepDistance — пятится
const back = run(b, 0.05, { dist: 1.3, dirToPlayer: { x: 1, z: 0 } });
expect(back.type).toBe('move');
if (back.type === 'move') expect(back.dir.x).toBeLessThan(0);
// Слишком далеко — подходит
const toward = run(b, 0.05, { dist: 3.8, dirToPlayer: { x: 0, z: 1 } });
expect(toward.type).toBe('move');
if (toward.type === 'move') expect(toward.dir.z).toBeGreaterThan(0);
});
it('на дистанции атаки — замах и плевок', () => {
const b = new EnemyBrain(ENEMY_KINDS.spitter);
wakeUp(b, { dist: 100, dirToPlayer: { x: 1, z: 0 } });
const mid: EnemySenses = { dist: 2.5, dirToPlayer: { x: 1, z: 0 } };
// Входим в радиус атаки — замах
run(b, 0.05, mid);
expect(b.state).toBe('windup');
// Через windup — плевок (ловим намерение в тик срабатывания)
expect(sawIntent(b, 0.45, mid, 'shoot')).toBe(true);
});
});
/** Патрульные точки в юнитах. */
const points = [
{ x: 10.5, z: 7.5 },
{ x: 12.5, z: 9.5 },
];
/** Сенсоры «герой далеко и не виден» (для патрульных сценариев). */
const far = (pos: { x: number; z: number }): EnemySenses => ({
dist: 30,
dirToPlayer: { x: 1, z: 0 },
pos,
canSee: false,
home: { x: 10.5, z: 7.5 },
hpFraction: 1,
});
describe('EnemyBrain: патруль', () => {
it('с патрулём стартует бодрым в patrol, без — спит в пепле', () => {
const patrolled = new EnemyBrain(withDefaults(ENEMY_KINDS.crawler), { points, pauseSec: 1 });
expect(patrolled.state).toBe('patrol');
expect(patrolled.asleep).toBe(false);
const sleeper = new EnemyBrain(ENEMY_KINDS.crawler);
expect(sleeper.state).toBe('dormant');
expect(sleeper.asleep).toBe(true);
});
it('дошёл до точки — пауза, потом идёт к следующей (ping-pong)', () => {
const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 });
// Стоим на точке 0 — сразу пауза
expect(b.update(1 / 60, far(points[0]!)).type).toBe('idle');
run(b, 1.1, far(points[0]!));
// Пауза истекла — движение к точке 1 (вниз-вправо)
const move = run(b, 0.05, far(points[0]!));
expect(move.type).toBe('move');
if (move.type === 'move') {
expect(move.dir.x).toBeCloseTo(0.707, 1);
expect(move.dir.z).toBeCloseTo(0.707, 1);
expect(move.run).toBeUndefined(); // патруль шагом, не бегом
}
// Дошли до точки 1 — снова пауза и разворот назад
run(b, 3.2, far(points[1]!));
const back = run(b, 0.05, far(points[1]!));
expect(back.type).toBe('move');
if (back.type === 'move') expect(back.dir.x).toBeLessThan(0);
});
it('шум уровня 0.35 вырывает из патруля в настороженность', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 });
const pos = { x: 10.5, z: 7.5 };
b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11.5, z: 8.5 }, level: NOISE_HEAR_LEVEL } });
expect(b.state).toBe('wary');
// Идёт к источнику шума
const move = b.update(1 / 60, { ...far(pos), noise: null });
expect(move.type).toBe('move');
});
it('тихий шум (ниже порога) не настораживает', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 });
b.update(1 / 60, { ...far(points[0]!), noise: { origin: { x: 11, z: 8 }, level: 0.2 } });
expect(b.state).toBe('patrol');
});
});
describe('EnemyBrain: настороженность', () => {
it('увидел героя в aggroRange — погоня', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 });
const pos = { x: 10.5, z: 7.5 };
b.update(1 / 60, { ...far(pos), noise: { origin: pos, level: 0.5 } });
expect(b.state).toBe('wary');
b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, z: 0 }, pos, canSee: true });
expect(b.state).toBe('chase');
});
it('герой не показался за warySec — возврат к дому', () => {
const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); // warySec 3
const pos = { x: 10.5, z: 7.5 };
b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11, z: 8 }, level: 0.5 } });
run(b, 1, { ...far({ x: 11, z: 8 }), noise: null });
expect(b.state).toBe('wary');
run(b, 2.2, { ...far({ x: 11, z: 8 }), noise: null });
expect(b.state).toBe('return');
// Идёт к дому и на месте — снова в патруль
run(b, 3, far({ x: 10.7, z: 7.7 }));
expect(b.state).toBe('patrol');
});
});
describe('EnemyBrain: погоня и отступление', () => {
it('потерял героя из виду дольше deaggroSec — возврат', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
b.hearLoud();
run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, z: 0 } });
expect(b.state).toBe('chase');
// Герой скрылся: 2.5 с без LOS — и враг уходит домой (позицию двигаем руками)
const pos = { x: 10, z: 10 };
for (let i = 0; i < Math.round(2.6 * 60); i++) {
b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, z: 0 }, canSee: false, pos, home: { x: 5, z: 5 } });
pos.x -= 0.007;
}
expect(b.state).toBe('return');
});
it('герой дальше 1.5 aggro — не гоняется по всей карте', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
b.hearLoud();
run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, z: 0 } });
run(b, 0.1, { dist: 7, dirToPlayer: { x: 1, z: 0 } }); // 1.5 * aggro 4 = 6
expect(b.state).toBe('return');
});
it('мало hp — бежит прочь бегом, оторвался — успокоился', () => {
const b = new EnemyBrain(withDefaults(ENEMY_KINDS.heavy)); // fleeBelowHpFraction 0.35
const chase: EnemySenses = { dist: 1, dirToPlayer: { x: 1, z: 0 } };
b.hearLoud();
run(b, 0.7, chase);
run(b, 0.1, { ...chase, hpFraction: 0.3 });
expect(b.state).toBe('flee');
const flee = b.update(1 / 60, { ...chase, hpFraction: 0.3 });
expect(flee.type).toBe('move');
if (flee.type === 'move') {
expect(flee.dir.x).toBe(-1); // прочь от героя
expect(flee.run).toBe(true);
}
// Герой отстал на fleeRange (4.5) — отбой в настороженность
run(b, 0.1, { dist: 4.6, dirToPlayer: { x: 1, z: 0 }, hpFraction: 0.3 });
expect(b.state).toBe('wary');
});
it('возврат без патруля — дошёл до дома и снова спит', () => {
const b = new EnemyBrain(ENEMY_KINDS.crawler);
b.hearLoud();
run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, z: 0 } });
run(b, 2.6, { dist: 3, dirToPlayer: { x: 1, z: 0 }, canSee: false, home: { x: 5, z: 5 } });
run(b, 5, { dist: 30, dirToPlayer: { x: 1, z: 0 }, canSee: false, pos: { x: 4.8, z: 4.8 }, home: { x: 5, z: 5 } });
expect(b.state).toBe('dormant');
expect(b.asleep).toBe(true);
});
});