Newer
Older
rpg / v2 / apps / game / tools / checks / weather.mjs
/**
 * Гейт погоды v2 (трек «погода в движке»): база погоды по областям, ручной
 * режим (дождь/снег рисуются и падают, облачность темнит кадр, туман — слои
 * по дальности с дрейфом и глубинным градиентом), кадр без осадков, сброс к
 * погоде области, управление из дев-панели. Скриншоты: /tmp/v2_weather_*.png.
 *
 * Запуск: node apps/game/tools/checks/weather.mjs [--port 5330]
 */
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 ?? 5330);

const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'weather', 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 bandBrightness(rgba, width, y0, y1) {
    let sum = 0, n = 0;
    for (let y = y0; y < y1; y++) for (let x = 0; x < width; x++) {
        const i = (y * width + x) * 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;
}

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

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('setClock', 720); // полдень — облачность мерить на ровном дне
        await client.call('stepTicks', 5);

        // ---- База погоды области ----
        let wi = await client.call('weatherInfo');
        check('луга: база — лёгкий ветер, облачность 0.15, без осадков',
            wi.windStrength === 0.15 && wi.cloud === 0.15 && wi.rain === 0 && wi.snow === 0 && wi.manual === null, wi);

        await client.call('gotoArea', 'old_siding');
        await client.call('stepTicks', 60); // фейд перехода 0.4с — дождаться конца
        wi = await client.call('weatherInfo');
        check('разъезд: база — продувает (ветер 0.6, дождь 0.15)',
            wi.windStrength === 0.6 && wi.cloud === 0.35 && wi.rain === 0.15, wi);

        // ---- Ручной ливень: рисуется и падает ----
        await client.call('setWeather', { rain: 1, snow: 0, cloud: 0, windStrength: 0 });
        await client.call('stepTicks', 3);
        wi = await client.call('weatherInfo');
        check('ручной ливень: стена капель (плотность на окно), снег 0, правка активна',
            wi.rainDrops > 1000 && wi.snowFlakes === 0 && wi.manual !== null, wi);
        const rainA = await shot(client, '/tmp/v2_weather_rain.png');
        await client.call('stepTicks', 30); // стена дождя падает (93 юнит/с)
        const rainB = await shot(client, '/tmp/v2_weather_rain2.png');
        const rainDiff = countDiff(rainA, rainB);
        check('дождь падает: кадры различаются', rainDiff > 100, { rainDiff });

        // ---- Снег: медленный, рисуется и падает ----
        await client.call('setWeather', { rain: 0, snow: 1, cloud: 0, windStrength: 0 });
        await client.call('stepTicks', 3);
        wi = await client.call('weatherInfo');
        check('ручной снег: снежинки плотностью на окно, дождь 0',
            wi.snowFlakes > 500 && wi.rainDrops === 0, wi);
        const snowA = await shot(client, '/tmp/v2_weather_snow.png');
        await client.call('stepTicks', 40); // снег медленный — шаг подлиннее
        const snowB = await shot(client, '/tmp/v2_weather_snow2.png');
        const snowDiff = countDiff(snowA, snowB);
        check('снег падает: кадры различаются', snowDiff > 100, { snowDiff });

        // ---- Облачность темнит кадр (день) ----
        await client.call('gotoArea', 'meadows');
        await client.call('stepTicks', 60); // фейд перехода — дождаться конца
        await client.call('setWeather', { rain: 0, snow: 0, cloud: 0, windStrength: 0 });
        await client.call('stepTicks', 2);
        const clearBright = brightness(await shot(client, '/tmp/v2_weather_clear.png'));
        await client.call('setWeather', { cloud: 1 });
        await client.call('stepTicks', 3);
        const cloudBright = brightness(await shot(client, '/tmp/v2_weather_cloud.png'));
        // порог 0.93: зенитный свет пасмурного дня (OVERCAST_TOP_LIGHT —
        // «свет переотражается, словно есть доп. свет прямо сверху») добавляет
        // освещённости, и кадр темнит слабее, чем до зенита (~0.89 против ~0.75)
        check('облачность темнит кадр (полная — заметно темнее)',
            cloudBright < clearBright * 0.93, { clearBright, cloudBright });

        // ---- Туман: слои по дальности, дрейф, глубинный градиент ----
        await client.call('setWeather', { rain: 0, snow: 0, cloud: 0, fog: 0, windStrength: 0.6 });
        await client.call('stepTicks', 2);
        const clear = await shot(client, '/tmp/v2_weather_fog0.png');
        await client.call('setWeather', { fog: 1 });
        await client.call('stepTicks', 3);
        const foggy = await shot(client, '/tmp/v2_weather_fog1.png');
        const fogDiff = countDiff(clear, foggy);
        check('туман рисуется: кадры без/с туманом различаются', fogDiff > 20000, { fogDiff });
        const W = decodePng(readFileSync('/tmp/v2_weather_fog0.png')).width;
        // разница «с туманом − без» по строкам: даль (верх) должен посветлеть
        // сильнее близи (низ) — слои копятся с глубиной
        const gradTop = bandBrightness(foggy, W, 0, 150) - bandBrightness(clear, W, 0, 150);
        const gradBottom = bandBrightness(foggy, W, 400, 540) - bandBrightness(clear, W, 400, 540);
        check('туман гуще вдали: верх кадра светлее низа на разнице',
            gradTop > gradBottom + 5, { gradTop, gradBottom });
        await client.call('stepTicks', 60); // ветер 0.6 гонит клубы
        const foggy2 = await shot(client, '/tmp/v2_weather_fog1b.png');
        const drift = countDiff(foggy, foggy2);
        check('туман дрейфует с ветром: кадры различаются', drift > 5000, { drift });
        await client.call('setClock', 0); // ночь — туман красится env-светом
        await client.call('stepTicks', 5);
        const nightFog = brightness(await shot(client, '/tmp/v2_weather_fog_night.png'));
        check('ночь: туман тёмный (env-свет), кадр не выбелен',
            nightFog < 90 && nightFog < brightness(foggy), { nightFog });
        await client.call('setClock', 720);
        await client.call('setWeather', null); // снять ручную правку перед молнией

        // ---- Молния: разряд + вспышка с тенями, гаснет ----
        await client.call('setWeather', { rain: 0.6, cloud: 0.6, fog: 0, windStrength: 0.6 });
        await client.call('stepTicks', 2);
        const lBefore = brightness(await shot(client, '/tmp/v2_lightning_before.png'));
        await client.call('makeLightning');
        await client.call('stepTicks', 2); // внутри окна вспышки (0.55 с)
        let li = await client.call('lightningInfo');
        check('молния: вспышка активна (свет + разряд)', li.active === true && li.flash > 0.3, li);
        const lFlash = brightness(await shot(client, '/tmp/v2_lightning_flash.png'));
        check('молния: кадр светлее во вспышку', lFlash > lBefore * 1.08, { lBefore, lFlash });
        await client.call('stepTicks', 60); // вспышка 0.55 с — погасла
        li = await client.call('lightningInfo');
        const lAfter = brightness(await shot(client, '/tmp/v2_lightning_after.png'));
        check('молния: погасла, кадр вернулся', !li.active && li.flash === 0 && lAfter < lFlash * 0.95,
            { lAfter, lFlash });
        // ночь: вспышка — главный свет кадра, читается драматичнее
        await client.call('setClock', 0);
        await client.call('stepTicks', 5);
        const nBefore = brightness(await shot(client, '/tmp/v2_lightning_night0.png'));
        await client.call('makeLightning');
        await client.call('stepTicks', 2);
        const nFlash = brightness(await shot(client, '/tmp/v2_lightning_night1.png'));
        check('молния ночью: вспышка освещает кадр', nFlash > nBefore * 1.4, { nBefore, nFlash });
        // тени вспышки: направление света постоянно (наклон от точки удара
        // не зависит), так что честный чек — сравнение кадра вспышки с
        // тенями и без (setLightningShadows(false)): тёмные пиксели (тень =
        // один ambient) в земной трети кадра исчезают вместе с shadow map
        await client.call('setWeather', { rain: 0, snow: 0, cloud: 0.4, fog: 0, windStrength: 0.2 });
        await client.call('stepTicks', 3);
        const sw = await client.call('windowInfo', 0, 40);
        const scx = (sw.minX + sw.maxX) / 2, scz = (sw.minZ + sw.maxZ) / 2;
        // тёмные пиксели нижней (земной) трети кадра
        const groundDarks = (rgba) => {
            let n = 0;
            for (let y = 360; y < 540; y++) for (let x = 0; x < W; x++) {
                const i = (y * W + x) * 4;
                if (rgba[i] * 0.3 + rgba[i + 1] * 0.6 + rgba[i + 2] * 0.1 < 40) n++;
            }
            return n;
        };
        await client.call('makeLightning', [scx, 0, scz]);
        await client.call('stepTicks', 2);
        const darksOn = groundDarks(await shot(client, '/tmp/v2_lightning_shadow_on.png'));
        await client.call('setLightningShadows', false);
        await client.call('stepTicks', 60); // вспышка догорела
        await client.call('makeLightning', [scx, 0, scz]);
        await client.call('stepTicks', 2);
        const darksOff = groundDarks(await shot(client, '/tmp/v2_lightning_shadow_off.png'));
        await client.call('setLightningShadows', true); // вернуть для следующих чеков
        check('молния: тени кастеров реально рисуются (гаснут с castShadow)',
            darksOn > darksOff + 150, { darksOn, darksOff });
        await client.call('setClock', 720);
        await client.call('setWeather', null); // снять ручную правку перед рощей

        // ---- setAsh(false): гейт-режим глушит и осадки с облачностью ----
        await client.call('setWeather', null); // ручная правка переживает смену области — снять
        await client.call('gotoArea', 'rust_grove'); // база рощи — морось 0.3
        await client.call('stepTicks', 60); // фейд перехода — дождаться конца
        wi = await client.call('weatherInfo');
        check('роща: база — морось (rain 0.3, облачность 0.5)',
            wi.rain === 0.3 && wi.cloud === 0.5 && wi.manual === null, wi);
        await client.call('setAsh', false);
        wi = await client.call('weatherInfo');
        check('setAsh(false): осадки и облачность глушатся (кадр-равенства)',
            wi.rainDrops === 0 && wi.snowFlakes === 0 && wi.cloud === 0, wi);

        // ---- Сброс возвращает базу области ----
        await client.call('setWeather', { rain: 0.9, windStrength: 1 });
        await client.call('setWeather', null);
        wi = await client.call('weatherInfo');
        check('сброс: вернулась база рощи, правок панели нет',
            wi.rain === 0.3 && wi.cloud === 0.5 && wi.manual === null, wi);

        // ---- Дев-панель: слайдер дождя реально управляет погодой ----
        await client.call('setAsh', true); // вернуть атмосферу после гейт-режима
        await client.call('devOpen');
        const hasSliders = await client.evaluate(() =>
            ['dev-w-cloud', 'dev-w-rain', 'dev-w-snow', 'dev-w-wind', 'dev-w-dir', 'dev-w-fog']
                .every((id) => document.getElementById(id) !== null));
        check('дев-панель: секция погоды с 6 слайдерами (включая туман)', hasSliders === true);
        // слайдер тумана → input-событие → ручная правка активна
        await client.evaluate(() => {
            const el = document.getElementById('dev-w-fog');
            el.value = '0.7';
            el.dispatchEvent(new Event('input'));
        });
        wi = await client.call('weatherInfo');
        check('дев-панель: слайдер тумана включает ручной режим', wi.manual?.fog === 0.7, wi);
        await client.call('devSetWeather', null); // сброс + синхронизация слайдеров
        // слайдер дождя → input-событие → ручная правка активна
        await client.evaluate(() => {
            const el = document.getElementById('dev-w-rain');
            el.value = '1';
            el.dispatchEvent(new Event('input'));
        });
        wi = await client.call('weatherInfo');
        check('дев-панель: слайдер дождя включает ручной режим', wi.manual?.rain === 1, wi);
        await client.call('devSetWeather', null); // сброс + синхронизация слайдеров
        wi = await client.call('weatherInfo');
        check('дев-панель: сброс вернул базу области', wi.manual === null && wi.windStrength === 0.2, wi);
        // кнопка молнии: клик → счётчик ударов растёт
        const hasBolt = await client.evaluate(() => document.getElementById('dev-w-lightning') !== null);
        check('дев-панель: кнопка молнии', hasBolt === true);
        const s0 = (await client.call('lightningInfo')).strikes;
        await client.evaluate(() => document.getElementById('dev-w-lightning').click());
        const s1 = (await client.call('lightningInfo')).strikes;
        check('дев-панель: кнопка молнии бьёт (счётчик +1)', s1 === s0 + 1, { s0, s1 });
        await client.call('devClose');
    } 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 — выходим явно