Newer
Older
rpg / apps / game / tools / checks / time.mjs
/**
 * Сценарий time — внутриигровое время суток через мост: старт, ход по тикам,
 * кривая суток (закат), ночные источники/лампа, инварианты.
 * Темп: 1 реальная секунда = 1 игровая минута, тик фиксированный 1/60 с —
 * 600 шагов моста = ровно 10 игровых минут (детерминированно, без ожидания).
 * 1) Новая игра: старт 8:00, фактор ночи 0;
 * 2) перемотка 8:00 + 600 шагов → +10 минут (±1, float-хвост-floor);
 * 3) 19:00 — середина заката: night ≈ 0.5, ambient — микс по фактическому
 *    фактору (время идёт и после пина, точное равенство неустойчиво);
 * 4) 21:00 — ночь: night 1, лампа героя зажглась на светлой днём улице;
 * 5) часы (scene:give) кладутся в сумку и видны в снапшоте;
 * 6) сумка открыта — время заморожено (локация не тикается), закрыта — идёт;
 * 7) инварианты чисты (time-bounded и прочие).
 * Запуск: node tools/agent.mjs run tools/checks/time.mjs
 */
import { startDevServer, openGame, Checks } from '../lib.mjs';

/** Ambient лугов и его ночной микс на середине заката (19:00). */
const AMB = { meadows: 0xd8dade, mix: 0xa1a6b3, night: 0x6a7288 };

export default async function ({ pretty }) {
    const c = new Checks('time');
    const server = await startDevServer();
    let ctx;
    try {
        await c.run('новая игра: старт 8:00, день', async () => {
            ctx = await openGame({ url: server.url, newGame: true });
            await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 });
            const s = await ctx.agent.snapshot();
            c.expect(s.time.minutes >= 480 && s.time.minutes < 485, `старт не 8:00 (${s.time.label})`, s.time);
            c.expect(s.time.night === 0, 'утром фактор ночи не нулевой', s.time);
            c.expect(/^\d{1,2}:\d{2}$/.test(s.time.label), `label не часы (${s.time.label})`, s.time);
            return null;
        });
        await c.run('ход времени: 600 шагов = +10 минут (±1)', async () => {
            await ctx.agent.command('scene:setTime', { hours: 8 }); // пин: точка отсчёта 480
            await ctx.agent.step(600); // 600 тиков × (1/60 с) × 1 мин/с = 10 мин
            const s = await ctx.agent.snapshot();
            const dt = s.time.minutes - 480;
            c.expect(dt >= 9 && dt <= 11, `после 600 шагов прошло ${dt} мин (не ~10)`, s.time);
            return null;
        });
        await c.run('закат 19:00: night ≈ 0.5, ambient — микс по фактору', async () => {
            await ctx.agent.command('scene:setTime', { hours: 19 });
            const w = await ctx.agent.waitFor('Math.abs(s.time.night - 0.5) < 0.02', { timeoutTicks: 300 });
            c.expect(w.ok, 'на середине заката фактор ночи не ~0.5', w.snapshot?.time);
            // Время продолжает идти (1 тик ≈ +1/60 мин) — сверяем ambient с миксом
            // по фактическому фактору из того же снапшота (округление по каналам).
            const s = await ctx.agent.snapshot();
            const mix = (a, b, k) =>
                [16, 8, 0].map((sh) => {
                    const ca = (a >> sh) & 255, cb = (b >> sh) & 255;
                    return Math.round(ca * (1 - k) + cb * k) << sh;
                }).reduce((x, y) => x | y);
            const expected = mix(AMB.meadows, AMB.night, s.time.night);
            c.expect(s.lighting.ambient === expected, `ambient ${s.lighting.ambient.toString(16)} не микс ${expected.toString(16)}`, { ambient: s.lighting.ambient, night: s.time.night });
            return null;
        });
        await c.run('ночь 21:00: night 1, ночная цель и лампа героя', async () => {
            await ctx.agent.command('scene:setTime', { hours: 21 });
            const w = await ctx.agent.waitFor(
                `s.time.night === 1 && s.lighting.ambient === ${AMB.night} && s.lighting.sources.some((l) => l.id === "lamp")`,
                { timeoutTicks: 300 }
            );
            c.expect(w.ok, 'в 21:00 луга не ночные или лампа не зажглась', w.snapshot?.lighting);
            return null;
        });
        await c.run('часы в сумке: scene:give кладёт предмет', async () => {
            await ctx.agent.command('scene:give', { id: 'clock' });
            const s = await ctx.agent.snapshot();
            c.expect(s.inventory.some((i) => i.id === 'clock' && i.count === 1), 'часов нет в снапшоте сумки', s.inventory);
            return null;
        });
        await c.run('сумка: время заморожено, после закрытия — идёт', async () => {
            await ctx.agent.command('scene:setTime', { hours: 10 }); // пин перед открытием
            await ctx.agent.press('inventory'); // открыть сумку (атомарно)
            await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 120 });
            await ctx.agent.press('advance'); // применить часы (первый пункт) — строка в панели
            await ctx.agent.step(120); // 2 с: в сумке локация не тикается
            const s = await ctx.agent.snapshot();
            c.expect(s.time.minutes === 600, `в сумке время уехало (${s.time.label})`, s.time);
            await ctx.agent.press('inventory'); // закрыть сумку
            const w = await ctx.agent.waitFor('s.time.minutes > 600', { timeoutTicks: 300 });
            c.expect(w.ok, 'после закрытия сумки время не пошло', w.snapshot?.time);
            return null;
        });
        await c.run('инварианты чисты при идущем времени', async () => {
            const inv = await ctx.agent.invariants();
            const bad = inv.filter((i) => i.severity === 'error');
            c.expect(bad.length === 0, 'ошибки инвариантов', bad);
            return null;
        });
    } finally {
        await ctx?.browser?.close();
        server.stop();
    }
    return c.finish({ pretty }).ok ? 0 : 1;
}