Newer
Older
rpg / v2 / apps / game / tools / checks / sky.mjs
/**
 * Гейт неба (трек «Небо: солнце, луна, звёзды, градиент»): на улицах
 * skyInfo включён (солнце+луна), в изо небо не видно (кадр бит-в-бит при
 * setSky(false) — регрессия изо-гейтов), first — градиент зенит/горизонт,
 * диск солнца в полдень, звёзды ночью (яркие точки в тёмном небе), луна
 * ночью с фазой (skyInfo.phase = формула), день/ночь различаются,
 * облачность темнит день и гасит звёзды, интерьер без неба.
 * Скриншоты: /tmp/v2_sky_*.png.
 *
 * Грабли детерминизма (как в camera.mjs): setAsh(false) + setClock ПЕРЕД
 * КАЖДЫМ кадром (firstShot пинит сам — иначе часы стоят и светила не там)
 * + stepTicks(1); мерцание звёзд идёт от времени цикла (fogTime), которое
 * пинится тем же тиком.
 *
 * Грабли камеры: градиент живёт на высотах 0..33° — на pitch −1.2 весь кадр
 * зенит; проверка градиента — pitch −0.5 (верх кадра ~зенит, низ ~горизонт).
 * Звёзды/облачность считаем на камере yaw 1.6 — от обоих светил (в полдень
 * солнце в +Z, в полночь там же луна; тост-оверлей занимает x<430, y<52 —
 * все замеры неба правее x=480).
 *
 * Запуск: node apps/game/tools/checks/sky.mjs [--port 5338]
 */
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 ?? 5338);

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

/** Число различающихся пикселей двух кадров (порог 8 на канал). */
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;
}

const W = 960, H = 540;

/** Средняя яркость прямоугольника кадра (в пикселях канваса). */
function meanLuma(img, x0, y0, x1, y1) {
    let s = 0, n = 0;
    for (let y = y0; y < y1; y++) {
        for (let x = x0; x < x1; x++) {
            const i = (y * W + x) * 4;
            s += (img[i] * 0.3 + img[i + 1] * 0.6 + img[i + 2] * 0.1);
            n++;
        }
    }
    return s / n;
}

/** Число ярких пикселей (все каналы > thr) в прямоугольнике — звёзды/диски.
 *  Порог 180: после пост-цепочки максимум белого диска ~191 (не 255). */
function countBright(img, x0, y0, x1, y1, thr = 180) {
    let n = 0;
    for (let y = y0; y < y1; y++) {
        for (let x = x0; x < x1; x++) {
            const i = (y * W + x) * 4;
            if (img[i] > thr && img[i + 1] > thr && img[i + 2] > thr) n++;
        }
    }
    return n;
}

/** Правая половина неба без тост-оверлея (x<430, y<52). */
const SKY_X0 = 480, SKY_X1 = 920, SKY_Y0 = 20, SKY_Y1 = 320;

async function shot(client, path) {
    await client.screenshot(path);
    return decodePng(readFileSync(path)).rgba;
}

/** Взгляд из первого лица (pitch < 0 — вверх) + кадр: часы пинятся всегда. */
async function firstShot(client, path, yaw, pitch, minutes = 720) {
    await client.call('setCameraMode', 'first', { yaw, pitch });
    await client.call('setClock', minutes);
    await client.call('stepTicks', 1);
    return shot(client, path);
}

