Newer
Older
rpg / v2 / apps / game / tools / checks / areas.mjs
/**
 * Скриншот-гейт областей v2 (трек 15 «карты и области»): конвертация v1 →
 * мир детерминирована (пересбор той же области — тот же кадр), спавн лугов,
 * blocked-тайлы (вода/дерево сплошные, тропа нет; тело зажимается гранью),
 * step-переход входом на тайл-триггер (фейд out→swap→in), возврат 'return'
 * по стеку, click-переход через «E» с подсказкой. Скриншоты: /tmp/v2_areas_*.png.
 *
 * Запуск: node apps/game/tools/checks/areas.mjs [--port 5314]
 */
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 ?? 5314);

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

/** Тиков на полный переход (out+in) с запасом. */
const PASS = 70;
/** Тиков на проход через один тайл с запасом (4 вокс/с, тайл = 3 вокс). */
const CROSS = 120;

// ---- Раскладка v1 (maps.json — чистые данные, движок не импортируется) ----
const maps = JSON.parse(readFileSync(new URL('../../src/data/maps.json', import.meta.url), 'utf8'));

/** RLE → плоский массив тайлов (как в areas.ts). */
function tilesFlat(id) {
    const m = maps[id];
    const out = [];
    for (const [tid, run] of m.tiles) for (let k = 0; k < run; k++) out.push(tid);
    return out;
}

/** Первый тайл области с данным id → {x, y} (в тайлах v1). */
function findTile(id, tileId) {
    const m = maps[id];
    const flat = tilesFlat(id);
    for (let i = 0; i < flat.length; i++) {
        if (flat[i] === tileId) return { x: i % m.width, y: Math.floor(i / m.width) };
    }
    throw new Error(`${id}: тайл ${tileId} не найден`);
}

