diff --git a/CLAUDE.md b/CLAUDE.md index 46be25a..3078c6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ - `docs/world.md` — библия мира (сеттинг, локации, персонажи, сюжет). **Любой новый контент сверять с ней.** - `docs/art-style.md` — арт-библия: палитра (32 цвета), размеры спрайтов, правила стиля, чеклист ассетов. - `packages/engine/src/` — модули движка: `core/` (Engine, GameLoop, Tween, GameState, StateMachine, Settings, EventBus), `scene/` (SceneManager + fade-переходы), `render/` (Renderer, Camera, IsoDepthLayer, Particles, scale), `input/` (действия, геймпад, VirtualJoystick), `map/` (изометрия, A*, mapFormat, Tiled-импорт), `ui/` (PixelText/VT323, Panel, Button, MenuList, DialogueBox), `dialogue/` (DialogueRunner — view-агностик), `audio/` (шины master/music/sfx), `assets/` (AssetLoader + атласы), `save/`, `math/` (iso, rng, easing). -- `apps/game/src/data/` — весь контент: карта (`map.ts`), переходы и области (`locations.ts` — `TransitionDef`/`AREAS`), интерактивные объекты (`interactables.ts` — `INTERACTABLES`), диалоги (`dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах). +- `apps/game/src/data/` — весь контент: карта (`map.ts`), переходы и области (`locations.ts` — `TransitionDef`/`AREAS`), интерактивные объекты (`interactables.ts` — `INTERACTABLES`), диалоги (JSON-графы в `data/dialogues/`, реестр `dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах); `dialogueRules.ts` — чистые правила графов (общие с CLI `npm run dialogues:dry` и редактором). - `apps/game/src/agent/` — контентный слой агентного моста: `snapshot.ts` (сборка слоёв), `GameAgent.ts` (`window.__agent`, только DEV). - `apps/game/tools/` — **сценарии применения тулз под эту игру** (знают контент: палитру, тайлы, локации, сценарии акта): `agent.mjs` (диспетчер агентных проверок) + `lib.mjs` (маяк загрузки поверх движкового каркаса), `checks/`, `pixelart/` (`gen.mjs` + `palette.mjs`), `audio/gen.mjs`, `aiart/`, `maps/`, смоуки; `guards/boundary.mjs` — сценарий запуска гварда (`npm run guard`, входит в `agent:check`). - `packages/engine/tools/` — **жанронезависимые тулзы движка** (ничего не знают об игре, палитра/маяк/корни — параметры): `png.mjs` (PNG-кодек), `canvas.mjs` (мини-канвас, ASCII-карты), `imaging.mjs` (кроп/квантизация/обзорный лист), `wav.mjs` (WAV-энкодер + синтез), `agent-lib.mjs` (браузерный каркас агентных проверок), `boundary.mjs` (правила гварда + тесты). Импорт из игры — только через `@rpg/engine/tools/*` (см. `exports` движка). Правило: тулза знает контент игры — ей место в `apps/game/tools`; нет — в движке. diff --git a/apps/game/src/data/__tests__/dialogueRules.test.ts b/apps/game/src/data/__tests__/dialogueRules.test.ts new file mode 100644 index 0000000..7d78a48 --- /dev/null +++ b/apps/game/src/data/__tests__/dialogueRules.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { DialogueGraph } from '@rpg/engine'; +import { analyzeGraph, checkGraph, type GraphRefs } from '../dialogueRules'; + +const refs = (): GraphRefs => ({ + flags: new Set(['met_elder', 'quest_bells_taken']), + vars: new Set(['flowers']), + items: new Set(['cloth']), + customs: new Set(['plant_flowers']), + strings: new Set(['line.key']) +}); + +describe('checkGraph — ссылки и структура', () => { + it('чистый граф — ошибок нет', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { a: { text: 'Привет', next: 'b' }, b: { text: 'Пока' } } + }; + expect(checkGraph('g', g, refs()).filter((i) => i.severity === 'error')).toEqual([]); + }); + + it('битые ссылки: флаг, вар, предмет, custom, textKey, next, start', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { + text: 'x', + when: ['нет_такого'], + whenVars: [{ key: 'нет_вара', op: 'ge', value: 1 }], + hasItem: ['salt'], + do: [ + { kind: 'giveItem', id: 'нет_предмета' }, + { kind: 'custom', id: 'нет_эффекта' } + ], + textKey: 'нет.строки', + next: 'в_никуда' + } + } + }; + const ids = checkGraph('g', g, refs()).map((i) => i.id); + expect(ids).toContain('flag-unknown'); + expect(ids).toContain('var-unknown'); + expect(ids).toContain('item-unknown'); + expect(ids).toContain('do-item-unknown'); + expect(ids).toContain('do-custom-unknown'); + expect(ids).toContain('string-unknown'); + expect(ids).toContain('dialogue-next'); + // битый start + const ids2 = checkGraph('g', { start: 'нет', nodes: {} }, refs()).map((i) => i.id); + expect(ids2).toContain('dialogue-start'); + }); + + it('правильные ссылки проходят, упоминания пишутся в used-множества', () => { + const usedFlags = new Set(); + const usedVars = new Set(); + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { + when: ['met_elder'], + whenVars: [{ key: 'flowers', op: 'ge', value: 3 }], + setFlags: ['quest_bells_taken'], + do: [{ kind: 'giveItem', id: 'cloth' }, { kind: 'custom', id: 'plant_flowers' }], + text: 'ok' + } + } + }; + expect(checkGraph('g', g, { ...refs(), usedFlags, usedVars })).toEqual([]); + expect([...usedFlags]).toEqual(['met_elder', 'quest_bells_taken']); + expect([...usedVars]).toEqual(['flowers']); + }); +}); + +describe('analyzeGraph — достижимость', () => { + it('сироты и тихие концы', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { text: 'ok', next: 'silent' }, + lost: { text: 'никто не ссылается' }, + silent: { setFlags: ['met_elder'] } // нет next и текста — молча кончается + } + }; + const a = analyzeGraph(g); + expect(a.reachable).toEqual(['a', 'silent']); + expect(a.orphans).toEqual(['lost']); + expect(a.silentEnds).toEqual(['silent']); + }); + + it('цикл без текста — error, цикл с текстом — не цикл-ошибка', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { text: 'Слово', next: 'hub' }, + hub: { next: 'hub' } // действие само в себя + } + }; + const a = analyzeGraph(g); + expect(a.textlessCycles).toEqual([['hub']]); + expect(a.silentEnds).toEqual([]); + }); + + it('выборы считаются рёбрами: цель выбора достижима', () => { + const g: DialogueGraph = { + start: 'q', + nodes: { + q: { text: '?', choices: [{ text: 'Да', next: 'yes' }] }, + yes: { text: 'Да' } + } + }; + const a = analyzeGraph(g); + expect(a.orphans).toEqual([]); + expect(a.reachable).toEqual(['q', 'yes']); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/dialogueRules.ts b/apps/game/src/data/dialogueRules.ts new file mode 100644 index 0000000..b52caf6 --- /dev/null +++ b/apps/game/src/data/dialogueRules.ts @@ -0,0 +1,178 @@ +import type { DialogueChoice, DialogueGraph, DialogueNode, Invariant } from '@rpg/engine'; + +/** + * Чистые правила диалоговых графов: реестры — параметры (не импорты), поэтому + * один и тот же checkGraph работает в validate.ts (юнит-тесты, agent:invariants), + * в CLI dry-run и в сервере визуального редактора. Истина одна. + */ + +export interface GraphRefs { + /** Допустимые id: флаги, вары, предметы, custom-эффекты, диалоги, строки. */ + flags: ReadonlySet; + vars: ReadonlySet; + items: ReadonlySet; + customs: ReadonlySet; + strings: ReadonlySet; + /** Куда записать упоминания (для «мёртвых» сущностей реестра) — опционально. */ + usedFlags?: Set; + usedVars?: Set; +} + +/** Результат обхода графа: кто достижим и где подозрительные места. */ +export interface Reachability { + /** Достижимые от start узлы (BFS по next/choices). */ + reachable: string[]; + /** Узлы, в которые нет ни одной ссылки. */ + orphans: string[]; + /** Циклы из узлов без текста и без выборов (раннер обрывает по MAX_STEPS). */ + textlessCycles: string[][]; + /** Достижимые концы без реплики (диалог закончится молча). */ + silentEnds: string[]; +} + +/** Ссылки узла/выбора, которые надо сверить с реестрами. */ +function refsOf(n: DialogueNode | DialogueChoice): { + flags: string[]; + vars: string[]; + items: string[]; + doOps: NonNullable; + textKey?: string; +} { + return { + flags: [...(n.when ?? []), ...(n.whenNot ?? []), ...(n.setFlags ?? []), ...(n.clearFlags ?? [])], + vars: [ + ...(n.whenVar ? [n.whenVar.key] : []), + ...(n.whenVars ?? []).map((c) => c.key), + ...Object.keys(n.setVars ?? {}) + ], + items: [...(n.hasItem ?? [])], + doOps: n.do ?? [], + textKey: n.textKey + }; +} + +function checkSpot( + out: Invariant[], + where: string, + refs: GraphRefs, + spot: string, + n: DialogueNode | DialogueChoice, + next: string | undefined, + graph: DialogueGraph +): void { + const r = refsOf(n); + for (const f of r.flags) { + if (!refs.flags.has(f)) out.push({ id: 'flag-unknown', severity: 'error', message: `${spot}: флаг «${f}» вне реестра FLAGS`, where }); + refs.usedFlags?.add(f); + } + for (const v of r.vars) { + if (!refs.vars.has(v)) out.push({ id: 'var-unknown', severity: 'error', message: `${spot}: вар «${v}» вне реестра VARS`, where }); + refs.usedVars?.add(v); + } + for (const it of r.items) { + if (!refs.items.has(it)) out.push({ id: 'item-unknown', severity: 'error', message: `${spot}: предмет «${it}» вне реестра ITEMS`, where }); + } + for (const op of r.doOps) { + if ((op.kind === 'giveItem' || op.kind === 'takeItem') && (op.id === undefined || !refs.items.has(op.id))) { + out.push({ id: 'do-item-unknown', severity: 'error', message: `${spot}: do[].${op.kind} — предмет «${op.id}» вне реестра ITEMS`, where }); + } + if (op.kind === 'custom' && (op.id === undefined || !refs.customs.has(op.id))) { + out.push({ id: 'do-custom-unknown', severity: 'error', message: `${spot}: do[].custom — имя «${op.id}» вне реестра DIALOGUE_CUSTOM`, where }); + } + } + if (r.textKey !== undefined && !refs.strings.has(r.textKey)) { + out.push({ id: 'string-unknown', severity: 'error', message: `${spot}: textKey «${r.textKey}» вне реестра строк`, where }); + } + if (next !== undefined && !graph.nodes[next]) { + out.push({ id: 'dialogue-next', severity: 'error', message: `${spot}: next «${next}» не существует`, where }); + } +} + +/** Все правила одного графа: ссылки, next, сироты, циклы, тихие концы. */ +export function checkGraph(id: string, graph: DialogueGraph, refs: GraphRefs): Invariant[] { + const out: Invariant[] = []; + const where = `data/dialogue/${id}`; + if (!graph.nodes[graph.start]) { + out.push({ id: 'dialogue-start', severity: 'error', message: `start «${graph.start}» не существует`, where }); + } + for (const [nid, node] of Object.entries(graph.nodes)) { + checkSpot(out, where, refs, `узел «${nid}»`, node, node.next, graph); + (node.choices ?? []).forEach((c, i) => { + checkSpot(out, where, refs, `узел «${nid}», выбор#${i}`, c, c.next, graph); + }); + } + const a = analyzeGraph(graph); + for (const o of a.orphans) { + out.push({ id: 'dialogue-orphan', severity: 'warn', message: `узел «${o}» недостижим`, where }); + } + for (const cycle of a.textlessCycles) { + out.push({ id: 'textless-cycle', severity: 'error', message: `цикл без текста: ${cycle.join(' → ')}`, where }); + } + for (const end of a.silentEnds) { + out.push({ id: 'silent-end', severity: 'warn', message: `диалог может кончиться молча в узле «${end}»`, where }); + } + return out; +} + +/** Исходящие рёбра узла (next + next выборов). */ +function edgesOf(graph: DialogueGraph, id: string): string[] { + const n = graph.nodes[id]; + if (!n) return []; + const out = n.next !== undefined ? [n.next] : []; + for (const c of n.choices ?? []) { + if (c.next !== undefined) out.push(c.next); + } + return out; +} + +/** Обход графа от start: достижимость, сироты, текстовые циклы, тихие концы. */ +export function analyzeGraph(graph: DialogueGraph): Reachability { + const reachable = new Set(); + if (graph.nodes[graph.start]) { + const queue = [graph.start]; + while (queue.length > 0) { + const id = queue.shift()!; + if (reachable.has(id)) continue; + reachable.add(id); + for (const next of edgesOf(graph, id)) { + if (graph.nodes[next] && !reachable.has(next)) queue.push(next); + } + } + } + const orphans = Object.keys(graph.nodes).filter((id) => !reachable.has(id)); + + // Циклы без текста: DFS с окраской; цикл собираем из стека. + const color = new Map(); // 1 — в стеке, 2 — готово + const stack: string[] = []; + const cycles: string[][] = []; + const visit = (id: string): void => { + color.set(id, 1); + stack.push(id); + for (const next of edgesOf(graph, id)) { + if (!graph.nodes[next]) continue; + const c = color.get(next); + if (c === 1) { + const cycle = stack.slice(stack.indexOf(next)); + const textless = cycle.every((cid) => { + const n = graph.nodes[cid]!; + return n.text === undefined && n.textKey === undefined && (n.choices?.length ?? 0) === 0; + }); + if (textless) cycles.push(cycle); + } else if (c === undefined) { + visit(next); + } + } + stack.pop(); + color.set(id, 2); + }; + for (const id of reachable) { + if (!color.has(id)) visit(id); + } + + const silentEnds = [...reachable].filter((id) => { + const n = graph.nodes[id]!; + return n.next === undefined && (n.choices?.length ?? 0) === 0 && n.text === undefined && n.textKey === undefined; + }); + + return { reachable: [...reachable], orphans, textlessCycles: cycles, silentEnds }; +} \ No newline at end of file diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index 33cc27f..b83d003 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -1,115 +1,23 @@ import type { DialogueGraph } from '@rpg/engine'; -import { FLAGS, VARS } from './ids'; -import { DIALOGUE_CUSTOM } from './effects'; +import elderFirst from './dialogues/elder_first.json'; +import elderRepeat from './dialogues/elder_repeat.json'; +import elderHandIn from './dialogues/elder_hand_in.json'; +import traderFirst from './dialogues/trader_first.json'; +import traderRepeat from './dialogues/trader_repeat.json'; +import traderAfter from './dialogues/trader_after.json'; /** - * Диалоги NPC как графы для DialogueRunner (по docs/world.md, акт 1). - * Мила — безгласная: говорит шёпотом, коротко. Ирвин — экономит дыхание. - * Флаги и вары — только через реестры data/ids.ts. + * Диалоги NPC как графы (по docs/world.md, акт 1). Источник истины — JSON + * в data/dialogues/ (формат 4 пробела + \n): их правит и визуальный редактор, + * и человек руками. Флаги/вары/предметы — строками, сверяются с реестрами + * validate.ts (checkGraph). Мила — безгласная: шёпотом, коротко. + * Ирвин — экономит дыхание. */ export const DIALOGUES: Record = { - elder_first: { - start: 'greet', - nodes: { - greet: { - speaker: 'Старейшина Ирвин', - text: 'Вернулся. Хорошо. Слышал гул на закате? Это пепел дышит у прудов.', - next: 'ask' - }, - ask: { - speaker: 'Старейшина Ирвин', - text: 'Три поляны лунных колокольчиков там, у Серых прудов. Если не собрать цветы до выдоха — задохнутся.', - next: 'task' - }, - task: { - speaker: 'Старейшина Ирвин', - text: 'Собери. Посади здесь, на лугу. Поляна без цветов — поляна без завтра.', - next: 'give' - }, - // Узел-действие: выдача задания без реплики. - give: { setFlags: [FLAGS.met_elder, FLAGS.quest_bells_taken], next: 'ring' }, - ring: { speaker: 'Звонарь', text: 'Прозвоню дорогу до прудов и вернусь до темноты.' } - } - }, - - elder_repeat: { - start: 'waiting', - nodes: { - waiting: { speaker: 'Старейшина Ирвин', text: 'Цветы ждут у прудов, звонарь. А пепел не ждёт.' } - } - }, - - // Сдача квеста: выбирается в talkTo, когда собрано достаточно цветов. - elder_hand_in: { - start: 'count', - nodes: { - count: { - speaker: 'Старейшина Ирвин', - text: 'Три цветка. Живые. Сажай у тропы, звонарь. Серая земля примет.', - next: 'accept' - }, - accept: { - setFlags: [FLAGS.quest_bells_done], - do: [{ kind: 'custom', id: DIALOGUE_CUSTOM.plant_flowers }], - next: 'ring' - }, - ring: { speaker: 'Звонарь', text: 'Пусть гудят. Это твой голос, Ирвин, — теперь в земле.' } - } - }, - - trader_first: { - start: 'stop', - nodes: { - stop: { - speaker: 'Торговка Мила', - text: '*звонит ручным колокольчиком дважды* ...Стой.', - next: 'warn' - }, - warn: { - speaker: 'Торговка Мила', - text: 'У прудов... свежий накат. Дышать поверх — голос отдашь. Как я отдала.', - next: 'gift' - }, - gift: { - speaker: 'Торговка Мила', - text: 'Держи вощёное полотно. На губы. И звони тихо — пепел не буди.', - next: 'give_cloth' - }, - give_cloth: { - setFlags: [FLAGS.met_mila, FLAGS.got_cloth], - do: [{ kind: 'giveItem', id: 'cloth' }], - next: 'reply' - }, - reply: { speaker: 'Звонарь', text: 'Спасибо, Мила. Верну и полотно, и голос — твой точно.' } - } - }, - - // Повторная Мила: до сдачи квеста. Ветка по вару motes — видит моты в сумке. - trader_repeat: { - start: 'quiet', - nodes: { - quiet: { - speaker: 'Торговка Мила', - text: '*колокольчик один раз* ...Звони тихо. Пепел просыпается от гулкого.', - next: 'motes_note' - }, - motes_note: { - speaker: 'Торговка Мила', - text: '*замечает искру в сумке* ...Моты? Серая земля с искрой — редкость. Принеси три — поменяю на соль.', - whenVar: { key: VARS.motes, op: 'ge', value: 1 } - } - } - }, - - // Мила после посадки: крючок акта 1. - trader_after: { - start: 'listen', - nodes: { - listen: { - speaker: 'Торговка Мила', - text: '*смотрит под ноги* ...Слышишь? Гул снизу. Это разъезд. Это Машина дышит.', - end: true - } - } - } + elder_first: elderFirst as unknown as DialogueGraph, + elder_repeat: elderRepeat as unknown as DialogueGraph, + elder_hand_in: elderHandIn as unknown as DialogueGraph, + trader_first: traderFirst as unknown as DialogueGraph, + trader_repeat: traderRepeat as unknown as DialogueGraph, + trader_after: traderAfter as unknown as DialogueGraph }; \ No newline at end of file diff --git a/apps/game/src/data/dialogues/elder_first.json b/apps/game/src/data/dialogues/elder_first.json new file mode 100644 index 0000000..4aeb6d8 --- /dev/null +++ b/apps/game/src/data/dialogues/elder_first.json @@ -0,0 +1,28 @@ +{ + "start": "greet", + "nodes": { + "greet": { + "speaker": "Старейшина Ирвин", + "text": "Вернулся. Хорошо. Слышал гул на закате? Это пепел дышит у прудов.", + "next": "ask" + }, + "ask": { + "speaker": "Старейшина Ирвин", + "text": "Три поляны лунных колокольчиков там, у Серых прудов. Если не собрать цветы до выдоха — задохнутся.", + "next": "task" + }, + "task": { + "speaker": "Старейшина Ирвин", + "text": "Собери. Посади здесь, на лугу. Поляна без цветов — поляна без завтра.", + "next": "give" + }, + "give": { + "setFlags": ["met_elder", "quest_bells_taken"], + "next": "ring" + }, + "ring": { + "speaker": "Звонарь", + "text": "Прозвоню дорогу до прудов и вернусь до темноты." + } + } +} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/elder_hand_in.json b/apps/game/src/data/dialogues/elder_hand_in.json new file mode 100644 index 0000000..afe62f1 --- /dev/null +++ b/apps/game/src/data/dialogues/elder_hand_in.json @@ -0,0 +1,19 @@ +{ + "start": "count", + "nodes": { + "count": { + "speaker": "Старейшина Ирвин", + "text": "Три цветка. Живые. Сажай у тропы, звонарь. Серая земля примет.", + "next": "accept" + }, + "accept": { + "setFlags": ["quest_bells_done"], + "do": [{ "kind": "custom", "id": "plant_flowers" }], + "next": "ring" + }, + "ring": { + "speaker": "Звонарь", + "text": "Пусть гудят. Это твой голос, Ирвин, — теперь в земле." + } + } +} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/elder_repeat.json b/apps/game/src/data/dialogues/elder_repeat.json new file mode 100644 index 0000000..f0b962e --- /dev/null +++ b/apps/game/src/data/dialogues/elder_repeat.json @@ -0,0 +1,9 @@ +{ + "start": "waiting", + "nodes": { + "waiting": { + "speaker": "Старейшина Ирвин", + "text": "Цветы ждут у прудов, звонарь. А пепел не ждёт." + } + } +} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/trader_after.json b/apps/game/src/data/dialogues/trader_after.json new file mode 100644 index 0000000..62d291e --- /dev/null +++ b/apps/game/src/data/dialogues/trader_after.json @@ -0,0 +1,10 @@ +{ + "start": "listen", + "nodes": { + "listen": { + "speaker": "Торговка Мила", + "text": "*смотрит под ноги* ...Слышишь? Гул снизу. Это разъезд. Это Машина дышит.", + "end": true + } + } +} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/trader_first.json b/apps/game/src/data/dialogues/trader_first.json new file mode 100644 index 0000000..fff3661 --- /dev/null +++ b/apps/game/src/data/dialogues/trader_first.json @@ -0,0 +1,29 @@ +{ + "start": "stop", + "nodes": { + "stop": { + "speaker": "Торговка Мила", + "text": "*звонит ручным колокольчиком дважды* ...Стой.", + "next": "warn" + }, + "warn": { + "speaker": "Торговка Мила", + "text": "У прудов... свежий накат. Дышать поверх — голос отдашь. Как я отдала.", + "next": "gift" + }, + "gift": { + "speaker": "Торговка Мила", + "text": "Держи вощёное полотно. На губы. И звони тихо — пепел не буди.", + "next": "give_cloth" + }, + "give_cloth": { + "setFlags": ["met_mila", "got_cloth"], + "do": [{ "kind": "giveItem", "id": "cloth" }], + "next": "reply" + }, + "reply": { + "speaker": "Звонарь", + "text": "Спасибо, Мила. Верну и полотно, и голос — твой точно." + } + } +} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/trader_repeat.json b/apps/game/src/data/dialogues/trader_repeat.json new file mode 100644 index 0000000..1e6ce5c --- /dev/null +++ b/apps/game/src/data/dialogues/trader_repeat.json @@ -0,0 +1,15 @@ +{ + "start": "quiet", + "nodes": { + "quiet": { + "speaker": "Торговка Мила", + "text": "*колокольчик один раз* ...Звони тихо. Пепел просыпается от гулкого.", + "next": "motes_note" + }, + "motes_note": { + "speaker": "Торговка Мила", + "text": "*замечает искру в сумке* ...Моты? Серая земля с искрой — редкость. Принеси три — поменяю на соль.", + "whenVar": { "key": "motes", "op": "ge", "value": 1 } + } + } +} \ No newline at end of file diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 3df0df3..7891609 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -15,43 +15,34 @@ import { FLAGS, VARS } from './ids'; import { ITEMS } from './items'; import { DIALOGUE_CUSTOM } from './effects'; +import { checkGraph, type GraphRefs } from './dialogueRules'; /** * Runtime-валидация контента → инварианты (замена JSON Schema: истина одна — * в TS-типах; здесь ловим то, что типы не выражают: ссылки, границы, стены). * Вызывается из GameAgent.invariants() и из юнит-теста — битый контент падает - * сразу в тестах, а не в рантайме игры. + * сразу в тестах, а не в рантайме игры. Правила графов диалогов — общие с CLI + * dry-run и редактором (data/dialogueRules.ts). */ const WHERE = 'data'; -/** Граф диалога: start/next/choices существуют, узлы-сироты (warn). */ +/** Реестры для checkGraph (чистые правила знают только множества id). */ +export function graphRefs(usedFlags?: Set, usedVars?: Set): GraphRefs { + return { + flags: new Set(Object.keys(FLAGS)), + vars: new Set(Object.keys(VARS)), + items: new Set(Object.keys(ITEMS)), + customs: new Set(Object.keys(DIALOGUE_CUSTOM)), + strings: new Set(), + usedFlags, + usedVars + }; +} + +/** Граф диалога: все правила checkGraph (ссылки, next, сироты, циклы, концы). */ export function validateDialogue(id: string, graph: DialogueGraph): Invariant[] { - const out: Invariant[] = []; - const where = `${WHERE}/dialogue/${id}`; - if (!graph.nodes[graph.start]) { - out.push({ id: 'dialogue-start', severity: 'error', message: `start «${graph.start}» не существует`, where }); - } - for (const [nodeId, node] of Object.entries(graph.nodes)) { - for (const next of (node.choices ?? []).map((c) => c.next).concat(node.next)) { - if (next !== undefined && !graph.nodes[next]) { - out.push({ id: 'dialogue-next', severity: 'error', message: `узел «${nodeId}»: next «${next}» не существует`, where }); - } - } - } - // Сироты: узлы, в которые нет ссылки (warn — могут быть «мёртвым ветвлением»). - const referenced = new Set([graph.start]); - for (const node of Object.values(graph.nodes)) { - for (const next of (node.choices ?? []).map((c) => c.next).concat(node.next)) { - if (next !== undefined) referenced.add(next); - } - } - for (const nodeId of Object.keys(graph.nodes)) { - if (!referenced.has(nodeId)) { - out.push({ id: 'dialogue-orphan', severity: 'warn', message: `узел «${nodeId}» недостижим`, where }); - } - } - return out; + return checkGraph(id, graph, graphRefs()); } /** NPC: не в стенах, в границах своей области. */ @@ -150,29 +141,9 @@ } }; + // Графы диалогов — общие правила checkGraph (и упоминания для dead-проверки). for (const [id, graph] of Object.entries(DIALOGUES)) { - const where = `${WHERE}/dialogue/${id}`; - for (const node of Object.values(graph.nodes)) { - const nodes = [node, ...(node.choices ?? [])]; - for (const n of nodes) { - for (const f of n.setFlags ?? []) checkFlag(f, where, `setFlags`); - for (const f of n.clearFlags ?? []) checkFlag(f, where, `clearFlags`); - for (const f of n.when ?? []) checkFlag(f, where, `when`); - for (const f of n.whenNot ?? []) checkFlag(f, where, `whenNot`); - if (n.whenVar) checkVar(n.whenVar.key, where, 'whenVar.key'); - for (const op of n.do ?? []) { - if (op.kind === 'giveItem' || op.kind === 'takeItem') { - if (op.id === undefined || !(op.id in ITEMS)) { - out.push({ id: 'do-item-unknown', severity: 'error', message: `do[].${op.kind}: предмет «${op.id}» вне реестра ITEMS`, where }); - } - } else if (op.kind === 'custom') { - if (op.id === undefined || !(op.id in DIALOGUE_CUSTOM)) { - out.push({ id: 'do-custom-unknown', severity: 'error', message: `do[].custom: имя «${op.id}» вне реестра DIALOGUE_CUSTOM`, where }); - } - } - } - } - } + out.push(...checkGraph(id, graph, graphRefs(flagsUsed, varsUsed))); } for (const area of Object.values(AREAS)) { const where = `${WHERE}/area/${area.id}`; @@ -201,6 +172,21 @@ } if (stage.dialogue !== undefined && !(stage.dialogue in DIALOGUES)) { out.push({ id: 'dialogue-ref', severity: 'error', message: `стадия#${i}: диалог «${stage.dialogue}» не существует`, where }); + continue; + } + // Стадия с doneFlag должна выставляться setFlags в графе стадии — + // иначе диалог никогда её не завершит. + if (stage.doneFlag !== undefined && stage.dialogue !== undefined) { + const graph = DIALOGUES[stage.dialogue]; + const sets = new Set(); + for (const node of Object.values(graph.nodes)) { + for (const n of [node, ...(node.choices ?? [])]) { + for (const f of n.setFlags ?? []) sets.add(f); + } + } + if (!sets.has(stage.doneFlag)) { + out.push({ id: 'quest-stage-unreachable', severity: 'error', message: `стадия#${i}: doneFlag «${stage.doneFlag}» не выставляется в графе «${stage.dialogue}»`, where }); + } } } } diff --git a/apps/game/tools/dialogues/dry-run.ts b/apps/game/tools/dialogues/dry-run.ts new file mode 100644 index 0000000..4e75f46 --- /dev/null +++ b/apps/game/tools/dialogues/dry-run.ts @@ -0,0 +1,83 @@ +/** + * CLI dry-run диалогов: прогон графов через DialogueRunner на реальном + * GameState (без Pixi) + структурный анализ. Печатает дерево «узел → + * выборы → куда», сироты, циклы, тихие концы и битые ссылки. + * + * Запуск: npm run dialogues:dry [-- ] + * (vite-node — чтобы работал TS/JSON-импорт из src.) + */ +import { GameState, DialogueRunner } from '@rpg/engine'; +import { DIALOGUES } from '../../src/data/dialogues'; +import { analyzeGraph, checkGraph, type GraphRefs } from '../../src/data/dialogueRules'; +import { FLAGS, VARS } from '../../src/data/ids'; +import { ITEMS } from '../../src/data/items'; +import { DIALOGUE_CUSTOM } from '../../src/data/effects'; + +/** Реестры игры для checkGraph (те же множества, что в validate.ts). */ +function refs(): GraphRefs { + return { + flags: new Set(Object.keys(FLAGS)), + vars: new Set(Object.keys(VARS)), + items: new Set(Object.keys(ITEMS)), + customs: new Set(Object.keys(DIALOGUE_CUSTOM)), + strings: new Set() + }; +} + +/** Пресеты состояния: в каких условиях гоняем каждый граф. */ +const PRESETS: { name: string; setup: (s: GameState) => void }[] = [ + { name: 'fresh', setup: () => {} }, + { + name: 'quest-taken+flowers', + setup: (s) => { + s.setFlag('quest_bells_taken'); + s.setFlag('met_elder'); + s.setVar('flowers', 3); + } + }, + { + name: 'quest-done', + setup: (s) => { + s.setFlag('quest_bells_taken'); + s.setFlag('met_elder'); + s.setFlag('quest_bells_done'); + } + } +]; + +/** Дерево реплик: что покажет раннер и куда можно пойти — см. main(). */ + +const main = (): void => { + const only = process.argv[2]; + let errors = 0; + for (const [id, graph] of Object.entries(DIALOGUES)) { + if (only && id !== only) continue; + console.log(`\n=== ${id} (start: ${graph.start}) ===`); + const a = analyzeGraph(graph); + const bad = checkGraph(id, graph, refs()).filter((i) => i.severity === 'error'); + errors += bad.length; + for (const inv of bad) console.log(` [${inv.severity}] ${inv.id}: ${inv.message}`); + for (const o of a.orphans) console.log(` [warn] сирота: «${o}»`); + for (const cycle of a.textlessCycles) console.log(` [error] цикл без текста: ${cycle.join(' → ')}`); + for (const end of a.silentEnds) console.log(` [warn] тихий конец: «${end}»`); + + // Прогон на каждом пресете: какие реплики реально покажутся. + for (const preset of PRESETS) { + const state = new GameState(); + preset.setup(state); + const runner = new DialogueRunner(state); + const shown: string[] = []; + runner.setView({ + show: (n) => shown.push(`${n.speaker ? n.speaker + ': ' : ''}${n.text}` + + (n.choices.length > 0 ? ` [${n.choices.map((c) => c.text).join(' | ')}]` : '')), + hide: () => {} + }); + runner.start(graph); + while (runner.active && !runner.waitingForChoice) runner.advance(); + console.log(` [${preset.name}] ${shown.join(' → ') || '(молча)'}`); + } + } + if (errors > 0) process.exitCode = 1; +}; + +main(); \ No newline at end of file diff --git a/apps/game/tsconfig.json b/apps/game/tsconfig.json index 282e669..3bc3728 100644 --- a/apps/game/tsconfig.json +++ b/apps/game/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "types": ["vite/client"] + "types": ["vite/client"], + "resolveJsonModule": true }, "include": ["src", "../engine/src"] } \ No newline at end of file diff --git a/docs/engine/practices.md b/docs/engine/practices.md index a66dbc3..4b28d41 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -69,9 +69,14 @@ ## Ситуация: добавляю NPC/диалог -1. `NpcDef` в `data/npcs.ts`, граф в `data/dialogues.ts`. -2. Валидатор проверит: несуществующие `next`/`choices` → `error`, недостижимые - узлы → `warn` (сироты допустимы, но проверь, что это не забытая ветка). +1. `NpcDef` в `data/npcs.ts`, граф — JSON в `data/dialogues/.json` + (источник истины, формат 4 пробела + `\n`), ключ — в реестр + `data/dialogues.ts`. +2. Правила графов общие (`data/dialogueRules.ts`): валидатор проверит + несуществующие `next`/`choices` и ссылки вне реестров → `error`, + недостижимые узлы → `warn` (сироты допустимы, но проверь, что это не + забытая ветка), цикл без текста → `error`. Прогнать глазами: + `npm run dialogues:dry []` — реплики на пресетах состояния + сироты/циклы. 3. Проверка через мост: `walkTo` до соседнего тайла → `tapTile(NPC)` → `runDialogue()` → прочитать `flags`/`dialogue.text` из снапшота. diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index 8eab16e..1be9156 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -230,6 +230,28 @@ runner.setView(view); // можно заменить в любой момент ``` +## Графы в JSON и dry-run + +Графы игры лежат в JSON (`data/dialogues/*.json`, реестр — `dialogues.ts`): +их можно править руками или визуальным редактором, формат — 4 пробела + `\n`. +Правила графов — чистые функции `data/dialogueRules.ts` (реестры — параметры, +одна истина для валидатора, CLI и редактора): + +```ts +import { checkGraph, analyzeGraph, type GraphRefs } from '.../data/dialogueRules'; + +const refs: GraphRefs = { flags: new Set(Object.keys(FLAGS)), vars: ..., items: ..., customs: ..., strings: new Set() }; +checkGraph('elder_first', graph, refs); // Invariant[]: ссылки, next, сироты (warn), циклы без текста (error), тихие концы (warn) +analyzeGraph(graph); // { reachable, orphans, textlessCycles, silentEnds } +``` + +Прогон глазами — `npm run dialogues:dry []`: каждый граф гоняется через +DialogueRunner на пресетах состояния (fresh / quest-taken+flowers / quest-done) +и печатает реплики, сироты, циклы и битые ссылки (exit 1 при ошибках). +Новые инварианты валидатора: `textless-cycle` (error), `silent-end` (warn), +`string-unknown` (textKey вне реестра строк), `quest-stage-unreachable` +(doneFlag стадии не выставляется setFlags в графе стадии). + ## DialogueBox Готовая нижняя панель для реплик (рисует имя, текст, варианты, подсказку diff --git a/package.json b/package.json index 6c23536..b0c232c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "art:lint": "node apps/game/tools/aiart/lint-art.mjs $(find apps/game/assets -name '*.png')", "audio": "node apps/game/tools/audio/gen.mjs", "maps": "vitest run apps/game/tools/maps/gen.test.ts", + "dialogues:dry": "vite-node apps/game/tools/dialogues/dry-run.ts", "guard": "node apps/game/tools/guards/boundary.mjs", "test": "vitest run", "test:watch": "vitest",