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

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

/** Чистая поляна у точки старта: герой (40,60) вокс = (13.33,20) юнит,
 *  ряд по z=60 пуст — проверено прожектором (равновесие погони ровно 0.55). */
const H = { x: 40, z: 60 };

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);
        // Демка стартует в меню — входим в игру и проходим фейд (48 тиков) + запас
        await client.call('startGame', false);
        await client.call('stepTicks', 60);
        const si = await client.call('sceneInfo');
        check('вошли в игру', si.current === 'game' && !si.transitioning, si);

        // Луг населён: патрульный ползун бодр, плевун и тяжёлый спят в пепле
        const stats0 = await client.call('combatStats');
        check('на лугу 3 сгустка, двое спят',
            stats0.enemies === 3 && stats0.asleep === 2 && stats0.dead === 0, stats0);

        // Кучка спящего рисуется: кадр с врагом vs чистый кадр (кучка 4×4
        // мировых вокселя — десятки пикселей апскейла; луг после combatClear
        // пуст — дифф только от кучки)
        await client.call('combatClear');
        await client.call('heroTeleport', H.x, H.z);
        await client.call('stepTicks', 2);
        const clearShot = await client.screenshot('/tmp/v2_combat_clear.png');
        await client.call('combatSpawn', 'crawler', 14.5, 20);
        await client.call('stepTicks', 2);
        const pileShot = await client.screenshot('/tmp/v2_combat_pile.png');
        const pileDiff = countDiff(png(clearShot), png(pileShot));
        check('кучка спящего сгустка рисуется', pileDiff > 100, { pileDiff });

        // Гулкий удар будит спящего БЕЗ урона: сгусток за краем конуса
        // (1.37 > 1.0), но в радиусе звука (4.5) → rise. Лёгкий шаг ставит
        // взгляд героя на +x (для конуса ниже); затем погоня — равновесие
        // сепарации 0.55 (радиусы 0.35+0.2)
        await client.call('combatClear');
        await client.call('heroTeleport', H.x, H.z);
        await client.call('stepTicks', 2);
        await client.call('combatSpawn', 'crawler', 14.7, 20);
        await client.call('setInput', 1, 1); // экранная диагональ = +x мира
        await client.call('stepTicks', 2);
        await client.call('setInput', null, null);
        await client.call('attackInput', true);
        await client.call('attackInput', false);
        await client.call('stepTicks', 5);
        let info = await client.call('combatInfo');
        check('звон будит спящего без урона (rise)',
            info.enemies[0].state === 'rise' && info.enemies[0].hp === 3, info.enemies[0]);
        await client.call('stepTicks', 150);
        info = await client.call('combatInfo');
        const heroNow = await client.call('heroInfo');
        const en = info.enemies[0];
        const dist = Math.hypot(en.pos.x - heroNow.pos[0] / 3, en.pos.z - heroNow.pos[2] / 3);
        check('погоня: догнал и «липнет» рядом (0.55)', en.state === 'chase' && dist < 0.75, { state: en.state, dist });

        // Удар конусом: 3 → 1 → труп; труп исчезает через 0.5 с
        await client.call('attackInput', true);
        await client.call('attackInput', false);
        await client.call('stepTicks', 5);
        info = await client.call('combatInfo');
        check('конус ранил: hp 3 → 1', info.enemies[0].hp === 1, info.enemies[0]);
        await client.call('stepTicks', 30); // кулдаун удара 0.45 с = 27 тиков
        await client.call('attackInput', true);
        await client.call('attackInput', false);
        await client.call('stepTicks', 5);
        info = await client.call('combatInfo');
        check('второй удар добил: труп', info.enemies[0].dead === true, info.enemies[0]);
        await client.call('stepTicks', 45); // corpseTimer 0.5 с
        const after = await client.call('combatStats');
        check('труп исчез', after.enemies === 0, after);

        // Смерть героя: контактные удары в упор — респаун на старте с полным
        // hp. Спящий вплотную НЕ просыпается (dormant глух к близости), поэтому
        // сначала будим звоном издали и даём дойти до 0.55; ползун в равновесии
        // дальше attackRange (0.4) не бьёт, поэтому каждый круг телепортируем
        // героя прямо на врага (вокс-сетка даёт остаток ≤ ~0.24 < 0.4).
        // Секция ДО плевуна: у героя ровно 5 сердец.
        await client.call('combatClear');
        await client.call('heroTeleport', H.x, H.z);
        await client.call('stepTicks', 2);
        await client.call('combatSpawn', 'crawler', 14.5, 20);
        await client.call('attackInput', true);
        await client.call('attackInput', false);
        await client.call('stepTicks', 150);
        const hpSeries = [];
        let respawnPos = null;
        let prevHp = 5;
        for (let i = 0; i < 5; i++) {
            const ci = await client.call('combatInfo');
            const foe = ci.enemies.find((e) => !e.dead && !e.asleep);
            if (!foe) { check(`круг ${i + 1}: сгусток жив и бодр`, false, ci.enemies); break; }
            await client.call('heroTeleport', Math.round(foe.pos.x * 3), Math.round(foe.pos.z * 3));
            await client.call('stepTicks', 3);
            const c = await client.call('combatInfo');
            hpSeries.push(c.hp);
            if (respawnPos === null && c.hp > prevHp) {
                // hp подскочил — герой пал и возродился; снимаем точку респауна
                const h = await client.call('heroInfo');
                respawnPos = h.pos;
            }
            prevHp = c.hp;
            await client.call('stepTicks', 70); // неуязвимость 0.8 с + кулдаун ползуна 0.8 с
        }
        const hero = await client.call('heroInfo');
        const finalInfo = await client.call('combatInfo');
        const fell = hpSeries.join(',') === '4,3,2,1,5'; // 5-е сердце → смерть и респаун
        check('смерть героя: 5 ударов, респаун с полным hp', fell && finalInfo.hp === 5, { hpSeries, finalInfo });
        const rp = respawnPos ?? hero.pos;
        check('респаун на точке старта (40,60)',
            Math.abs(rp[0] - 40) < 0.01 && Math.abs(rp[2] - 60) < 0.01, rp);

        // Плевун: будим издали, держит дистанцию и плюёт — герой теряет сердце
        await client.call('combatClear');
        await client.call('heroTeleport', H.x, H.z);
        await client.call('stepTicks', 2);
        await client.call('combatSpawn', 'spitter', 15.5, 20);
        await client.call('setFollow', false); // камера фиксирована — зона HUD чистая
        await client.call('stepTicks', 2);
        const hearts5Shot = await client.screenshot('/tmp/v2_combat_hearts5.png');
        await client.call('attackInput', true); // звон будит (конус не достаёт: 2.17 > 1.0)
        await client.call('attackInput', false);
        await client.call('stepTicks', 240); // замах 0.35 + плевок + полёт ~1 с
        info = await client.call('combatInfo');
        check('плевун нанёс урон: hp 5 → 4', info.hp === 4, { hp: info.hp, enemies: info.enemies });
        const hearts4Shot = await client.screenshot('/tmp/v2_combat_hearts4.png');
        const hudDiff = countDiffZone(png(hearts5Shot), png(hearts4Shot), 0, 60, 700, 960);
        check('HUD сердец перерисовался (правый верх)', hudDiff > 100, { hudDiff });
        await client.call('setFollow', true);

        // Резонанс: заряд ≥ 0.6 с усыпляет обоих в радиусе 3 (сгустки за краем
        // конуса — проснулись от звона без урона; спавн с одной стороны, чтобы
        // толкучка не втолкнула кого-то в strike-радиус героя)
        await client.call('combatClear');
        await client.call('heroTeleport', H.x, H.z);
        await client.call('stepTicks', 2);
        await client.call('combatSpawn', 'crawler', 15.0, 20);
        await client.call('combatSpawn', 'crawler', 15.3, 20);
        await client.call('attackInput', true);
        await client.call('attackInput', false);
        await client.call('stepTicks', 10);
        await client.call('attackInput', true);
        await client.call('stepTicks', 40); // заряд 0.83 с ≥ 0.6
        await client.call('attackInput', false);
        info = await client.call('combatInfo');
        check('резонанс усыпил обоих (sleeping)',
            info.enemies.length === 2 && info.enemies.every((e) => e.state === 'sleeping'), info.enemies);

        await client.call('combatClear');

        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);

/** PNG со скриншота. */
function png(path) {
    return decodePng(readFileSync(path));
}

/** Число различающихся пикселей двух кадров одного размера. */
function countDiff(a, b) {
    let n = 0;
    for (let i = 0; i < Math.min(a.rgba.length, b.rgba.length); i += 4) {
        if (Math.abs(a.rgba[i] - b.rgba[i]) > 8 || Math.abs(a.rgba[i + 1] - b.rgba[i + 1]) > 8
            || Math.abs(a.rgba[i + 2] - b.rgba[i + 2]) > 8) n++;
    }
    return n;
}

/** Различия зоны [y0..y1)×[x0..x1) — HUD-зона без шума сцены. */
function countDiffZone(a, b, y0, y1, x0, x1) {
    const stride = a.width * 4;
    let n = 0;
    for (let y = y0; y < y1; y++) {
        for (let x = x0; x < x1; x++) {
            const i = y * stride + x * 4;
            if (Math.abs(a.rgba[i] - b.rgba[i]) > 8 || Math.abs(a.rgba[i + 1] - b.rgba[i + 1]) > 8
                || Math.abs(a.rgba[i + 2] - b.rgba[i + 2]) > 8) n++;
        }
    }
    return n;
}