/** Воксельный центр тайла (как tileCenter в игре: tile*UNIT + 1.5). */
const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 });

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;
}
function isBlack(rgba) {
    let bright = 0, n = 0;
    for (let i = 0; i < rgba.length; i += 4) {
        n++;
        if (rgba[i] + rgba[i + 1] + rgba[i + 2] > 30) bright++;
    }
    return bright / n < 0.01;
}
/** Число различимых цветов (квантование 5 бит/канал). */
function colorCount(rgba) {
    const seen = new Set();
    for (let i = 0; i < rgba.length; i += 4) {
        seen.add((rgba[i] >> 3) + (rgba[i + 1] >> 3) * 32 + (rgba[i + 2] >> 3) * 1024);
    }
    return seen.size;
}

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('stepTicks', 10);
        // пепел — живая система (дрейф с тиками): в этом гейте мир без атмосферы
        await client.call('setAsh', false);
        // олени тоже живые (бродят в реальном времени до setManual) — без фауны
        await client.call('setFauna', false);
        await client.call('stepTicks', 1); // перерисовать кадр без пепла

        // Старт: луга, герой на спавне, меши собраны
        let ai = await client.call('areaInfo');
        check('старт: луга, герой на спавне (16,28), меши собраны',
            ai.id === 'meadows' && ai.heroTile.x === 16 && ai.heroTile.y === 28 && ai.meshes > 0,
            { id: ai.id, tile: ai.heroTile, meshes: ai.meshes });

        // Детерминизм сборки: пересбор той же области — тот же кадр
        const spawnC = center({ x: 16, y: 28 });
        await client.call('setFollow', false);
        await client.call('stepTicks', 1); // отрисовать кадр с фиксированной камерой (следящая — на float-цели)
        const shotA = await client.screenshot('/tmp/v2_areas_meadows.png');
        await client.call('gotoArea', 'meadows');
        await client.call('stepTicks', 5);
        const shotA2 = await client.screenshot('/tmp/v2_areas_meadows2.png');
        const detDiff = countDiff(decodePng(readFileSync(shotA)).rgba, decodePng(readFileSync(shotA2)).rgba);
        check('пересборка области детерминирована (кадры равны)', detDiff === 0, { detDiff });

        // Чистая мировая ось — комбинированный стик: при yaw π/4 экранное
        // (1,1) = +x мира (hero-гейт демки), (1,-1) = −z, (-1,1) = +z
        await client.call('heroTeleport', spawnC.x, spawnC.z);
        await client.call('setInput', 1, 1);
        await client.call('stepTicks', 10);
        const hiProbe = await client.call('heroInfo');
        await client.call('setInput', null, null);
        check('стик (1,1) — чистый +x мира (без дрейфа по z)',
            hiProbe.pos[0] - spawnC.x > 0.3 && Math.abs(hiProbe.pos[2] - spawnC.z) < 0.2,
            { dx: Math.round((hiProbe.pos[0] - spawnC.x) * 100) / 100,
              dz: Math.round((hiProbe.pos[2] - spawnC.z) * 100) / 100 });

        // blocked: дерево и вода сплошные (хоть одна под-колонка тайла),
        // тропа — ни одной сплошной под-колонки
        const solidTile = async (c) => {
            let n = 0;
            for (const dx of [-1, 0, 1]) for (const dz of [-1, 0, 1]) {
                if (await client.call('solidAt', c.x + dx, c.z + dz)) n++;
            }
            return n;
        };
        const tree = center(findTile('meadows', 3));
        const water = center(findTile('meadows', 2));
        const path = center(findTile('meadows', 1));
        const sTree = await solidTile(tree);
        const sWater = await solidTile(water);
        const sPath = await solidTile(path);
        check('blocked: дерево/вода сплошные, тропа проходима', sTree > 0 && sWater === 9 && sPath === 0,
            { tree: sTree, water: sWater, path: sPath });

        // Тело зажимается гранью воды (вода — сплошной слой на уровне ног)
        await client.call('heroTeleport', water.x - 3.6, water.z);
        await client.call('setInput', 1, 1);
        await client.call('stepTicks', CROSS);
        await client.call('setInput', null, null);
        const hiStop = await client.call('heroInfo');
        const gap = water.x - hiStop.pos[0]; // центр тела до центра воды
        check('тело героя останавливается у грани воды', gap > 2.6 && gap < 4.5,
            { gap: Math.round(gap * 10) / 10 });

        // Скриншоты областей: различаются и не пустые
        const areas = ['meadows', 'ponds', 'zvenets', 'rust_grove', 'old_siding', 'machine_hall', 'house_elder'];
        const shots = {};
        let minDiff = Infinity;
        let allColorful = true;
        for (const id of areas) {
            await client.call('gotoArea', id);
            await client.call('stepTicks', 5);
            const p = `/tmp/v2_areas_${id}.png`;
            const img = decodePng(readFileSync(await client.screenshot(p)));
            shots[id] = img.rgba;
            if (isBlack(img.rgba) || colorCount(img.rgba) < 8) allColorful = false;
        }
        for (let i = 1; i < areas.length; i++) {
            minDiff = Math.min(minDiff, countDiff(shots[areas[i - 1]], shots[areas[i]]));
        }
        check('скриншоты 7 областей: не пустые, различаются', allColorful && minDiff > 5000,
            { minDiff, allColorful });

        // step-переход: телепорт у триггера (54,28) → идти на него → Звенец
        await client.call('gotoArea', 'meadows');
        await client.call('stepTicks', 5);
        const trig = center({ x: 54, y: 28 });
        await client.call('heroTeleport', trig.x - 3, trig.z);
        await client.call('setInput', 1, 1);
        await client.call('stepTicks', CROSS);
        await client.call('setInput', null, null);
        await client.call('stepTicks', PASS);
        ai = await client.call('areaInfo');
        check('step-переход луга→Звенец (фейд, возврат запомнен)',
            ai.id === 'zvenets' && ai.returns === 1 && !ai.fade.active, { id: ai.id, returns: ai.returns });

        // Вторая пара step-переходов: луга (2,2) → пруды → обратно в луга
        await client.call('gotoArea', 'meadows');
        await client.call('stepTicks', 5);
        const pTrig = center({ x: 2, y: 2 });
        await client.call('heroTeleport', pTrig.x + 3, pTrig.z);
        await client.call('setInput', -1, -1);
        await client.call('stepTicks', CROSS);
        await client.call('setInput', null, null);
        await client.call('stepTicks', PASS);
        ai = await client.call('areaInfo');
        check('второй step-переход: луга→пруды', ai.id === 'ponds', ai.id);
        const pBack = center({ x: 3, y: 2 });
        await client.call('heroTeleport', pBack.x, pBack.z);
        await client.call('setInput', -1, -1);
        await client.call('stepTicks', CROSS);
        await client.call('setInput', null, null);
        await client.call('stepTicks', PASS);
        ai = await client.call('areaInfo');
        check('обратный переход пруды→луга', ai.id === 'meadows', ai.id);

        // return-переход: люк подземелья → зал → люк обратно
        await client.call('gotoArea', 'old_siding');
        await client.call('stepTicks', 5);
        const hatch = center({ x: 28, y: 31 });
        await client.call('heroTeleport', hatch.x - 3, hatch.z);
        await client.call('setInput', 1, 1);
        await client.call('stepTicks', CROSS);
        await client.call('setInput', null, null);
        await client.call('stepTicks', PASS);
        ai = await client.call('areaInfo');
        const wentDown = ai.id === 'machine_hall' && ai.returns === 1;
        check('переход в подземелье через люк (step, 0.6 с)', wentDown, { id: ai.id, returns: ai.returns });
        if (wentDown) {
            // запад/восток триггера (13,21) — стены; идём с севера (13,20), +z
            const hall = center({ x: 13, y: 20 });
            await client.call('heroTeleport', hall.x, hall.z);
            await client.call('setInput', -1, 1);
            await client.call('stepTicks', CROSS);
            await client.call('setInput', null, null);
            await client.call('stepTicks', PASS);
            ai = await client.call('areaInfo');
            check('возврат из подземелья по стеку (на тайле входа)',
                ai.id === 'old_siding' && ai.returns === 0, { id: ai.id, returns: ai.returns });
        }

        // click-переход: дверь дома Ирвина в Звенце — подсказка и «E»
        await client.call('gotoArea', 'zvenets');
        await client.call('stepTicks', 5);
        const door = center({ x: 13, y: 12 });
        await client.call('heroTeleport', door.x + 3, door.z);
        await client.call('stepTicks', 5);
        let ii = await client.call('interactInfo');
        check('подсказка у двери: «E — Дом Ирвина»', ii.label === 'Дом Ирвина' && ii.hint.includes('E'), ii);
        await client.call('heroTeleport', door.x + 30, door.z); // ~10 тайлов в стороне
        await client.call('stepTicks', 5);
        ii = await client.call('interactInfo');
        check('вдали от двери подсказки нет', ii.label === null && ii.hint === '', ii);
        await client.call('heroTeleport', door.x + 3, door.z);
        await client.call('stepTicks', 5);
        await client.call('interact');
        await client.call('stepTicks', PASS);
        ai = await client.call('areaInfo');
        check('click-переход «E» → дом Ирвина', ai.id === 'house_elder', ai.id);
        const inside = await client.screenshot('/tmp/v2_areas_inside.png');
        check('кадр интерьера рисуется (не пустой)', colorCount(decodePng(readFileSync(inside)).rgba) >= 8, {});
    } finally {
        await browser.close();
    }
} catch (e) {
    report.error = String(e && e.stack ? e.stack : e);
    console.error(report.error);
} finally {
    server.stop();
}
report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));