import { describe, expect, it } from 'vitest';
import { angleBetween, horDist, horNorm, inCircle, inCone } from '../shapes';
import { hasLineOfSight } from '../los';
import { PlayerCombat } from '../playerCombat';
import { PLAYER_COMBAT } from '../stats';
import { ENEMY_KINDS, withDefaults } from '../enemies';
describe('shapes (горизонталь x/z)', () => {
it('дистанция и нормаль в плоскости x/z', () => {
expect(horDist({ x: 0, z: 0 }, { x: 3, z: 4 })).toBe(5);
expect(horNorm({ x: 0, z: 0 }, { x: 3, z: 0 })).toEqual({ x: 1, z: 0 });
expect(horNorm({ x: 1, z: 1 }, { x: 1, z: 1 })).toEqual({ x: 0, z: 0 });
});
it('круг включает границу', () => {
expect(inCircle({ x: 0, z: 0 }, 2, { x: 2, z: 0 })).toBe(true);
expect(inCircle({ x: 0, z: 0 }, 2, { x: 2.01, z: 0 })).toBe(false);
});
it('конус: дальность и полуугол по мировым осям', () => {
const from = { x: 0, z: 0 };
const dir = { x: 1, z: 0 };
// В пределах дальности и угла
expect(inCone(from, dir, 3, Math.PI / 4, { x: 2, z: 1 })).toBe(true);
// За дальностью
expect(inCone(from, dir, 3, Math.PI / 4, { x: 3.1, z: 0 })).toBe(false);
// Вне полуугла (90°)
expect(inCone(from, dir, 3, Math.PI / 4, { x: 0, z: 2 })).toBe(false);
// Нулевое направление — круг без проверки угла
expect(inCone(from, { x: 0, z: 0 }, 1, 0.01, { x: 0.9, z: 0.4 })).toBe(true);
});
it('угол между векторами', () => {
expect(angleBetween({ x: 1, z: 0 }, { x: 0, z: 1 })).toBeCloseTo(Math.PI / 2);
expect(angleBetween({ x: 1, z: 0 }, { x: 2, z: 0 })).toBeCloseTo(0);
});
});
describe('LOS по сетке', () => {
/** Непрозрачна одна колонка (3, 2). */
const opaque = (tx: number, tz: number) => tx === 3 && tz === 2;
it('чистая прямая видна', () => {
expect(hasLineOfSight({ x: 0, z: 0.5 }, { x: 6, z: 0.5 }, opaque)).toBe(true);
});
it('колонка на пути перекрывает', () => {
expect(hasLineOfSight({ x: 0, z: 0.5 }, { x: 6, z: 0.5 }, () => true)).toBe(false);
expect(hasLineOfSight({ x: 0, z: 2 }, { x: 6, z: 2 }, opaque)).toBe(false);
});
it('короткие отрезки и нулевая длина видимы', () => {
expect(hasLineOfSight({ x: 1, z: 1 }, { x: 1.2, z: 1 }, opaque)).toBe(true);
expect(hasLineOfSight({ x: 1, z: 1 }, { x: 1, z: 1 }, opaque)).toBe(true);
});
it('нулевая дистанция видна', () => {
expect(hasLineOfSight({ x: 1, z: 1 }, { x: 1, z: 1 }, () => true)).toBe(true);
});
});
describe('PlayerCombat', () => {
it('короткий удар: кулдаун триггерится, повтор до кулдауна — null', () => {
const p = new PlayerCombat();
p.startCharge();
expect(p.release()).toBe('attack');
p.startCharge();
expect(p.release()).toBe(null); // кулдаун 0.45 ещё не прошёл
p.update(0.5);
p.startCharge();
expect(p.release()).toBe('attack');
});
it('удержание 0.6 с — резонанс со своим кулдауном', () => {
const p = new PlayerCombat();
p.startCharge();
p.update(0.65);
expect(p.release()).toBe('resonance');
p.startCharge();
p.update(0.65);
expect(p.release()).toBe(null); // резонанс перезаряжается 3 с
p.update(3);
p.startCharge();
p.update(0.65);
expect(p.release()).toBe('resonance');
});
it('урон: неуязвимость после удара, кламп к hp, смерть', () => {
const p = new PlayerCombat();
expect(p.takeDamage(1)).toBe(1);
expect(p.invuln).toBe(true);
expect(p.takeDamage(1)).toBe(0); // неуязвим
p.update(PLAYER_COMBAT.invulnTime + 0.1);
expect(p.takeDamage(99)).toBe(4); // кламп до остатка hp
expect(p.dead).toBe(true);
p.revive();
expect(p.hp).toBe(PLAYER_COMBAT.maxHp);
expect(p.dead).toBe(false);
});
});
describe('данные врагов', () => {
it('withDefaults подставляет все поля ИИ', () => {
const k = withDefaults(ENEMY_KINDS.crawler);
for (const f of ['aggroRange', 'deaggroSec', 'fleeBelowHpFraction', 'fleeRange', 'hearRadius', 'warySec'] as const) {
expect(k[f]).toBeDefined();
}
// Явное значение не перезаписывается
expect(withDefaults(ENEMY_KINDS.spitter).aggroRange).toBe(5);
expect(withDefaults(ENEMY_KINDS.crawler).hearRadius).toBe(3.5);
});
});