/**
* Сценарий ai — ИИ врагов (батч 4).
* 1) тяжеловес с патрулём стартует бодрым в patrol и реально ходит;
* 2) тихий шум (0.35) при далёком герое — настороженность (wary);
* 3) не увидел героя — wary гаснет, возврат к дому и снова patrol;
* 4) герой рядом с LOS — погоня (chase);
* 5) герой далеко — потеря погони (return -> patrol);
* 6) мало hp — отступление бегом (flee), дистанция растёт;
* 7) инварианты (enemy-state-legal, enemy-in-bounds) чисты.
* Запуск: node tools/agent.mjs run tools/checks/ai.mjs
*/
import { startDevServer, openGame, Checks } from '../agent-lib.mjs';
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
/** Тяжеловес из снапшота. */
const heavy = (s) => (s.enemies ?? []).find((e) => e.kind === 'heavy');
export default async function ({ pretty }) {
const c = new Checks('ai');
const server = await startDevServer();
let ctx;
try {
await c.run('патруль: тяжеловес бодр и ходит по маршруту', async () => {
ctx = await openGame({ url: server.url, newGame: true });
const s0 = await ctx.agent.snapshot();
const h0 = heavy(s0);
c.expect(h0?.state === 'patrol', 'тяжеловес не в patrol на старте', h0);
// Спавн (6,22), патруль к (4,20): позиция меняется без участия героя.
const w = await ctx.agent.waitFor(
'(s.enemies.find((e) => e.kind === "heavy")?.pos.x ?? 9) < 6',
{ timeoutTicks: 600 }
);
c.expect(w.ok, 'тяжеловес не сдвинулся с места в патруле', w.snapshot.enemies);
return null;
});
await c.run('тихий шум настораживает патрульного', async () => {
// Герой далеко (спавн 14,14), шум у маршрута тяжеловеса.
await ctx.agent.command('scene:noise', { x: 5, y: 21, level: 0.35 });
const w = await ctx.agent.waitFor(
's.enemies.find((e) => e.kind === "heavy")?.state === "wary"',
{ timeoutTicks: 300 }
);
c.expect(w.ok, 'тяжеловес не насторожился от шума', w.snapshot.enemies);
return null;
});
await c.run('настороженность гаснет: wary -> return -> patrol', async () => {
const w = await ctx.agent.waitFor(
's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"',
{ timeoutTicks: 900 }
);
c.expect(w.ok, 'тяжеловес не вернулся к патрулю', w.snapshot.enemies);
return null;
});
await c.run('герой рядом — погоня', async () => {
await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 });
await ctx.agent.command('scene:teleport', { x: 5, y: 22 });
const w = await ctx.agent.waitFor(
's.enemies.find((e) => e.kind === "heavy")?.state === "chase"',
{ timeoutTicks: 300 }
);
c.expect(w.ok, 'тяжеловес не погнался за героем', w.snapshot.enemies);
return null;
});
await c.run('герой далеко — погоня гаснет и враг уходит домой', async () => {
await ctx.agent.command('scene:teleport', { x: 14, y: 14 });
const w = await ctx.agent.waitFor(
's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"',
{ timeoutTicks: 900 }
);
c.expect(w.ok, 'тяжеловес не отступил к дому', w.snapshot.enemies);
return null;
});
await c.run('мало hp — отступление бегом', async () => {
await ctx.agent.command('scene:teleport', { x: 6, y: 21 });
const dmg = await ctx.agent.command('scene:damageEnemy', { id: 'heavy', value: 6 });
c.expect(dmg === true, 'scene:damageEnemy не нашла тяжеловеса');
const w = await ctx.agent.waitFor(
's.enemies.find((e) => e.kind === "heavy")?.state === "flee"',
{ timeoutTicks: 300 }
);
c.expect(w.ok, 'тяжеловес не отступает при низком hp', w.snapshot.enemies);
// Держится подальше: дистанция до неподвижного героя растёт.
const s1 = await ctx.agent.snapshot();
const d1 = dist(heavy(s1).pos, s1.hero.pos);
await ctx.agent.waitFor('false', { timeoutTicks: 60 }); // 1 сек наблюдения
const s2 = await ctx.agent.snapshot();
const d2 = dist(heavy(s2).pos, s2.hero.pos);
c.expect(d2 > d1, 'отступающий не отдаляется', { d1, d2 });
return null;
});
await c.run('инварианты ИИ чисты', async () => {
const inv = await ctx.agent.invariants();
const errors = (inv ?? []).filter((i) => i.severity === 'error');
c.expect(errors.length === 0, 'инварианты нарушены', errors);
return null;
});
} finally {
await ctx?.browser?.close().catch(() => {});
await server.stop();
}
return c.finish({ pretty }).ok ? 0 : 1;
}