Newer
Older
rpg / v2 / apps / game / tools / checks / atmosphere.mjs
/**
 * Гейт атмосферы v2 (трек 18 «время суток, освещение, музыка»): часы суток
 * (1 сек = 1 игровая минута), кривая ночи по часам, точечные источники
 * по флагам/времени/области (bells, окна, очаг, лампа героя), мерцание,
 * пепел как живой слой кадра, тема области с кроссфейдом. Скриншоты:
 * /tmp/v2_atmosphere_*.png.
 *
 * Запуск: node apps/game/tools/checks/atmosphere.mjs [--port 5320]
 */
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs';
import { decodePng } from '@rpg/engine/tools/png.mjs';

const args = parseArgs(process.argv.slice(2));
const port = Number(args.port ?? 5320);

const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'atmosphere', ok: false, checks: [] };
const check = (title, ok, info = null) => {
    report.checks.push({ title, ok, info });
    console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};

/** Средняя яркость кадра 0..255 (день/ночь сравнение). */
function brightness(rgba) {
    let sum = 0, n = 0;
    for (let i = 0; i < rgba.length; i += 4) {
        sum += rgba[i] * 0.3 + rgba[i + 1] * 0.6 + rgba[i + 2] * 0.1;
        n++;
    }
    return sum / n;
}

function countDiff(a, b) {
    let n = 0;
    for (let i = 0; i < a.length; i += 4) {
        if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++;
    }
    return n;
}

try {
    await server.waitReady;
    const browser = await launchBrowser();
    try {
        const client = await openGame(browser, { url: `http://localhost:${port}/` });
        await client.call('startGame', false); // страница стартует в меню (меню/автосейв)
        const errs = client.errors();
        check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3));

        await client.call('setManual', true);
        await client.call('setAsh', false); // кадр-равенства — мир без пепла
        await client.call('setClock', 480); // страница грузилась в реальном времени
        await client.call('stepTicks', 5);

        // ---- Часы суток ----
        let ci = await client.call('clockInfo');
        check('старт: 8:00, ночь 0 (день)', ci.label === '8:00' && ci.minutes === 480 && ci.nightFactor === 0, ci);

        await client.call('setClock', 1140); // 19:00 — половина заката
        ci = await client.call('clockInfo');
        check('19:00 — фактор ночи 0.5 (золотой час)', ci.label === '19:00' && Math.abs(ci.nightFactor - 0.5) < 0.01, ci);

        await client.call('setClock', 1134); // 18:54 → пик золотого часа (0.45)
        ci = await client.call('clockInfo');
        check('18:54 — пик золотого часа 0.45', Math.abs(ci.nightFactor - 0.45) < 0.01, ci);

        await client.call('setClock', 1200); // 20:00 — ночь
        ci = await client.call('clockInfo');
        check('20:00 — ночь (фактор 1)', ci.label === '20:00' && ci.nightFactor === 1, ci);

        await client.call('setClock', 1495); // > суток: mod 1440 → 0:55
        ci = await client.call('clockInfo');
        check('перемотка mod 1440: 0:55', ci.label === '0:55' && ci.minutes === 55, ci);

        // ---- Ночные источники лугов (bells) + лампа героя ----
        await client.call('setClock', 480); // назад в день
        let li = await client.call('lightingInfo');
        check('день на лугах: источников нет (bells whenNight)', li.sources.length === 0, li);

        await client.call('setClock', 1200); // ночь
        li = await client.call('lightingInfo');
        check('ночь на лугах: колокольчики светятся (bells@тайлы)',
            li.sources.some((s) => s.id === 'bells' && s.intensity > 0), li);
        check('ночь: лампа героя у героя', li.sources.some((s) => s.id === 'hero_lamp'), li);

        // Мерцание: интенсивность bells меняется со временем (flicker 0.15)
        const i0 = li.sources.find((s) => s.id === 'bells')?.intensity ?? -1;
        let i1 = i0;
        for (let k = 0; k < 4 && i1 === i0; k++) {
            await client.call('stepTicks', 9);
            li = await client.call('lightingInfo');
            i1 = li.sources.find((s) => s.id === 'bells')?.intensity ?? -1;
        }
        check('мерцание: интенсивность пульсирует', i1 !== i0, { i0, i1 });

        // ---- Кадр дня против ночи (фон и свет гаснут) ----
        await client.call('setFollow', false);
        await client.call('setClock', 720); // полдень
        await client.call('stepTicks', 1);
        const dayShot = await client.screenshot('/tmp/v2_atmosphere_day.png');
        await client.call('setClock', 1320); // 22:00 — глухая ночь
        await client.call('stepTicks', 1);
        const nightShot = await client.screenshot('/tmp/v2_atmosphere_night.png');
        const dayBright = brightness(decodePng(readFileSync(dayShot)).rgba);
        const nightBright = brightness(decodePng(readFileSync(nightShot)).rgba);
        check('кадр ночи темнее кадра дня', nightBright < dayBright * 0.85, { dayBright, nightBright });

        // ---- Пепел: живой слой кадра ----
        await client.call('setClock', 720);
        await client.call('setAsh', true);
        await client.call('stepTicks', 1);
        const shotNoAsh = await client.screenshot('/tmp/v2_atmosphere_ash0.png');
        await client.call('setAsh', true);
        await client.call('stepTicks', 40); // пепел дрейфует с тиками
        const shotAsh = await client.screenshot('/tmp/v2_atmosphere_ash1.png');
        const ashDiff = countDiff(decodePng(readFileSync(shotNoAsh)).rgba, decodePng(readFileSync(shotAsh)).rgba);
        check('пепел дрейфует: кадры различаются', ashDiff > 100, { ashDiff });

        // ---- Источники по флагам: очаг, башня ----
        await client.call('setAsh', false);
        await client.call('gotoArea', 'house_elder');
        li = await client.call('lightingInfo');
        check('очаг до сдачи: hearth_low тлеет', li.sources.some((s) => s.id === 'hearth_low'), li);
        await client.call('setState', { flags: ['quest_bells_done'] });
        await client.call('stepTicks', 1);
        li = await client.call('lightingInfo');
        check('после сдачи: hearth_high горит, hearth_low погас',
            li.sources.some((s) => s.id === 'hearth_high') && !li.sources.some((s) => s.id === 'hearth_low'), li);

        await client.call('gotoArea', 'zvenets');
        await client.call('stepTicks', 1);
        li = await client.call('lightingInfo');
        check('башня без звонка: отсвета нет', !li.sources.some((s) => s.id === 'tower_resonance'), li);
        await client.call('setState', { flags: ['rang_tower'] });
        await client.call('stepTicks', 1);
        li = await client.call('lightingInfo');
        check('после звонка: холодный отсвет башни', li.sources.some((s) => s.id === 'tower_resonance'), li);

        // ---- Тема области ----
        await client.call('gotoArea', 'meadows'); // назад на луга (тема области)
        await client.call('stepTicks', 1);
        let ai = await client.call('audioInfo');
        check('луга: тема meadows играет', ai.theme === 'meadows' && ai.played.some((p) => p.key === 'theme/meadows'), ai);
        await client.call('gotoArea', 'house_elder');
        await client.call('stepTicks', 1);
        ai = await client.call('audioInfo');
        check('интерьер: темы нет (тишина, как в v1)', ai.theme === null, ai);
    } finally {
        await browser.close();
    }
} catch (e) {
    console.error(e);
    report.fatal = String(e);
}

report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));
process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно