From 89dbf9f8ffdd9d2f0e27ef5e7fef5c9bc33574c1 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Mon, 27 Apr 2026 12:25:09 +0000 Subject: [PATCH] RPG Quest system --- src/common/rpg/conditions.ts | 65 +++++++++++++++++++++ src/common/rpg/dialog.ts | 92 +++++++----------------------- src/common/rpg/quest.ts | 106 +++++++++++++++++++++++++++++++++++ src/common/rpg/types.ts | 65 +++++++++++++++++++-- 4 files changed, 249 insertions(+), 79 deletions(-) create mode 100644 src/common/rpg/conditions.ts create mode 100644 src/common/rpg/quest.ts diff --git a/src/common/rpg/conditions.ts b/src/common/rpg/conditions.ts new file mode 100644 index 0000000..d1298c0 --- /dev/null +++ b/src/common/rpg/conditions.ts @@ -0,0 +1,65 @@ +import type { RPGCondition, RPGVariables } from "./types"; + +type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null'; +type ConditionValue = string | number | boolean | null; + +export interface ParsedCondition { + variable: string; + negate: boolean; + operator?: ConditionOperator; + value?: ConditionValue; +} + +export function parseCondition(s: RPGCondition): ParsedCondition { + // ~variable — falsy check, nothing else allowed + if (s.startsWith('~') && !s.includes(' ')) + return { variable: s.slice(1), negate: true }; + + const spaceIdx = s.indexOf(' '); + if (spaceIdx === -1) + return { variable: s, negate: false }; + + const variable = s.slice(0, spaceIdx); + const rest = s.slice(spaceIdx + 1).trim(); + + if (rest === 'null') return { variable, negate: false, operator: 'null' }; + if (rest === '~null') return { variable, negate: false, operator: '~null' }; + + const opMatch = rest.match(/^(==|!=|>=|<=|>|<)\s*(.+)$/); + if (!opMatch) throw new Error(`Invalid condition: "${s}"`); + + const [, operator, rawValue] = opMatch; + let value: ConditionValue; + if (rawValue === 'null') value = null; + else if (rawValue === 'true') value = true; + else if (rawValue === 'false') value = false; + else if (!isNaN(Number(rawValue))) value = Number(rawValue); + else value = rawValue.replace(/^['"]|['"]$/g, ''); + + return { variable, negate: false, operator: operator as ConditionOperator, value }; +} + +export function evaluateCondition({ variable, negate, operator, value }: ParsedCondition, variables: RPGVariables): boolean { + const val = variables[variable]; + + if (operator === 'null') return val == null; + if (operator === '~null') return val != null; + + if (operator === undefined) + return negate ? !Boolean(val) : Boolean(val); + + if (operator === '==') return val === value; + if (operator === '!=') return val !== value; + + if (typeof val !== 'number' || typeof value !== 'number') return false; + if (operator === '<') return val < value; + if (operator === '>') return val > value; + if (operator === '<=') return val <= value; + if (operator === '>=') return val >= value; + + return false; +} + +export function evaluateConditions(conditions: RPGCondition[], variables: RPGVariables): boolean { + return conditions.every(c => evaluateCondition(parseCondition(c), variables)); +} diff --git a/src/common/rpg/dialog.ts b/src/common/rpg/dialog.ts index 9ba1cda..db080e1 100644 --- a/src/common/rpg/dialog.ts +++ b/src/common/rpg/dialog.ts @@ -1,50 +1,21 @@ -import { isDialog, type Dialog, type DialogChoice, type DialogNode } from "./types"; +import { + isDialog, + type Dialog, + type DialogChoice, + type DialogNode, + type RPGActions, + type RPGVariables, +} from "./types"; -type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null'; -type ConditionValue = string | number | boolean | null; - -interface ParsedCondition { - variable: string; - negate: boolean; - operator?: ConditionOperator; - value?: ConditionValue; -} - -function parseCondition(s: string): ParsedCondition { - // ~variable — falsy check, nothing else allowed - if (s.startsWith('~') && !s.includes(' ')) - return { variable: s.slice(1), negate: true }; - - const spaceIdx = s.indexOf(' '); - if (spaceIdx === -1) - return { variable: s, negate: false }; - - const variable = s.slice(0, spaceIdx); - const rest = s.slice(spaceIdx + 1).trim(); - - if (rest === 'null') return { variable, negate: false, operator: 'null' }; - if (rest === '~null') return { variable, negate: false, operator: '~null' }; - - const opMatch = rest.match(/^(==|!=|>=|<=|>|<)\s*(.+)$/); - if (!opMatch) throw new Error(`Invalid condition: "${s}"`); - - const [, operator, rawValue] = opMatch; - let value: ConditionValue; - if (rawValue === 'null') value = null; - else if (rawValue === 'true') value = true; - else if (rawValue === 'false') value = false; - else if (!isNaN(Number(rawValue))) value = Number(rawValue); - else value = rawValue.replace(/^['"]|['"]$/g, ''); - - return { variable, negate: false, operator: operator as ConditionOperator, value }; -} - -export type DialogVariables = Record; -export type DialogActions = Record Promise | void>; +import { + evaluateConditions, + parseCondition, + type ParsedCondition, +} from "./conditions"; export interface DialogRuntimeOptions { - variables: DialogVariables; - actions: DialogActions; + variables: RPGVariables; + actions: RPGActions; } export interface DialogNodeView { @@ -194,7 +165,7 @@ export namespace Dialogs { for (const combo of combinations) { const variables = Object.fromEntries( Object.entries(combo).filter(([, v]) => v !== undefined) - ) as DialogVariables; + ) as RPGVariables; const engine = new DialogEngine(dialog, { variables, actions: {} }); const stack: string[] = [dialog.startNodeId]; @@ -260,7 +231,7 @@ export class DialogEngine { const node = this.nodeMap.get(targetId); if (!node) return null; - if (!this.evaluateConditions(node.conditions ?? [])) { + if (!evaluateConditions(node.conditions ?? [], this.options.variables)) { targetId = node.nextNodeId; continue; } @@ -290,35 +261,10 @@ export class DialogEngine { return choices.filter(choice => { if (choice.nextNodeId === undefined) return true; const target = this.nodeMap.get(choice.nextNodeId); - return target ? this.evaluateConditions(target.conditions ?? []) : false; + return target ? evaluateConditions(target.conditions ?? [], this.options.variables) : false; }); } - private evaluateConditions(conditions: string[]): boolean { - return conditions.every(c => this.evaluateCondition(parseCondition(c))); - } - - private evaluateCondition({ variable, negate, operator, value }: ParsedCondition): boolean { - const val = this.options.variables[variable]; - - if (operator === 'null') return val == null; - if (operator === '~null') return val != null; - - if (operator === undefined) - return negate ? !Boolean(val) : Boolean(val); - - if (operator === '==') return val === value; - if (operator === '!=') return val !== value; - - if (typeof val !== 'number' || typeof value !== 'number') return false; - if (operator === '<') return val < value; - if (operator === '>') return val > value; - if (operator === '<=') return val <= value; - if (operator === '>=') return val >= value; - - return false; - } - [Symbol.asyncIterator]() { return this; } @@ -336,4 +282,4 @@ export class DialogEngine { get currentSpeaker(): string | null { return this.currentNode?.speaker || null; } -} \ No newline at end of file +} diff --git a/src/common/rpg/quest.ts b/src/common/rpg/quest.ts new file mode 100644 index 0000000..056963d --- /dev/null +++ b/src/common/rpg/quest.ts @@ -0,0 +1,106 @@ +import { evaluateConditions } from "./conditions"; +import { + isQuest, + type Quest, + type QuestStage, + type RPGActions, + type RPGVariables, +} from "./types"; + +export type QuestStatus = 'inactive' | 'active' | 'completed'; + +export interface QuestRuntimeOptions { + variables: RPGVariables; + actions: RPGActions; +} + +export namespace Quests { + export function validate(quest: unknown, actions: string[]): string[] { + if (!isQuest(quest)) return ['invalid quest structure']; + + const errors: string[] = []; + const actionSet = new Set(actions); + + for (const stage of quest.stages) { + for (const action of stage.actions) { + if (!actionSet.has(action.type)) { + errors.push(`stage '${stage.id}': unknown action type '${action.type}'`); + } + } + } + + return errors; + } + + export function isValid(v: unknown, actions: string[]): v is Quest { + return validate(v, actions).length === 0; + } +} + +export class QuestEngine { + private _status: QuestStatus = 'inactive'; + private _stageIndex: number = 0; + + constructor( + private readonly quest: Quest, + private readonly options: QuestRuntimeOptions, + ) { + this.quest = quest; + this.options = options; + } + + get id(): string { + return this.quest.id; + } + + isAvailable(): boolean { + return evaluateConditions(this.resolveConditions(this.quest.conditions ?? []), this.options.variables); + } + + start(): void { + this._status = 'active'; + this._stageIndex = 0; + } + + private resolveConditions(conditions: string[]): string[] { + const questVar = `quest.${this.quest.id}`; + return conditions.map(c => c.replace(/^(~?)\$/, `$1${questVar}`)); + } + + async checkAndAdvance(): Promise { + if (this._status !== 'active') return; + + const stage = this.quest.stages[this._stageIndex]; + if (!stage) return; + + const allDone = evaluateConditions(this.resolveConditions(stage.objectives.map(obj => obj.condition)), this.options.variables); + + if (!allDone) return; + + for (const action of stage.actions) { + await this.options.actions[action.type]?.(action.arg); + } + + if (this._stageIndex + 1 < this.quest.stages.length) { + this._stageIndex++; + } else { + this._status = 'completed'; + } + } + + getVariables(): RPGVariables { + const prefix = `quest.${this.quest.id}`; + return { + [prefix]: this._status, + [`${prefix}.stage`]: this._stageIndex, + }; + } + + get currentStage(): QuestStage | null { + return this.quest.stages[this._stageIndex] ?? null; + } + + get status(): QuestStatus { + return this._status; + } +} diff --git a/src/common/rpg/types.ts b/src/common/rpg/types.ts index 1a1228d..83635ac 100644 --- a/src/common/rpg/types.ts +++ b/src/common/rpg/types.ts @@ -1,8 +1,13 @@ -export interface DialogAction { +export type RPGCondition = string; +export type RPGVariables = Record; + +export interface RPGAction { type: string; arg?: string | number | boolean | null; } +export type RPGActions = Record Promise | void>; + export interface DialogChoice { text: string; nextNodeId?: string; @@ -14,8 +19,8 @@ export interface DialogNode { text: string; nextNodeId?: string; choices?: DialogChoice[]; - conditions?: string[]; - actions?: DialogAction[]; + conditions?: RPGCondition[]; + actions?: RPGAction[]; } export interface Dialog { @@ -23,7 +28,28 @@ export interface Dialog { startNodeId: string; } -function isDialogAction(v: unknown): v is DialogAction { +export interface QuestObjective { + id: string; + description: string; + condition: RPGCondition; +} + +export interface QuestStage { + id: string; + description: string; + objectives: QuestObjective[]; + actions: RPGAction[]; +} + +export interface Quest { + id: string; + title: string; + description: string; + conditions?: RPGCondition[]; + stages: QuestStage[]; +} + +function isRPGAction(v: unknown): v is RPGAction { if (typeof v !== 'object' || v === null) return false; return typeof (v as Record).type === 'string'; } @@ -43,8 +69,8 @@ function isDialogNode(v: unknown): v is DialogNode { && typeof n.text === 'string' && (n.nextNodeId === undefined || typeof n.nextNodeId === 'string') && (n.choices === undefined || (Array.isArray(n.choices) && n.choices.every(isDialogChoice))) - && (n.conditions === undefined || (Array.isArray(n.conditions) && n.conditions.every(v => typeof v === 'string'))) - && (n.actions === undefined || (Array.isArray(n.actions) && n.actions.every(isDialogAction))); + && (n.conditions === undefined || (Array.isArray(n.conditions) && n.conditions.every(c => typeof c === 'string'))) + && (n.actions === undefined || (Array.isArray(n.actions) && n.actions.every(isRPGAction))); } export function isDialog(v: unknown): v is Dialog { @@ -53,3 +79,30 @@ export function isDialog(v: unknown): v is Dialog { return Array.isArray(d.nodes) && d.nodes.every(isDialogNode) && typeof d.startNodeId === 'string'; } + +function isQuestObjective(v: unknown): v is QuestObjective { + if (typeof v !== 'object' || v === null) return false; + const o = v as Record; + return typeof o.id === 'string' + && typeof o.description === 'string' + && typeof o.condition === 'string'; +} + +function isQuestStage(v: unknown): v is QuestStage { + if (typeof v !== 'object' || v === null) return false; + const s = v as Record; + return typeof s.id === 'string' + && typeof s.description === 'string' + && Array.isArray(s.objectives) && s.objectives.every(isQuestObjective) + && Array.isArray(s.actions) && s.actions.every(isRPGAction); +} + +export function isQuest(v: unknown): v is Quest { + if (typeof v !== 'object' || v === null) return false; + const q = v as Record; + return typeof q.id === 'string' + && typeof q.title === 'string' + && typeof q.description === 'string' + && Array.isArray(q.stages) && q.stages.every(isQuestStage) + && (q.conditions === undefined || (Array.isArray(q.conditions) && q.conditions.every(c => typeof c === 'string'))); +}