diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index 4cd2f7e..6a9df46 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -46,6 +46,8 @@ walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise; /** Дождаться и долистать активный диалог. false — диалог не открылся. */ runDialogue(timeoutTicks?: number): Promise; + /** Дождаться выбора в диалоге и выбрать вариант по тексту. false — не дождались/нет варианта. */ + pickChoiceByText(text: string, timeoutTicks?: number): Promise; /** «Новая игра» из меню. */ newGame(): Promise; /** id текущей локации (null — сцена не локация). */ @@ -222,6 +224,20 @@ return !this.snapshot().dialogue; } + /** + * Дождаться выбора в диалоге и выбрать вариант по тексту (через + * scene:pickChoiceByText — порядок вариантов сценарию знать не надо). + */ + async pickChoiceByText(text: string, timeoutTicks = 600): Promise { + const opened = await this.waitFor('s.dialogue != null && s.dialogue.waitingForChoice', { + timeoutTicks + }); + if (!opened.ok) return false; + const picked = this.command('scene:pickChoiceByText', { text }); + this.step(2); + return picked === true; + } + /** «Новая игра» из меню (команда сцены — без координат кнопок). */ async newGame(): Promise { // begin() молча отбрасывает команды во время fade-перехода — ждём его конца. @@ -253,6 +269,7 @@ command: (name, args) => agent.command(name, args), walkTo: (tx, ty, opts) => agent.walkTo(tx, ty, opts), runDialogue: (timeoutTicks) => agent.runDialogue(timeoutTicks), + pickChoiceByText: (text, timeoutTicks) => agent.pickChoiceByText(text, timeoutTicks), newGame: () => agent.newGame(), currentArea: () => agent.currentArea() }; diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 3de1af1..4c3d0bf 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -198,6 +198,7 @@ value?: number | string | boolean; flag?: string; index?: number; + text?: string; from?: { x?: number; y?: number }; to?: { x?: number; y?: number }; key?: string; @@ -267,6 +268,10 @@ if (typeof a.index !== 'number') return null; d.dialogue.pickChoice(a.index); return true; + case 'scene:pickChoiceByText': + // Выбор по тексту реплики — сценариям не надо знать порядок вариантов. + if (typeof a.text !== 'string') return null; + return d.dialogue.pickChoiceByText(a.text); case 'scene:skipCutscene': { if (!d.cutscene.active) return false; while (d.cutscene.active) d.cutscene.update(0.5); diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts index b32829a..4d75191 100644 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -9,6 +9,7 @@ npcsLayer, type EnemySnapshot, type GameSnapshot, + type DialogueSnapshot, type LocationSnapshot, type NpcSnapshot } from '../snapshot'; @@ -76,7 +77,17 @@ }); it('диалог: объект проходит, null остаётся null', () => { - const d = { id: 'elder', nodeId: 'n1', speaker: 'Ирвин', text: 'Привет', choices: [], waitingForChoice: false }; + const d: DialogueSnapshot = { + id: 'elder', + nodeId: 'n1', + speaker: 'Ирвин', + text: 'Привет', + mood: null, + tags: [], + choices: [], + path: ['n1'], + waitingForChoice: false + }; expect(dialogueLayer(d).dialogue).toEqual(d); expect(dialogueLayer(null).dialogue).toBeNull(); }); diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index e97d747..286599e 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -63,7 +63,13 @@ nodeId: string | null; speaker: string | null; text: string | null; + /** Настроение реплики (презентационное; на геймплей не влияет). */ + mood: string | null; + /** Свободные метки узла — для фильтров проверок. */ + tags: string[]; choices: string[]; + /** Показанные узлы по порядку — как диалог был пройден. */ + path: string[]; waitingForChoice: boolean; } diff --git a/apps/game/src/data/dialogues/elder_first.json b/apps/game/src/data/dialogues/elder_first.json index 4aeb6d8..63bfcee 100644 --- a/apps/game/src/data/dialogues/elder_first.json +++ b/apps/game/src/data/dialogues/elder_first.json @@ -14,7 +14,12 @@ "task": { "speaker": "Старейшина Ирвин", "text": "Собери. Посади здесь, на лугу. Поляна без цветов — поляна без завтра.", - "next": "give" + "choices": [ + { + "text": "Прозвоню дорогу до прудов и вернусь до темноты.", + "next": "give" + } + ] }, "give": { "setFlags": ["met_elder", "quest_bells_taken"], diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts index eb0f9ea..c75b6f3 100644 --- a/apps/game/src/systems/DialogueSystem.ts +++ b/apps/game/src/systems/DialogueSystem.ts @@ -93,13 +93,24 @@ this.runner.pick(index); } + /** Выбрать вариант по тексту (агентный мост). false — такого варианта нет. */ + pickChoiceByText(text: string): boolean { + const index = this.runner.choices.findIndex((c) => c.text === text); + if (index < 0) return false; + this.runner.pick(index); + return true; + } + /** Состояние диалога для агентного моста (снапшот; не для логики). */ get agentState(): { id: string | null; nodeId: string | null; speaker: string | null; text: string | null; + mood: string | null; + tags: string[]; choices: string[]; + path: string[]; waitingForChoice: boolean; } | null { if (!this.runner.active) return null; @@ -109,7 +120,10 @@ nodeId: this.runner.nodeId, speaker: node?.speaker ?? null, text: node?.text ?? null, + mood: node?.mood ?? null, + tags: node?.tags ?? [], choices: this.runner.choices.map((c) => c.text), + path: this.runner.path, waitingForChoice: this.runner.waitingForChoice }; } diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index 481831a..b8ce958 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -103,6 +103,7 @@ { name: 'transitions', run: () => runScenario('transitions', 'apps/game/tools/checks/transitions.mjs') }, { name: 'interact', run: () => runScenario('interact', 'apps/game/tools/checks/interact.mjs') }, { name: 'interact-world', run: () => runScenario('interact-world', 'apps/game/tools/checks/interact-world.mjs') }, + { name: 'quest-bells', run: () => runScenario('quest-bells', 'apps/game/tools/checks/quest-bells.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') }, diff --git a/apps/game/tools/checks/quest-bells.mjs b/apps/game/tools/checks/quest-bells.mjs new file mode 100644 index 0000000..3ed37d8 --- /dev/null +++ b/apps/game/tools/checks/quest-bells.mjs @@ -0,0 +1,107 @@ +/** + * Сценарий quest-bells — полный цикл квеста «Три цветка» (акт 1) через мост. + * 1) Ирвин: elder_first до выбора, вариант по ТЕКСТУ (pickChoiceByText) -> + * флаги met_elder + quest_bells_taken; + * 2) чит-цветы (scene:setVar flowers=3) -> повторный разговор: elder_hand_in + * (квест-стадия ready), флаг quest_bells_done, кат-сцена посадки: + * vars.flowers -> 0, тост «Поляна гудит», поляна зеленеет; + * 3) Мила: trader_first (полотно в сумке, met_mila), затем trader_after + * (эпилог из квест-стадии); + * 4) инвариант interact-used-consistent и контент чисты. + * Запуск: node tools/agent.mjs run tools/checks/quest-bells.mjs + */ +import { startDevServer, openGame, Checks } from '../lib.mjs'; + +/** Реплика героя в elder_first (выбор по тексту — не по индексу). */ +const RING_REPLY = 'Прозвоню дорогу до прудов и вернусь до темноты.'; + +export default async function ({ pretty }) { + const c = new Checks('quest-bells'); + const server = await startDevServer(); + let ctx; + try { + await c.run('Ирвин: elder_first, выбор по тексту, флаги квеста', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.command('scene:sleepAll'); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + // NPC в Звенце: тропа на востоке (26,14), затем Ирвин (12,9). + const walk = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(walk, 'не дошёл до тропы в Звенец'); + await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(12, 9); // Ирвин — клик издалека + const o = await ctx.agent.waitFor('s.dialogue?.id === "elder_first"', { timeoutTicks: 900 }); + c.expect(o.ok, 'диалог elder_first не открылся', o.snapshot.dialogue); + // Листаем до выбора: advance при выборе активирует курсор — проверяем снапшот. + for (let i = 0; i < 12; i++) { + const s = await ctx.agent.snapshot(); + if (s.dialogue?.waitingForChoice) break; + if (!s.dialogue) break; + await ctx.agent.press('advance'); // догнать печать или следующая реплика + } + const picked = await ctx.agent.pickChoiceByText(RING_REPLY); + c.expect(picked, 'вариант по тексту не найден', (await ctx.agent.snapshot()).dialogue); + const d = await ctx.agent.runDialogue(900); + c.expect(d, 'диалог не завершился после выбора'); + const w = await ctx.agent.waitFor( + 's.flags.includes("met_elder") && s.flags.includes("quest_bells_taken")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'граф не поднял флаги знакомства и взятия квеста', w.snapshot.flags); + return null; + }); + await c.run('сдача: elder_hand_in, кат-сцена посадки, поляна зеленеет', async () => { + // Чит-цветы: сам сбор (пруды) покрыт клик-роутингом, здесь — сдача. + await ctx.agent.command('scene:setVar', { id: 'flowers', value: 3 }); + await ctx.agent.tapTile(12, 9); + const o = await ctx.agent.waitFor('s.dialogue?.id === "elder_hand_in"', { timeoutTicks: 900 }); + c.expect(o.ok, 'квест-стадия не дала elder_hand_in', o.snapshot.dialogue); + const d = await ctx.agent.runDialogue(900); + c.expect(d, 'диалог сдачи не завершился'); + const w = await ctx.agent.waitFor( + 's.flags.includes("quest_bells_done") && s.vars.flowers === 0', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'нет флага сдачи или цветы не списаны', { + flags: w.snapshot.flags, + vars: w.snapshot.vars + }); + // Кат-сцена посадки: тост и конец (клики блокируются до её конца). + const t = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("гудит")', { + timeoutTicks: 900 + }); + c.expect(t.ok, 'нет тоста посадки', t.snapshot.lastToast); + await ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 900 }); + return null; + }); + await c.run('Мила: trader_first (полотно) -> trader_after (эпилог)', async () => { + await ctx.agent.tapTile(17, 11); + const o = await ctx.agent.waitFor('s.dialogue?.id === "trader_first"', { timeoutTicks: 900 }); + c.expect(o.ok, 'первый диалог Милы не открылся', o.snapshot.dialogue); + const d1 = await ctx.agent.runDialogue(900); + c.expect(d1, 'первый диалог Милы не завершился'); + const inv = await ctx.agent.waitFor( + '(s.inventory.find((i) => i.id === "cloth")?.count ?? 0) === 1', + { timeoutTicks: 300 } + ); + c.expect(inv.ok, 'полотно (do[] giveItem) не в сумке', inv.snapshot.inventory); + // Повторный разговор: квест-стадия-эпилог -> trader_after. + await ctx.agent.tapTile(17, 11); + const o2 = await ctx.agent.waitFor('s.dialogue?.id === "trader_after"', { timeoutTicks: 900 }); + c.expect(o2.ok, 'эпилог trader_after не открылся', o2.snapshot.dialogue); + const d2 = await ctx.agent.runDialogue(600); + c.expect(d2, 'эпилог не завершился'); + 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; +} \ No newline at end of file diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 63516fd..a19dd05 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -39,15 +39,19 @@ `LocationScene` отдаёт: `scene/area/areaName`, `hero {tile, pos, hp, facing, moving, invuln, inHazard}`, `enemies [{kind, state, hp, pos, asleep, dead}]`, `npcs`, -`transitions`, `dialogue {id, nodeId, text, choices, waitingForChoice} | null`, -`cutscene`, `lastToast {text, tick}` (единственный канал текста реакций — иначе -агенту нужен OCR), `collision {width, height, blocked (0/1 по тайлам, включает -footprint пропов), props}` — карта коллизий для проверки движения. `MenuScene` -отдаёт `{scene: 'menu'}` и команду `menu:newGame`. +`transitions`, `dialogue {id, nodeId, speaker, text, mood, tags, choices, path, +waitingForChoice} | null` (`path` — показанные узлы по порядку, `mood`/`tags` — +презентационные метаданные узла), `cutscene`, `lastToast {text, tick}` +(единственный канал текста реакций — иначе агенту нужен OCR), `collision +{width, height, blocked (0/1 по тайлам, включает footprint пропов), props}` — +карта коллизий для проверки движения. `MenuScene` отдаёт `{scene: 'menu'}` и +команду `menu:newGame`. Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках): `scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`, `scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A*), `scene:pickChoice {index}`, +`scene:pickChoiceByText {text}` (выбор варианта диалога по тексту реплики — +порядок вариантов знать не надо; false — такого варианта нет), `scene:skipCutscene`, `scene:noise {x, y, level}` (шум в тайле: 0.35 — бодрые слышат в hearRadius, 0.7+ — будит спящих), `scene:damageEnemy {id, value}` (урон сгустку — проверки отступления), `scene:walkable {x, y}` (проходим ли @@ -94,6 +98,7 @@ command(name, args?): JsonValue; walkTo(tx, ty, opts?): Promise; // маршрут A* + клики по узлам runDialogue(timeoutTicks?): Promise;// листает диалог до конца + pickChoiceByText(text, timeoutTicks?): Promise; // ждёт выбор, берёт по тексту newGame(): Promise; currentArea(): string | null; } diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 4b28d41..53fdcdb 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -78,7 +78,13 @@ забытая ветка), цикл без текста → `error`. Прогнать глазами: `npm run dialogues:dry []` — реплики на пресетах состояния + сироты/циклы. 3. Проверка через мост: `walkTo` до соседнего тайла → `tapTile(NPC)` → - `runDialogue()` → прочитать `flags`/`dialogue.text` из снапшота. + `runDialogue()` → прочитать `flags`/`dialogue.text` из снапшота + (`s.dialogue.path` — какие узлы реально показаны). Нюанс typewriter: + `advance` при открытой реплике сначала **догоняет печать**, а при + `waitingForChoice` **активирует курсор** (берёт подсвеченный вариант!) — + сценариям, идущим до конкретного выбора, надо проверять + `s.dialogue.waitingForChoice` перед каждым press и останавливаться. + Выбор в сценарии — `pickChoiceByText('текст')`, не по индексу. Условия ветки: `when`/`whenNot` — флаги, `whenVars` — несколько переменных (AND), `hasItem` — предметы. Предметы раннер знает только через @@ -207,6 +213,11 @@ вернёт `{ ok: false, error: 'неизвестный ключ…' }` (список — `AGENT_SNAPSHOT_KEYS`). Если ждёшь `ok: false` без `error` в деталях — смотрим `error`, а не таймаут. +6. Новый метод моста добавляется **в трёх местах**: `AgentApi`/`GameAgent` + + `registerAgent` (apps/game/src/agent/GameAgent.ts) и прокси `AgentClient` + (packages/engine/tools/agent-lib.mjs) — у клиента белый список методов, + без строки там сценарий получит «not a function». Полный цикл сюжета — + образец `checks/quest-bells.mjs` (квест «Три цветка» от взятия до эпилога). ## Ситуация: добавляю звук diff --git a/packages/engine/tools/agent-lib.mjs b/packages/engine/tools/agent-lib.mjs index a8d971e..2032689 100644 --- a/packages/engine/tools/agent-lib.mjs +++ b/packages/engine/tools/agent-lib.mjs @@ -120,7 +120,8 @@ key(code) { return this.call('key', code); } command(name, args) { return this.call('command', name, args); } walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); } - runDialogue() { return this.call('runDialogue'); } + runDialogue(timeoutTicks) { return this.call('runDialogue', timeoutTicks); } + pickChoiceByText(text, timeoutTicks) { return this.call('pickChoiceByText', text, timeoutTicks); } newGame() { return this.call('newGame'); } currentArea() { return this.call('currentArea'); }