diff --git a/docs/engine/practices.md b/docs/engine/practices.md index dd6d832..b70ee71 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -75,6 +75,13 @@ 3. Проверка через мост: `walkTo` до соседнего тайла → `tapTile(NPC)` → `runDialogue()` → прочитать `flags`/`dialogue.text` из снапшота. +Условия ветки: `when`/`whenNot` — флаги, `whenVars` — несколько переменных +(AND), `hasItem` — предметы. Предметы раннер знает только через +`DialogueWorld` (игра передаёт `setWorld`) — без него `hasItem`-ветки +скрыты. Условие нужно **вне диалога** (квест-стадия, фильтр контента) — +не пиши замыкание `ready: (s) => ...`: вызывай чистый `evalConditions(c, +state, world)` — то же правило, что проверяет граф, и валидатор его видит. + ## Ситуация: упал смоук / проверка См. `agent.md` «waitFor и переходы сцен». Частые причины, по частоте: diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index 849cdd0..ac5dbe4 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -162,8 +162,24 @@ - `choices` — варианты игрока; показываются только прошедшие условия; `pick(index)` применяет эффекты выбора и идёт по его `next`. - Условия на узлах и выборах: `when` (все флаги установлены), `whenNot` - (ни один не установлен), `whenVar: { key, op, value }` (eq/ne/gt/lt/ge/le). + (ни один не установлен), `whenVar: { key, op, value }` (eq/ne/gt/lt/ge/le), + `whenVars` (несколько переменных, AND), `hasItem` (все предметы в сумке). Не прошедший условие узел пропускается: диалог уходит по его `next` или завершается. +- Предметы движок знать не может: `hasItem` резолвится через `DialogueWorld` + (`{ hasItem(id): boolean }`), который игра передаёт третьим аргументом + конструктора или `setWorld()`. Без world такие условия ложны. + +### evalConditions: проверка условий без рантайма + +Условия — чистые данные, и проверять их можно без запуска диалога — +для квестовых стадий, dry-run и валидатора: + +```ts +import { evalConditions, type DialogueConditions, type DialogueWorld } from '@rpg/engine'; + +const ready: DialogueConditions = { when: ['quest_bells_taken'], whenVars: [{ key: 'flowers', op: 'ge', value: 3 }] }; +if (evalConditions(ready, gameState, world)) { /* ветка доступна */ } +``` - Эффекты (`setFlags`, `clearFlags`, `setVars`) применяются при входе в узел и при выборе варианта. - Циклы из узлов без текста обрываются безопасно (MAX_STEPS). diff --git a/packages/engine/src/dialogue/DialogueRunner.ts b/packages/engine/src/dialogue/DialogueRunner.ts index e39ac19..0b21553 100644 --- a/packages/engine/src/dialogue/DialogueRunner.ts +++ b/packages/engine/src/dialogue/DialogueRunner.ts @@ -20,7 +20,15 @@ setVars?: Record; } -/** Условия показа узла/варианта. */ +/** + * Предикаты мира, которых движок знать не может (сумка, репутация...). + * Игра передаёт реализацию в раннер; без неё hasItem-условия ложны. + */ +export interface DialogueWorld { + hasItem(id: string): boolean; +} + +/** Условия показа узла/варианта (все перечисленные группы — AND). */ export interface DialogueConditions { /** Все эти флаги должны быть установлены. */ when?: string[]; @@ -28,6 +36,10 @@ whenNot?: string[]; /** Условие по переменной. */ whenVar?: VarCondition; + /** Несколько условий по переменным, AND. */ + whenVars?: VarCondition[]; + /** Все эти предметы должны быть в сумке (резолв — DialogueWorld). */ + hasItem?: string[]; } export interface DialogueChoice extends DialogueEffects, DialogueConditions { @@ -55,6 +67,56 @@ nodes: Record; } +/** Есть ли в условиях хоть что-то для проверки. */ +export function hasConditions(c: DialogueConditions): boolean { + return Boolean( + c.when?.length || c.whenNot?.length || c.whenVar || c.whenVars?.length || c.hasItem?.length + ); +} + +/** Сравнение переменной по op; undefined (переменной нет) не проходит gt/lt/ge/le. */ +function checkVar(state: GameState, cond: VarCondition): boolean { + const v = state.getVar(cond.key); + switch (cond.op) { + case 'eq': + return v === cond.value; + case 'ne': + return v !== cond.value; + case 'gt': + return Number(v) > Number(cond.value); + case 'lt': + return Number(v) < Number(cond.value); + case 'ge': + return Number(v) >= Number(cond.value); + case 'le': + return Number(v) <= Number(cond.value); + } +} + +/** + * Чистая проверка условий узла/выбора без рантайма: удобна для квестовых + * стадий, dry-run и валидатора. world не задан → hasItem ложен. + */ +export function evalConditions(c: DialogueConditions, state: GameState, world?: DialogueWorld): boolean { + for (const f of c.when ?? []) { + if (!state.hasFlag(f)) return false; + } + for (const f of c.whenNot ?? []) { + if (state.hasFlag(f)) return false; + } + if (c.whenVar && !checkVar(state, c.whenVar)) return false; + for (const cond of c.whenVars ?? []) { + if (!checkVar(state, cond)) return false; + } + if (c.hasItem) { + if (!world) return false; + for (const id of c.hasItem) { + if (!world.hasItem(id)) return false; + } + } + return true; +} + /** Вид: игра рисует реплику и варианты своими средствами. */ export interface DialogueView { /** Показать реплику. choices пуст, если выбора нет. */ @@ -76,7 +138,8 @@ constructor( private state: GameState, - view?: DialogueView + view?: DialogueView, + private world?: DialogueWorld ) { this.view = view ?? null; } @@ -86,6 +149,11 @@ this.view = view; } + /** Подключить/заменить предикаты мира (сумка и т.п.). */ + setWorld(world: DialogueWorld): void { + this.world = world; + } + get active(): boolean { return this.graph !== null; } @@ -185,7 +253,7 @@ for (let steps = 0; current && steps < MAX_STEPS; steps++) { const n: DialogueNode = current.node; - if (n.when || n.whenNot || n.whenVar) { + if (hasConditions(n)) { if (!this.checkConditions(n)) { // условие не прошло — уходим по next или заканчиваем if (n.next !== undefined) { @@ -273,36 +341,6 @@ } private checkConditions(c: DialogueConditions): boolean { - for (const f of c.when ?? []) { - if (!this.state.hasFlag(f)) return false; - } - for (const f of c.whenNot ?? []) { - if (this.state.hasFlag(f)) return false; - } - if (c.whenVar) { - const v = this.state.getVar(c.whenVar.key); - const { op, value } = c.whenVar; - switch (op) { - case 'eq': - if (v !== value) return false; - break; - case 'ne': - if (v === value) return false; - break; - case 'gt': - if (!(Number(v) > Number(value))) return false; - break; - case 'lt': - if (!(Number(v) < Number(value))) return false; - break; - case 'ge': - if (!(Number(v) >= Number(value))) return false; - break; - case 'le': - if (!(Number(v) <= Number(value))) return false; - break; - } - } - return true; + return evalConditions(c, this.state, this.world); } } \ No newline at end of file diff --git a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts index 29614e3..977fcdb 100644 --- a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts +++ b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { DialogueRunner, type DialogueView } from '../DialogueRunner'; +import { DialogueRunner, evalConditions, type DialogueView } from '../DialogueRunner'; import { GameState } from '../../core/GameState'; /** Тестовый view: запоминает показанные реплики. */ @@ -215,4 +215,120 @@ }); expect(dr.active).toBe(false); // безопасно завершился }); + + it('whenVars: несколько условий по переменным, AND', () => { + const state = new GameState(); + state.setVar('gold', 5); + state.setVar('trust', 2); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'q', + nodes: { + q: { + text: 'Торговец:', + choices: [ + { + text: 'Дорогой товар', + whenVars: [ + { key: 'gold', op: 'ge', value: 10 }, + { key: 'trust', op: 'ge', value: 1 } + ], + next: 'buy' + }, + { text: 'Уйти', next: 'bye' } + ] + }, + buy: { text: 'Куплено.' }, + bye: { text: 'Пока.' } + } + }); + // gold < 10 — первая ветка скрыта, хотя trust проходит + expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']); + state.setVar('gold', 50); + dr.start({ + start: 'q', + nodes: { + q: { + text: 'Торговец:', + choices: [ + { + text: 'Дорогой товар', + whenVars: [ + { key: 'gold', op: 'ge', value: 10 }, + { key: 'trust', op: 'ge', value: 1 } + ], + next: 'buy' + }, + { text: 'Уйти', next: 'bye' } + ] + }, + buy: { text: 'Куплено.' }, + bye: { text: 'Пока.' } + } + }); + expect(shown[1]?.choices.map((c) => c.text)).toEqual(['Дорогой товар', 'Уйти']); + }); + + it('hasItem резолвится через DialogueWorld; без world — ложно', () => { + const state = new GameState(); + const world = { hasItem: (id: string) => id === 'cloth' }; + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view, world); + dr.start({ + start: 'q', + nodes: { + q: { + text: 'Торговец:', + choices: [ + { text: 'Продать ткань', hasItem: ['cloth'], next: 'sell' }, + { text: 'Продать соль', hasItem: ['salt'], next: 'nope' }, + { text: 'Уйти', next: 'bye' } + ] + }, + sell: { text: 'Продано.' }, + nope: { text: 'Нет соли.' }, + bye: { text: 'Пока.' } + } + }); + expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Продать ткань', 'Уйти']); + + // узел-выбор без текста и без world: hasItem-варианты скрыты — конец диалога + const dr2 = new DialogueRunner(state, makeView().view); + dr2.start({ + start: 'q', + nodes: { + q: { choices: [{ text: 'Продать ткань', hasItem: ['cloth'], next: 'sell' }] }, + sell: { text: 'Продано.' } + } + }); + expect(dr2.active).toBe(false); // нет доступных вариантов — конец + }); + + it('evalConditions работает без рантайма на чистом GameState', () => { + const state = new GameState(); + state.setFlag('met_elder'); + state.setVar('flowers', 3); + const world = { hasItem: (id: string) => id === 'cloth' }; + + expect(evalConditions({ when: ['met_elder'] }, state)).toBe(true); + expect(evalConditions({ when: ['met_elder'], whenNot: ['quest_done'] }, state)).toBe(true); + expect(evalConditions({ when: ['quest_done'] }, state)).toBe(false); + expect( + evalConditions( + { whenVars: [{ key: 'flowers', op: 'ge', value: 3 }] }, + state, + world + ) + ).toBe(true); + expect( + evalConditions( + { whenVars: [{ key: 'flowers', op: 'ge', value: 4 }] }, + state + ) + ).toBe(false); + expect(evalConditions({ hasItem: ['cloth'] }, state, world)).toBe(true); + expect(evalConditions({ hasItem: ['cloth'] }, state)).toBe(false); // нет world + expect(evalConditions({}, state)).toBe(true); // пустые условия — истина + }); }); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 31ac1c0..6883426 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -163,10 +163,16 @@ // dialogue export { DialogueRunner, + evalConditions, + hasConditions, type DialogueGraph, type DialogueNode, type DialogueChoice, - type DialogueView + type DialogueView, + type DialogueWorld, + type DialogueConditions, + type DialogueEffects, + type VarCondition } from './dialogue/DialogueRunner'; // ui