Newer
Older
rpg / v2 / packages / engine / src / combat / __tests__ / world.test.ts
import { describe, expect, it } from 'vitest';
import { circleHitsSolid, CombatWorld, moveCircleHor, type CombatDeps, type CombatHooks } from '../world';
import type { Hor } from '../shapes';
import type { EnemyKindId } from '../enemies';

/** События хуков, накопленные за тест (в v1 — подписки на EventBus). */
interface HookLog {
    hurt: number;
    killed: EnemyKindId[];
    awake: EnemyKindId[];
    attacks: string[];
    spit: number;
}

/**
 * Фейковые зависимости боевого мира: герой в заданной точке, мир проходим
 * и прозрачен (переопределяется в тестах), урон и хуки копятся в журнал.
 */
function makeDeps(playerPos: Hor, over: Partial<CombatDeps> = {}): {
    deps: CombatDeps;
    damaged: { n: number; from: Hor }[];
    log: HookLog;
} {
    const damaged: { n: number; from: Hor }[] = [];
    const log: HookLog = { hurt: 0, killed: [], awake: [], attacks: [], spit: 0 };
    const hooks: CombatHooks = {
        onEnemyHurt: () => log.hurt++,
        onEnemyKilled: (id) => log.killed.push(id),
        onEnemyAwake: (id) => log.awake.push(id),
        onAttack: (mode) => log.attacks.push(mode),
        onSpit: () => log.spit++,
    };
    const deps: CombatDeps = {
        getPlayerPos: () => playerPos,
        damagePlayer: (n, from) => damaged.push({ n, from: { ...from } }),
        solid: () => false,
        opaque: () => false,
        hooks,
        ...over,
    };
    return { deps, damaged, log };
}

/** Прогон мира n секунд тиками 1/60. */
const run = (w: CombatWorld, seconds: number): void => {
    for (let i = 0; i < Math.round(seconds * 60); i++) w.update(1 / 60);
};

describe('движение круга по колонкам', () => {
    /** Единственная solid-колонка (2,1). */
    const solid = (cx: number, cz: number) => cx === 2 && cz === 1;

    it('вход в колонку зажимается к её грани (с обеих сторон)', () => {
        const pos: Hor = { x: 1.5, z: 1.5 };
        moveCircleHor(pos, { x: 1, z: 0 }, 0.2, solid);
        expect(pos.x).toBeCloseTo(1.799, 2); // грань x=2 минус радиус и зазор
        expect(circleHitsSolid(pos.x, pos.z, 0.2, solid)).toBe(false);

        const back: Hor = { x: 3.5, z: 1.5 };
        moveCircleHor(back, { x: -1, z: 0 }, 0.2, solid);
        expect(back.x).toBeCloseTo(3.201, 2);
    });

    it('скольжение вдоль стены: ось Z свободна при блоке по X', () => {
        const pos: Hor = { x: 1.5, z: 1.5 };
        moveCircleHor(pos, { x: 1, z: 0.5 }, 0.2, solid);
        expect(pos.x).toBeCloseTo(1.799, 2);
        expect(pos.z).toBeCloseTo(2.0, 5); // по Z прошёл свободно
    });

    it('погоня в стену останавливает сгустка у грани', () => {
        const player: Hor = { x: 5.5, z: 5.5 };
        const { deps } = makeDeps(player, { solid: (cx, cz) => cx === 4 && cz >= 4 && cz <= 6 });
        const w = new CombatWorld(deps);
        const e = w.spawnEnemy('crawler', { x: 2.5, z: 5.5 });
        w.noise(player, 1.0); // разбудить и начать погоню
        run(w, 3);
        const en = w.enemies.get(e)!;
        expect(en.pos.x).toBeLessThan(4); // не прошёл сквозь стену
        expect(en.pos.x).toBeGreaterThan(3.5); // но доехал до неё
    });
});

