Newer
Older
rpg / apps / game / tools / checks / synth.mjs
/**
 * Сценарий synth — спек-синтез: агент без слуха описывает звуки параметрами
 * (scene:synthesize → playSpec), факты запуска читаем из window.__gameAudioLog.
 * 1) удар (hit): запуск в логе с заданным ключом и громкостью;
 * 2) звон (chime): другой ключ, кэш — компиляция не мешает повтору;
 * 3) детерминизм: тот же спек дважды — оба запуска в логе;
 * 4) негодный спек (нет dur / неизвестный kind) — команда возвращает null;
 * 5) гул (hum) не глушит амбиент области (разные голоса).
 * Запуск: node tools/agent.mjs run tools/checks/synth.mjs
 */
import { withChecks } from '../lib.mjs';

export default async function ({ pretty }) {
    return withChecks(
        'synth',
        async (t) => {
            const { c } = t;
            /**
             * Команда моста с ожиданием появления ключа в логе
             * (синтез асинхронный): считаем запуски ключа до и после.
             */
            const synthAndWait = async (spec, { key = 'agent/synth', volume, tries = 12 } = {}) => {
                const count = async () =>
                    (await t.readAudioLog()).filter((e) => e.key === key).length;
                const before = await count();
                const res = await t.ctx.agent.command('scene:synthesize', {
                    spec,
                    key,
                    ...(volume !== undefined ? { volume } : {})
                });
                const log = await t.waitAudioLog((l) => l.filter((e) => e.key === key).length > before, tries);
                return { res, log };
            };
            await c.run('новая игра: спек-синтез недоступен в прод-сборке с внятной ошибкой', async () => {
                await t.boot();
                c.expect(Array.isArray(await t.readAudioLog()), 'DEV-лог аудио недоступен (прод-сборка?)');
                return null;
            });
            await c.run('удар по спеку: запуск в логе с заданным ключом и громкостью', async () => {
                const { res, log } = await synthAndWait(
                    { kind: 'hit', dur: 0.3, low: 600, high: 3000, seed: 7 },
                    { key: 'agent/hit-test', volume: 0.5 }
                );
                c.expect(res === true, 'scene:synthesize не принят мостом', res);
                const hits = log.filter((e) => e.key === 'agent/hit-test');
                c.expect(hits.length > 0, 'удар по спеку не сыграл', log);
                c.expect(Math.abs(hits.at(-1).volume - 0.5) < 0.01, 'громкость не дошла до голоса', hits.at(-1));
                return null;
            });
            await c.run('звон по спеку: другой ключ играет параллельно', async () => {
                const { res, log } = await synthAndWait(
                    { kind: 'chime', dur: 0.6, freq: 880 },
                    { key: 'agent/chime-test' }
                );
                c.expect(res === true, 'scene:synthesize не принят мостом', res);
                c.expect(log.some((e) => e.key === 'agent/chime-test'), 'звон по спеку не сыграл', log);
                c.expect(
                    log.some((e) => e.key === 'agent/hit-test'),
                    'предыдущий голос пропал из лога (лог мал?)',
                    log
                );
                return null;
            });
            await c.run('детерминизм: тот же спек дважды — оба запуска в логе', async () => {
                const spec = { kind: 'scrape', dur: 0.5, low: 80, high: 400, tone: 60, seed: 3 };
                const first = await synthAndWait(spec, { key: 'agent/scrape-test' });
                const second = await synthAndWait(spec, { key: 'agent/scrape-test' });
                c.expect(first.res === true && second.res === true, 'команда не принята', [first.res, second.res]);
                const plays = second.log.filter((e) => e.key === 'agent/scrape-test');
                c.expect(plays.length >= 2, 'повторный синтез не сыграл', plays);
                return null;
            });
            await c.run('негодный спек: без dur и с неизвестным kind — null', async () => {
                const noDur = await t.ctx.agent.command('scene:synthesize', { spec: { kind: 'hit' } });
                const badKind = await t.ctx.agent.command('scene:synthesize', { spec: { kind: 'thunder', dur: 1 } });
                const noSpec = await t.ctx.agent.command('scene:synthesize', {});
                c.expect(noDur === null, 'спек без dur должен вернуть null', noDur);
                c.expect(badKind === null, 'неизвестный kind должен вернуть null', badKind);
                c.expect(noSpec === null, 'вызов без спека должен вернуть null', noSpec);
                return null;
            });
            await c.run('гул (hum) не глушит амбиент области', async () => {
                const { res, log } = await synthAndWait(
                    { kind: 'hum', dur: 2, tone: 55, loop: true },
                    { key: 'agent/hum-test' }
                );
                c.expect(res === true, 'scene:synthesize не принят мостом', res);
                c.expect(log.some((e) => e.key === 'agent/hum-test'), 'гул по спеку не сыграл', log);
                c.expect(log.some((e) => e.key === 'ambience/meadows'), 'амбиент области пропал', log);
                return null;
            });
        },
        { pretty }
    );
}