// Фаза луны по часам — та же формула, что движка (moonPhase(hours, 8))
const phaseAt = (hours) => (1 - Math.cos(2 * Math.PI * ((hours / 24) % 8) / 8)) / 2;

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('setFauna', false);
        await client.call('setFollow', false);
        await client.evaluate(() => { document.getElementById('fps').hidden = true; });

        // ---- Небо на улице: включено, солнце+луна ----
        await client.call('setClock', 720);
        await client.call('stepTicks', 1);
        const si = await client.call('skyInfo');
        check('луга: небо включено, 2 тела (солнце+луна)',
            si !== null && si.enabled === true && si.bodyCount === 2
            && si.stars === true && Math.abs(si.hours - 12) < 0.01, si);

        // ---- Градиент: зенит ≠ горизонт (first, pitch −0.5: верх кадра
        //      ~зенит, низ ещё небо у горизонта; камера от светил) ----
        const grad = await firstShot(client, '/tmp/v2_sky_gradient.png', 1.6, -0.5);
        const top = meanLuma(grad, SKY_X0, SKY_Y0, SKY_X1, 110);
        const mid = meanLuma(grad, SKY_X0, 230, SKY_X1, 330);
        check('градиент неба: зенит (верх) темнее горизонта (середина)',
            mid - top > 8, { top: Math.round(top), mid: Math.round(mid) });

        // ---- Диск солнца в полдень (yaw на светило, elev ≈ 1.167) ----
        const sun = await firstShot(client, '/tmp/v2_sky_sun.png', -Math.PI / 2, -1.167);
        const sunPx = countBright(sun, 280, 90, 680, 450);
        check('диск солнца: яркие пиксели при взгляде на светило',
            sunPx > 100, { sunPx });
        check('солнце меняет кадр против градиента без светила',
            countDiff(sun, grad) > 100, { diff: countDiff(sun, grad) });

        // ---- Ночь: темнее дня, звёзды — яркие точки (камера от светил:
        //      в полночь луна стоит ровно в направлении полуденного солнца) ----
        const night = await firstShot(client, '/tmp/v2_sky_night.png', 1.6, -0.5, 0);
        check('ночь темнее дня (та же камера)', countDiff(night, grad) > 5000,
            { diff: countDiff(night, grad) });
        const dayStars = countBright(grad, SKY_X0, SKY_Y0, SKY_X1, 320);
        const nightStars = countBright(night, SKY_X0, SKY_Y0, SKY_X1, 320);
        check('звёзды ночью: яркие точки в тёмном небе (днём их нет)',
            nightStars > 30 && dayStars < 5, { nightStars, dayStars });

        // ---- Луна ночью: фаза по формуле, диск виден ----
        // 22:00: moonK = 1/3 (az 2π/3, elev ≈ 1.027), фаза ≈ 0.124
        const moon = await firstShot(client, '/tmp/v2_sky_moon.png', -Math.PI / 3, -1.027, 1320);
        const si2 = await client.call('skyInfo');
        const expPhase = phaseAt(22);
        const gotPhase = si2.bodies[1].phase;
        check('луна: фаза из формулы (22:00 ≈ 0.124), интенсивность ночи',
            Math.abs(gotPhase - expPhase) < 0.005 && si2.bodies[1].intensity === 1,
            { gotPhase, expPhase, intensity: si2.bodies[1].intensity });
        const moonPx = countBright(moon, 280, 90, 680, 450);
        check('луна: диск рисуется (яркие пиксели в направлении тела)',
            moonPx > 50, { moonPx });

        // ---- Облачность: темнит день (та же камера) и гасит звёзды ночью ----
        await client.call('setWeather', { cloud: 1 });
        await client.call('stepTicks', 30); // разгон cloudK к цели
        const dayCloudy = await firstShot(client, '/tmp/v2_sky_daycloudy.png', 1.6, -0.5);
        const cloudyNight = await firstShot(client, '/tmp/v2_sky_cloudy.png', 1.6, -0.5, 0);
        const cloudyStars = countBright(cloudyNight, SKY_X0, SKY_Y0, SKY_X1, 320);
        const dayLuma = meanLuma(grad, SKY_X0, SKY_Y0, SKY_X1, 320);
        const cloudyLuma = meanLuma(dayCloudy, SKY_X0, SKY_Y0, SKY_X1, 320);
        check('облачность темнит день и гасит звёзды',
            cloudyLuma < dayLuma && cloudyStars * 2 < nightStars,
            { dayLuma: Math.round(dayLuma), cloudyLuma: Math.round(cloudyLuma),
              nightStars, cloudyStars });
        await client.call('setWeather', null); // база области дальше
        await client.call('stepTicks', 1);

        // ---- Изо-регрессия: небо в изо не видно — кадр бит-в-бит ----
        await client.call('setCameraMode', 'iso');
        await client.call('setFollow', false);
        const isoRef = await stableShotRef(client, '/tmp/v2_sky_iso.png');
        await client.call('setSky', false);
        const isoNoSky = await stableShotRef(client, '/tmp/v2_sky_iso_nosky.png');
        check('изо: кадр с небом и без попиксельно равен (небо в изо не видно)',
            countDiff(isoRef, isoNoSky) === 0, { diff: countDiff(isoRef, isoNoSky) });
        await client.call('setSky', true);
        await client.call('stepTicks', 1);

        // ---- Интерьер без неба ----
        await client.call('gotoArea', 'house_elder');
        await client.call('stepTicks', 60); // полный фейд out+in (0.4 с × 2)
        const siIn = await client.call('skyInfo');
        check('интерьер (дом старейшины): неба нет', siIn === null, siIn);
    } finally {
        await browser.close();
    }
} catch (e) {
    console.error(e);
    report.fatal = String(e);
}

/** Кадр в изо в детерминированном состоянии (полдень, один тик). */
async function stableShotRef(client, path) {
    await client.call('setClock', 720);
    await client.call('stepTicks', 1);
    return shot(client, path);
}

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 — выходим явно