Newer
Older
rpg / v2 / apps / demo / tools / checks / save.mjs
/**
 * Скриншот-гейт сохранений v2 (трек «сохранение»): слоты JSON с метой
 * (SaveManager, localStorage), сериализация состояния демки (герой, флаги/
 * переменные, сумка), загрузка восстанавливает состояние и кадр.
 * Ручной режим цикла — детерминизм. Скриншоты: /tmp/v2_save_*.png.
 *
 * Запуск: node apps/demo/tools/checks/save.mjs [--port 5310]
 */
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { sleep, 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 ?? 5310);
const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url));

const server = startDevServer({ port, cwd: DEMO_DIR });
const report = { name: 'save', ok: false, checks: [] };
const check = (title, ok, info = null) => {
    report.checks.push({ title, ok, info });
    console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};

// Точка съёмки «сохранённого состояния»: у дома (42,51), камера одна на кадры.
const SPOT = { x: 42, z: 51 };

try {
    await server.waitReady;
    const browser = await launchBrowser();
    try {
        const client = await openGame(browser, { url: `http://localhost:${port}/` });

        const errs = client.errors();
        check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3));

        await client.call('setManual', true);
        // Демка стартует в меню (стек сцен) — входим в игру и проходим переход (0.4 с)
        await client.call('startGame', false);
        await client.call('stepTicks', 60);
        check('слотов на старте нет', (await client.call('saveList')).length === 0);

        // Готовим состояние: диалог дал цветок, герой у дома
        await client.call('heroTeleport', 42, 51);
        await client.call('stepTicks', 2);
        await client.call('interact');
        await client.call('dialoguePick', 0); // «Да, я копал» → giveItem
        await client.call('dialogueAdvance');
        await client.call('dialogueAdvance');
        await client.call('heroTeleport', SPOT.x, SPOT.z);
        await client.call('stepTicks', 2);
        const saved = await client.call('saveTo', 'gate', 'Гейт сейвов');
        check('saveTo пишет слот и мету',
            saved.list.length === 1 && saved.list[0].slot === 'gate'
            && saved.list[0].meta.title === 'Гейт сейвов' && saved.list[0].meta.version === 1,
            saved.list);
        const stateA = await client.call('dialogueInfo');
        const shotA = await client.screenshot('/tmp/v2_save_a.png');
        const imgA = decodePng(readFileSync(shotA));

        // Ломаем состояние: отдаём цветок, уезжаем далеко
        await client.call('interact');
        await client.call('dialoguePick', 3); // «Отдать цветок» → takeItem
        await client.call('dialogueAdvance');
        await client.call('dialogueAdvance');
        await client.call('heroTeleport', 60, 12);
        await client.call('stepTicks', 2);
        const broken = await client.call('invInfo');
        const shotB = await client.screenshot('/tmp/v2_save_b.png');
        const imgB = decodePng(readFileSync(shotB));
        const abDiff = countDiff(imgA.rgba, imgB.rgba);
        check('состояние изменилось: сумка пуста, кадр уехал',
            broken.empty === true && abDiff > 5000, { empty: broken.empty, abDiff });
        const brokenFlags = (await client.call('dialogueInfo')).flags;

        // Загрузка: флаги/сумка/позиция восстановлены, кадр вернулся
        const loaded = await client.call('loadFrom', 'gate');
        const restored = await client.call('dialogueInfo');
        const invRestored = await client.call('invInfo');
        const restoredFlags = restored.flags.filter((f) => !brokenFlags.includes(f) || stateA.flags.includes(f));
        check('loadFrom восстановил героя у дома',
            loaded.loaded === true
            && Math.abs(loaded.heroPos[0] - SPOT.x) < 0.01
            && Math.abs(loaded.heroPos[1] - SPOT.z) < 0.01, loaded);
        check('loadFrom восстановил флаги (без flowerGivenAway)',
            restoredFlags.includes('admitted') && restoredFlags.includes('flowerGiven')
            && !restored.flags.includes('flowerGivenAway'), restored.flags);
        check('loadFrom восстановил сумку: цветок ×1',
            invRestored.all.length === 1 && invRestored.all[0].id === 'flower', invRestored);
        await client.call('stepTicks', 2); // ручной режим: рендер только в тике
        const shotBack = await client.screenshot('/tmp/v2_save_back.png');
        const imgBack = decodePng(readFileSync(shotBack));
        const backDiff = countDiff(imgA.rgba, imgBack.rgba);
        check('кадр после загрузки = кадр на момент сохранения', backDiff < 100, { backDiff });

        // Удаление слота
        await client.call('saveDelete', 'gate');
        check('saveDelete чистит слот', (await client.call('saveList')).length === 0);

        report.ok = report.checks.every((c) => c.ok);
    } finally {
        await browser.close();
    }
} catch (e) {
    check('прогон без исключений', false, String(e));
} finally {
    server.stop();
}

console.log(JSON.stringify(report, null, 2));
process.exit(report.ok ? 0 : 1);

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