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