diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 52a8954..087c1d1 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -11,7 +11,9 @@ type JsonValue, type SceneAgent, type SceneRegistry, - type SnapshotLayer + type SnapshotLayer, + type SoundKind, + type SoundSpec } from '@rpg/engine'; import type { CutsceneRunner } from '@rpg/engine'; import type { Game } from '../Game'; @@ -34,6 +36,9 @@ import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer, collisionLayer } from './snapshot'; import { hasLineOfSight } from '../systems/combat/los'; +/** Допустимые kind спек-синтеза (как в движке, строками — спек идёт по мосту). */ +const SOUND_KINDS: readonly string[] = ['hit', 'chime', 'scrape', 'hum']; + /** * Агентный фасад сцены локации: слой снапшота, инварианты, whitelist-команды. * Вынесен из LocationScene (SceneAgent), чтобы сцена оставалась оркестратором @@ -195,6 +200,9 @@ index?: number; from?: { x?: number; y?: number }; to?: { x?: number; y?: number }; + key?: string; + volume?: number; + spec?: Partial; }; switch (name) { case 'scene:sleepAll': @@ -264,6 +272,29 @@ while (d.cutscene.active) d.cutscene.update(0.5); return true; } + case 'scene:synthesize': { + // Звук по описанию (спек → playSpec): агент без слуха описывает + // звук параметрами; факт запуска виден в DEV-логе аудио. + const s = a.spec as Partial | undefined; + if (!s || !SOUND_KINDS.includes(s.kind as SoundKind) || typeof s.dur !== 'number') { + return null; + } + const spec: SoundSpec = { kind: s.kind as SoundKind, dur: s.dur }; + if (typeof s.low === 'number') spec.low = s.low; + if (typeof s.high === 'number') spec.high = s.high; + if (typeof s.tone === 'number') spec.tone = s.tone; + if (typeof s.freq === 'number') spec.freq = s.freq; + if (typeof s.power === 'number') spec.power = s.power; + if (typeof s.seed === 'number') spec.seed = s.seed; + if (typeof s.peak === 'number') spec.peak = s.peak; + if (typeof s.loop === 'boolean') spec.loop = s.loop; + void d.game.audio.playSpec( + typeof a.key === 'string' ? a.key : 'agent/synth', + spec, + typeof a.volume === 'number' ? a.volume : 0.6 + ); + return true; + } default: return null; } diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index b32f81b..72bc0ff 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -105,7 +105,8 @@ { name: 'interact-world', run: () => runScenario('interact-world', 'apps/game/tools/checks/interact-world.mjs') }, { name: 'collision', run: () => runScenario('collision', 'apps/game/tools/checks/collision.mjs') }, { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') }, - { name: 'audio', run: () => runScenario('audio', 'apps/game/tools/checks/audio.mjs') } + { name: 'audio', run: () => runScenario('audio', 'apps/game/tools/checks/audio.mjs') }, + { name: 'synth', run: () => runScenario('synth', 'apps/game/tools/checks/synth.mjs') } ]; const only = args.only ?? all.map((p) => p.name); const skip = args.skip ?? []; diff --git a/apps/game/tools/checks/synth.mjs b/apps/game/tools/checks/synth.mjs new file mode 100644 index 0000000..51bf6a3 --- /dev/null +++ b/apps/game/tools/checks/synth.mjs @@ -0,0 +1,100 @@ +/** + * Сценарий 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 { startDevServer, openGame, Checks } from '../lib.mjs'; + +/** Прочитать DEV-лог аудио из страницы. */ +const readLog = (page) => page.evaluate(() => window.__gameAudioLog ?? null); + +/** Команда моста с ожиданием появления ключа в логе (синтез асинхронный). */ +async function synthAndWait({ page, agent }, spec, { key = 'agent/synth', volume, tries = 12 } = {}) { + const before = (await readLog(page)).filter((e) => e.key === key).length; + const res = await agent.command('scene:synthesize', { spec, key, ...(volume !== undefined ? { volume } : {}) }); + for (let i = 0; i < tries; i++) { + const log = await readLog(page); + if (log.filter((e) => e.key === key).length > before) return { res, log }; + await agent.step(30); + } + return { res, log: await readLog(page) }; +} + +export default async function ({ pretty }) { + const c = new Checks('synth'); + const server = await startDevServer(); + let ctx; + try { + await c.run('новая игра: спек-синтез недоступен в прод-сборке с внятной ошибкой', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + c.expect(Array.isArray(await readLog(ctx.page)), 'DEV-лог аудио недоступен (прод-сборка?)'); + return null; + }); + await c.run('удар по спеку: запуск в логе с заданным ключом и громкостью', async () => { + const { res, log } = await synthAndWait( + ctx, + { 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( + ctx, + { 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(ctx, spec, { key: 'agent/scrape-test' }); + const second = await synthAndWait(ctx, 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 ctx.agent.command('scene:synthesize', { spec: { kind: 'hit' } }); + const badKind = await ctx.agent.command('scene:synthesize', { spec: { kind: 'thunder', dur: 1 } }); + const noSpec = await 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( + ctx, + { 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; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/docs/engine/agent.md b/docs/engine/agent.md index da38cac..4f86a29 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -52,8 +52,25 @@ слышат в hearRadius, 0.7+ — будит спящих), `scene:damageEnemy {id, value}` (урон сгустку — проверки отступления), `scene:walkable {x, y}` (проходим ли тайл: стены, вода, footprint пропов), `scene:raycast {from, to}` (прямая -видимость между тайлами: false — высокий объект на отрезке). Команда вне -whitelist возвращает `null` — расширять осознанно. +видимость между тайлами: false — высокий объект на отрезке), +`scene:synthesize {spec, key?, volume?}` — **звук по описанию**: агент без слуха +задаёт спек `SoundSpec` (движок детерминированно компилирует его в буфер — +см. `assets-audio-save.md`, «Спек-синтез»): + +```json +{ "kind": "hit", "dur": 0.3, "low": 600, "high": 3000, "seed": 7 } +{ "kind": "chime", "dur": 0.6, "freq": 880 } +{ "kind": "scrape", "dur": 0.5, "low": 80, "high": 400, "tone": 60 } +{ "kind": "hum", "dur": 8, "tone": 55, "power": 2 } +``` + +`kind` — форма звука (`hit` удар / `chime` звон / `scrape` скрип, дверь-куст / +`hum` гул-луп), `dur` в секундах — обязательны; остальное — параметры с +дефолтами (`low`/`high` полоса шума, `tone` низкая подложка, `freq` частота +звона, `power` крутизна спада, `seed` — детерминизм, `peak`, `loop`). Ключ +голоса по умолчанию `agent/synth` — факт запуска ищется в DEV-логе +`window.__gameAudioLog`. Спек с неизвестным `kind` или без `dur` → команда +вернёт `null`. Команда вне whitelist возвращает `null` — расширять осознанно. Состояние врага читается из снапшота: `s.enemies[i].{kind, state, hp, pos, asleep, dead}` — состояния `dormant/rise/patrol/wary/chase/windup/attack/ diff --git a/docs/engine/practices.md b/docs/engine/practices.md index f1fbd1d..315f72d 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -192,7 +192,8 @@ пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта» одного ключа. Звук **без файла вообще** — `audio.playSpec(key, spec)`: спек `SoundSpec` (kind `hit`/`chime`/`scrape`/`hum` + параметры) движок - детерминированно компилирует сам — путь для ИИ-агента и быстрых прототипов. + детерминированно компилирует сам — путь для ИИ-агента и быстрых прототипов + (агентный доступ — op `scene:synthesize`, образец — `checks/synth.mjs`). 4. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия diff --git a/docs/plan.md b/docs/plan.md index 5e9ae77..6457e6f 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -24,7 +24,7 @@ | 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | в составе работы B | | 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | в работе | | 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | ✅ D0–D2 сделаны (2026-09-07) | -| 8 | Синтез звука по запросу для агента (работа E, ниже) | 📋 спланировано | +| 8 | Синтез звука по запросу для агента (работа E, ниже) | ✅ E1+E2 сделаны (2026-09-07); E3 — опционально | | 9 | Инфраструктура аудио + музыкальный слой (работы F/G, ниже) | 📋 спланировано | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся @@ -319,16 +319,16 @@ `__gameAudioLog`. Нейросеть позже — опциональный «описание → спек» маппер поверх того же API; сэмпл-библиотеки как основной путь не используются. -- **E1 (движок): компилятор спеков.** `audio.playSpec(key, spec, opts?)` — +- **E1 (движок) ✅ (2026-09-07): компилятор спеков.** `audio.playSpec(key, spec, opts?)` — kinds `hit`/`chime`/`scrape`/`hum`, параметры: `dur` (обяз.), `low`/`high` (полоса шума), `tone` (низкая подложка/основной тон гула), `freq` (звон), `power` (крутизна затухания), `seed` (детерминизм), `peak`, `loop` (кроссфейд краёв). Кэш буферов по ключу. Тесты: детерминизм (одинаковые спеки/сиды → одинаковые сэмплы), kinds, кэш. -- **E2 (мост): агентный доступ.** Op `scene:synthesize` (спек → playSpec), - документация форм спеков в `docs/engine/agent.md`; проба - `checks/synth.mjs` — агент слепо синтезирует «удар» и «звон», проверка - фактов запуска в аудио-логе. +- **E2 (мост) ✅ (2026-09-07): агентный доступ.** Op `scene:synthesize` (спек → + playSpec), документация форм спеков в `docs/engine/agent.md`; проба + `checks/synth.mjs` (в `agent:check`) — агент слепо синтезирует удар/звон/ + скрип/гул, проверка фактов запуска в аудио-логе + отрицательные случаи. - **E3 (контент, опционально):** миграция части файловых SFX на спеки (дверь, цветок/куст) — где процедурная формула тривиальна.