/**
* Сценарий lighting — освещение сцены через мост: ambient областей, день/ночь
* по внутриигровому времени (scene:setTime), окна Звенца, очаг дома Ирвина
* (флаг квеста), лампа героя, мерцание, виньетка при низком hp.
* Перед каждым ambient-ассертом время пинуется — реальный темп (1 с = 1 мин)
* не должен влиять на результат.
* 1) Луга, полдень: светлый дневной ambient, источников нет;
* 2) 21:00: ночная цель ambient лугов, лампа героя на светлой днём улице;
* 3) Звенец ночью: ночная цель деревни, три окна горят, башня молчит;
* 4) Дом Ирвина: тёмный интерьер, hearth_low, лампа; мерцание очага гуляет;
* 5) телепорт внутри дома — лампа следует за героем (разность векторов);
* 6) quest_bells_done: hearth_high вместо hearth_low и строго ярче;
* 7) урон герою до hp 2 — виньетка темнеет (полный боевой путь);
* 8) инварианты чисты.
* Запуск: node tools/agent.mjs run tools/checks/lighting.mjs
*/
import { startDevServer, openGame, Checks } from '../lib.mjs';
/** Ambient областей (те же константы, что в data/locations.ts). */
const AMB = {
meadows: 0xd8dade,
meadowsNight: 0x6a7288,
zvenets: 0xdad6d0,
zvenetsNight: 0x7b8298,
house_elder: 0x54586a
};
/** Экранное смещение источника b относительно a (px). */
function lightVec(s, a, b) {
const x = s.lighting.sources.find((l) => l.id === a);
const y = s.lighting.sources.find((l) => l.id === b);
return x && y ? { x: y.x - x.x, y: y.y - x.y } : null;
}
export default async function ({ pretty }) {
const c = new Checks('lighting');
const server = await startDevServer();
let ctx;
try {
await c.run('луга в полдень: дневной ambient, источников нет', async () => {
ctx = await openGame({ url: server.url, newGame: true });
await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 });
await ctx.agent.command('scene:setTime', { hours: 12 });
const w = await ctx.agent.waitFor(
`s.lighting.ambient === ${AMB.meadows} && s.lighting.sources.length === 0`,
{ timeoutTicks: 300 }
);
c.expect(w.ok, 'днём луга не светлы или есть источники', w.snapshot?.lighting);
c.expect(w.snapshot.time.night === 0, 'в полдень фактор ночи не нулевой', w.snapshot.time);
return null;
});
await c.run('ночь на лугах (21:00): ночная цель ambient, лампа героя', async () => {
await ctx.agent.command('scene:setTime', { hours: 21 });
const w = await ctx.agent.waitFor(
`s.lighting.ambient === ${AMB.meadowsNight} && s.lighting.sources.some((l) => l.id === "lamp")`,
{ timeoutTicks: 300 }
);
c.expect(w.ok, 'ночью луга не потемнели или лампа не зажглась', w.snapshot?.lighting);
c.expect(w.snapshot.time.night === 1, 'в 21:00 фактор ночи не единичный', w.snapshot.time);
return null;
});
await c.run('Звенец ночью: ночная цель, три окна, башня молчит', async () => {
await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 });
const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 });
c.expect(w.ok, 'не пришли в Звенец');
await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 });
await ctx.agent.command('scene:setTime', { hours: 21 }); // пин: ходьба ест время
const s = await ctx.agent.snapshot();
c.expect(s.lighting.ambient === AMB.zvenetsNight, 'ambient Звенца не ночной', s.lighting);
const windows = s.lighting.sources.filter((l) => l.id.startsWith('window_'));
c.expect(windows.length === 3, 'ночью должно гореть три окна домов', s.lighting.sources);
c.expect(!s.lighting.sources.some((l) => l.id === 'tower_resonance'), 'башня светит без звонка', s.lighting.sources);
c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'ночью на улице нет лампы героя', s.lighting.sources);
return null;
});
await c.run('дом Ирвина: тёмный интерьер, hearth_low, лампа героя', async () => {
await ctx.agent.tapTile(7, 6); // дверь дома — клик-переход
const w = await ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 });
c.expect(w.ok, 'не вошли в дом Ирвина');
await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 });
const s = await ctx.agent.snapshot();
c.expect(s.lighting.ambient === AMB.house_elder, 'ambient интерьера не тёмный', s.lighting);
c.expect(s.lighting.sources.some((l) => l.id === 'hearth_low'), 'нет тлеющего очага', s.lighting.sources);
c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_high'), 'очаг горит ровно до сдачи квеста', s.lighting.sources);
c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'в тёмном доме нет лампы героя', s.lighting.sources);
// Мерцание: серия снапшотов — интенсивность очага гуляет (с допуском).
const seen = new Set();
for (let i = 0; i < 8; i++) {
const t = await ctx.agent.snapshot();
const h = t.lighting.sources.find((l) => l.id === 'hearth_low');
if (h) seen.add(Math.round(h.intensity * 100));
await ctx.agent.step(6);
}
c.expect(seen.size >= 3, `мерцание очага не видно (вариантов ${seen.size})`, [...seen]);
return null;
});
await c.run('лампа героя: следует за телепортом', async () => {
const v1 = lightVec(await ctx.agent.snapshot(), 'hearth_low', 'lamp');
await ctx.agent.command('scene:teleport', { x: 2, y: 2 });
await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 });
const v2 = lightVec(await ctx.agent.snapshot(), 'hearth_low', 'lamp');
c.expect(v1 !== null && v2 !== null, 'нет лампы или очага в снапшоте', { v1, v2 });
// Очаг статичен: v2 - v1 — чистое смещение лампы (камера двигает оба поровну).
const shift = Math.hypot(v2.x - v1.x, v2.y - v1.y);
c.expect(shift > 60, `лампа не переехала за героем (смещение ${shift.toFixed(0)} px)`, { v1, v2 });
return null;
});
await c.run('сдача квеста: hearth_high вместо hearth_low и ярче', async () => {
await ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' });
const w = await ctx.agent.waitFor('s.lighting.sources.some((l) => l.id === "hearth_high")', {
timeoutTicks: 300
});
c.expect(w.ok, 'после сдачи квеста очаг не разгорелся', w.snapshot.lighting.sources);
const s = await ctx.agent.snapshot();
c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_low'), 'тлеющий очаг не погас', s.lighting.sources);
const high = s.lighting.sources.find((l) => l.id === 'hearth_high');
// Минимум hearth_high (1.05·0.8) выше максимума hearth_low (0.75).
c.expect(high.intensity > 0.84 - 0.02, 'hearth_high не ярче тлеющего', high);
return null;
});
await c.run('виньетка: урон до hp 2 — края экрана темнеют', async () => {
await ctx.agent.command('scene:damagePlayer', { value: 3 });
const w = await ctx.agent.waitFor('s.lighting.vignette > 0.29', { timeoutTicks: 300 });
c.expect(w.ok, 'виньетка не загорелась при hp 2', w.snapshot?.lighting);
return null;
});
await c.run('инварианты чисты при работающем свете', async () => {
const inv = await ctx.agent.invariants();
const bad = inv.filter((i) => i.severity === 'error');
c.expect(bad.length === 0, 'ошибки инвариантов', bad);
return null;
});
} finally {
await ctx?.browser?.close();
server.stop();
}
return c.finish({ pretty }).ok ? 0 : 1;
}