describe('CombatWorld: спавн и шум', () => {
    it('без патруля спит в пепле, с патрулём сразу бодр', () => {
        const { deps } = makeDeps({ x: 20, z: 20 });
        const w = new CombatWorld(deps);
        const sleeper = w.enemies.get(w.spawnEnemy('crawler', { x: 10.5, z: 10.5 }))!;
        expect(sleeper.brain.asleep).toBe(true);
        const patrol = w.enemies.get(
            w.spawnEnemy('crawler', { x: 12.5, z: 10.5 }, { patrol: { points: [{ x: 12, z: 10 }, { x: 14, z: 10 }], pauseSec: 1 } })
        )!;
        expect(patrol.brain.state).toBe('patrol');
        expect(patrol.brain.asleep).toBe(false);
    });

    it('шаги героя (0.35) не будят спящего, гулкий звон (1.0) будит', () => {
        const { deps, log } = makeDeps({ x: 20, z: 20 });
        const w = new CombatWorld(deps);
        const e = w.spawnEnemy('crawler', { x: 10.5, z: 10.5 });
        const en = w.enemies.get(e)!;

        w.noise({ x: 10.5, z: 10.5 }, 0.35); // шаги прямо рядом
        w.update(1 / 60);
        expect(en.brain.asleep).toBe(true);
        expect(log.awake).toEqual([]);

        w.noise({ x: 10.5, z: 10.5 }, 1.0); // гулкий удар
        expect(en.brain.asleep).toBe(false);
        expect(en.brain.state).toBe('rise');
        expect(log.awake).toEqual(['crawler']);
    });

    it('бодрый патрульный слышит тихий шум в hearRadius и настораживается', () => {
        const { deps } = makeDeps({ x: 20, z: 20 });
        const w = new CombatWorld(deps);
        const e = w.spawnEnemy('heavy', { x: 10.5, z: 10.5 }, {
            patrol: { points: [{ x: 10, z: 10 }, { x: 12, z: 12 }], pauseSec: 1 },
        });
        w.noise({ x: 11.5, z: 10.5 }, 0.35); // шаги в 1 юните — в hearRadius 4
        w.update(1 / 60);
        expect(w.enemies.get(e)!.brain.state).toBe('wary');
    });

    it('шум вне радиуса слуха не слышен', () => {
        const { deps } = makeDeps({ x: 20, z: 20 });
        const w = new CombatWorld(deps);
        const e = w.spawnEnemy('crawler', { x: 10.5, z: 10.5 }, {
            patrol: { points: [{ x: 10, z: 10 }, { x: 12, z: 12 }], pauseSec: 1 },
        });
        w.noise({ x: 18.5, z: 10.5 }, 0.5); // hearRadius ползуна 3.5, шум в 8 юнитах
        w.update(1 / 60);
        expect(w.enemies.get(e)!.brain.state).toBe('patrol');
    });

    it('инварианты боевого мира пусты на честном ИИ', () => {
        const { deps } = makeDeps({ x: 20, z: 20 });
        const w = new CombatWorld(deps);
        w.spawnEnemy('crawler', { x: 10.5, z: 10.5 }, {
            patrol: { points: [{ x: 10, z: 10 }, { x: 12, z: 12 }], pauseSec: 1 },
        });
        run(w, 10);
        expect(w.agentInvariants()).toEqual([]);
    });
});

describe('CombatWorld: удар конусом и смерть', () => {
    it('конус бьёт видимого врага, второй удар добивает, труп исчезает', () => {
        const player: Hor = { x: 1.5, z: 1.5 };
        const { deps, log } = makeDeps(player);
        const w = new CombatWorld(deps);
        const e = w.spawnEnemy('crawler', { x: 2.2, z: 1.5 }); // dist 0.7 ≤ coneRange 1.0
        const en = w.enemies.get(e)!;

        expect(w.playerConeAttack(player, { x: 1, z: 0 })).toBe(1);
        expect(en.hp).toBe(1); // 3 - 2
        expect(log.hurt).toBe(1);
        expect(log.attacks).toEqual(['cone']);
        expect(en.brain.dead).toBe(false);

        expect(w.playerConeAttack(player, { x: 1, z: 0 })).toBe(1);
        expect(en.brain.dead).toBe(true);
        expect(log.killed).toEqual(['crawler']);

        run(w, 0.6); // corpseTimer 0.5 истекает
        expect(w.enemies.size).toBe(0);
        expect(w.world.isAlive(e)).toBe(false);
    });

    it('звон не бьёт врага за непрозрачной колонкой', () => {
        const player: Hor = { x: 0.5, z: 0.5 };
        const { deps, log } = makeDeps(player, { opaque: (cx, cz) => cx === 1 && cz === 0 });
        const w = new CombatWorld(deps);
        const e = w.spawnEnemy('crawler', { x: 2.5, z: 0.5 }); // в конусе, но колонка (1,0) на линии
        expect(w.playerConeAttack(player, { x: 1, z: 0 })).toBe(0);
        expect(w.enemies.get(e)!.hp).toBe(3);
        expect(log.hurt).toBe(0);
    });
});

