Newer
Older
rpg / v2 / packages / engine / src / combat / __tests__ / primitives.test.ts
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);
    });
});