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);
});
});