describe('CombatWorld: резонанс, удар врага, снаряды', () => {
    it('резонанс усыпляет в радиусе и только их', () => {
        const player: Hor = { x: 0, z: 0 };
        const { deps } = makeDeps(player);
        const w = new CombatWorld(deps);
        const near = w.enemies.get(w.spawnEnemy('crawler', { x: 2, z: 0 }))!;
        const farEnemy = w.enemies.get(w.spawnEnemy('crawler', { x: 3.5, z: 0 }))!;
        w.noise({ x: 0, z: 0 }, 1.0); // поднять обоих из пепла
        expect(w.resonancePulse(player)).toBe(1);
        expect(near.brain.asleep).toBe(true);
        expect(farEnemy.brain.asleep).toBe(false); // 3.5 > resonanceRange 3.0
    });

    it('тяжёлый доходит до радиуса атаки и бьёт контактом', () => {
        const player: Hor = { x: 10, z: 10 };
        const { deps, damaged } = makeDeps(player);
        const w = new CombatWorld(deps);
        w.spawnEnemy('heavy', { x: 10.7, z: 10 }); // dist 0.7 ≤ attackRange 0.8
        w.noise(player, 1.0);
        run(w, 1.5); // подъём 0.6 + замах 0.6 + удар
        expect(damaged.length).toBe(1);
        expect(damaged[0]!.n).toBe(2);
    });

    it('ползун догоняет героя и «липнет» на дистанции тел', () => {
        const player: Hor = { x: 10, z: 10 };
        const { deps } = makeDeps(player);
        const w = new CombatWorld(deps);
        const e = w.spawnEnemy('crawler', { x: 7, z: 10 });
        w.noise(player, 1.0);
        run(w, 8);
        const en = w.enemies.get(e)!;
        const d = Math.hypot(en.pos.x - player.x, en.pos.z - player.z);
        expect(d).toBeLessThan(0.6); // радиусы 0.35 + 0.2
        expect(d).toBeGreaterThan(0.4); // тела не слипаются
    });

    it('снаряд летит и попадает в героя', () => {
        const player: Hor = { x: 7, z: 5 };
        const { deps, damaged } = makeDeps(player);
        const w = new CombatWorld(deps);
        w.spawnProjectile({ x: 5, z: 5 }, { x: 1, z: 0 }, 1);
        run(w, 1.2); // 2 юнита на скорости 2.2
        expect(damaged.length).toBe(1);
        expect(damaged[0]!.n).toBe(1);
        expect(w.projectiles.size).toBe(0);
    });

    it('снаряд глохнет о непрозрачную колонку и не доходит до героя', () => {
        const player: Hor = { x: 7, z: 5 };
        const { deps, damaged } = makeDeps(player, { opaque: (cx, cz) => cx === 6 && cz === 5 });
        const w = new CombatWorld(deps);
        w.spawnProjectile({ x: 5, z: 5 }, { x: 1, z: 0 }, 1);
        run(w, 1);
        expect(damaged.length).toBe(0);
        expect(w.projectiles.size).toBe(0);
    });

    it('снаряд без попадания исчезает по сроку жизни', () => {
        const player: Hor = { x: 100, z: 100 };
        const { deps } = makeDeps(player);
        const w = new CombatWorld(deps);
        w.spawnProjectile({ x: 5, z: 5 }, { x: 1, z: 0 }, 1);
        run(w, 0.5);
        expect(w.projectiles.size).toBe(1); // ещё летит (жизнь 1.5 с)
        run(w, 1.2);
        expect(w.projectiles.size).toBe(0);
    });
});