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 { withChecks } from '../lib.mjs';

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

export default async function ({ pretty }) {
    return withChecks(
        'time',
        async (t) => {
            const { c } = t;
            await c.run('новая игра: старт 8:00, день', async () => {
                await t.boot();
                const s = await t.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 t.ctx.agent.command('scene:setTime', { hours: 8 }); // пин: точка отсчёта 480
                await t.ctx.agent.step(600); // 600 тиков × (1/60 с) × 1 мин/с = 10 мин
                const s = await t.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 t.ctx.agent.command('scene:setTime', { hours: 19 });
                const w = await t.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 t.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 t.ctx.agent.command('scene:setTime', { hours: 21 });
                const w = await t.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 t.ctx.agent.command('scene:give', { id: 'clock' });
                const s = await t.ctx.agent.snapshot();
                c.expect(s.inventory.some((i) => i.id === 'clock' && i.count === 1), 'часов нет в снапшоте сумки', s.inventory);
                return null;
            });
            await c.run('сумка: время заморожено, после закрытия — идёт', async () => {
                await t.ctx.agent.command('scene:setTime', { hours: 10 }); // пин перед открытием
                await t.ctx.agent.press('inventory'); // открыть сумку (атомарно)
                await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 120 });
                await t.ctx.agent.press('advance'); // применить часы (первый пункт) — строка в панели
                await t.ctx.agent.step(120); // 2 с: в сумке локация не тикается
                const s = await t.ctx.agent.snapshot();
                c.expect(s.time.minutes === 600, `в сумке время уехало (${s.time.label})`, s.time);
                await t.ctx.agent.press('inventory'); // закрыть сумку
                const w = await t.ctx.agent.waitFor('s.time.minutes > 600', { timeoutTicks: 300 });
                c.expect(w.ok, 'после закрытия сумки время не пошло', w.snapshot?.time);
                return null;
            });
            await c.run('инварианты чисты при идущем времени', async () => {
                await t.expectInvariantsClean('при идущем времени');
                return null;
            });
        },
        { pretty }
    );
}