Newer
Older
rpg / apps / game / tools / checks / lighting.mjs
/**
 * Сценарий 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 { withChecks } 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 }) {
    return withChecks(
        'lighting',
        async (t) => {
            const { c } = t;
            await c.run('луга в полдень: дневной ambient, источников нет', async () => {
                await t.boot();
                await t.ctx.agent.command('scene:setTime', { hours: 12 });
                const w = await t.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 t.ctx.agent.command('scene:setTime', { hours: 21 });
                const w = await t.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 t.goToArea('zvenets', { x: 26, y: 14 });
                await t.ctx.agent.command('scene:setTime', { hours: 21 }); // пин: ходьба ест время
                const s = await t.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 t.ctx.agent.tapTile(7, 6); // дверь дома — клик-переход
                const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 });
                c.expect(w.ok, 'не вошли в дом Ирвина');
                await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 });
                const s = await t.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 shot = await t.ctx.agent.snapshot();
                    const h = shot.lighting.sources.find((l) => l.id === 'hearth_low');
                    if (h) seen.add(Math.round(h.intensity * 100));
                    await t.ctx.agent.step(6);
                }
                c.expect(seen.size >= 3, `мерцание очага не видно (вариантов ${seen.size})`, [...seen]);
                return null;
            });
            await c.run('лампа героя: следует за телепортом', async () => {
                const v1 = lightVec(await t.ctx.agent.snapshot(), 'hearth_low', 'lamp');
                await t.ctx.agent.command('scene:teleport', { x: 2, y: 2 });
                await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 });
                const v2 = lightVec(await t.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 t.ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' });
                const w = await t.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 t.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 t.ctx.agent.command('scene:damagePlayer', { value: 3 });
                const w = await t.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 () => {
                await t.expectInvariantsClean('при работающем свете');
                return null;
            });
        },
        { pretty }
    );
}