RPG Quest system
This commit is contained in:
parent
ad327b17d7
commit
89dbf9f8ff
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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';
|
import {
|
||||||
type ConditionValue = string | number | boolean | null;
|
evaluateConditions,
|
||||||
|
parseCondition,
|
||||||
interface ParsedCondition {
|
type ParsedCondition,
|
||||||
variable: string;
|
} from "./conditions";
|
||||||
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<string, string | number | boolean>;
|
|
||||||
export type DialogActions = Record<string, (arg?: unknown) => Promise<void> | void>;
|
|
||||||
|
|
||||||
export interface DialogRuntimeOptions {
|
export interface DialogRuntimeOptions {
|
||||||
variables: DialogVariables;
|
variables: RPGVariables;
|
||||||
actions: DialogActions;
|
actions: RPGActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogNodeView {
|
export interface DialogNodeView {
|
||||||
|
|
@ -194,7 +165,7 @@ export namespace Dialogs {
|
||||||
for (const combo of combinations) {
|
for (const combo of combinations) {
|
||||||
const variables = Object.fromEntries(
|
const variables = Object.fromEntries(
|
||||||
Object.entries(combo).filter(([, v]) => v !== undefined)
|
Object.entries(combo).filter(([, v]) => v !== undefined)
|
||||||
) as DialogVariables;
|
) as RPGVariables;
|
||||||
|
|
||||||
const engine = new DialogEngine(dialog, { variables, actions: {} });
|
const engine = new DialogEngine(dialog, { variables, actions: {} });
|
||||||
const stack: string[] = [dialog.startNodeId];
|
const stack: string[] = [dialog.startNodeId];
|
||||||
|
|
@ -260,7 +231,7 @@ export class DialogEngine {
|
||||||
const node = this.nodeMap.get(targetId);
|
const node = this.nodeMap.get(targetId);
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
if (!this.evaluateConditions(node.conditions ?? [])) {
|
if (!evaluateConditions(node.conditions ?? [], this.options.variables)) {
|
||||||
targetId = node.nextNodeId;
|
targetId = node.nextNodeId;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -290,35 +261,10 @@ export class DialogEngine {
|
||||||
return choices.filter(choice => {
|
return choices.filter(choice => {
|
||||||
if (choice.nextNodeId === undefined) return true;
|
if (choice.nextNodeId === undefined) return true;
|
||||||
const target = this.nodeMap.get(choice.nextNodeId);
|
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]() {
|
[Symbol.asyncIterator]() {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -336,4 +282,4 @@ export class DialogEngine {
|
||||||
get currentSpeaker(): string | null {
|
get currentSpeaker(): string | null {
|
||||||
return this.currentNode?.speaker || null;
|
return this.currentNode?.speaker || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
export interface DialogAction {
|
export type RPGCondition = string;
|
||||||
|
export type RPGVariables = Record<string, string | number | boolean>;
|
||||||
|
|
||||||
|
export interface RPGAction {
|
||||||
type: string;
|
type: string;
|
||||||
arg?: string | number | boolean | null;
|
arg?: string | number | boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RPGActions = Record<string, (arg?: unknown) => Promise<void> | void>;
|
||||||
|
|
||||||
export interface DialogChoice {
|
export interface DialogChoice {
|
||||||
text: string;
|
text: string;
|
||||||
nextNodeId?: string;
|
nextNodeId?: string;
|
||||||
|
|
@ -14,8 +19,8 @@ export interface DialogNode {
|
||||||
text: string;
|
text: string;
|
||||||
nextNodeId?: string;
|
nextNodeId?: string;
|
||||||
choices?: DialogChoice[];
|
choices?: DialogChoice[];
|
||||||
conditions?: string[];
|
conditions?: RPGCondition[];
|
||||||
actions?: DialogAction[];
|
actions?: RPGAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Dialog {
|
export interface Dialog {
|
||||||
|
|
@ -23,7 +28,28 @@ export interface Dialog {
|
||||||
startNodeId: string;
|
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;
|
if (typeof v !== 'object' || v === null) return false;
|
||||||
return typeof (v as Record<string, unknown>).type === 'string';
|
return typeof (v as Record<string, unknown>).type === 'string';
|
||||||
}
|
}
|
||||||
|
|
@ -43,8 +69,8 @@ function isDialogNode(v: unknown): v is DialogNode {
|
||||||
&& typeof n.text === 'string'
|
&& typeof n.text === 'string'
|
||||||
&& (n.nextNodeId === undefined || typeof n.nextNodeId === 'string')
|
&& (n.nextNodeId === undefined || typeof n.nextNodeId === 'string')
|
||||||
&& (n.choices === undefined || (Array.isArray(n.choices) && n.choices.every(isDialogChoice)))
|
&& (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.conditions === undefined || (Array.isArray(n.conditions) && n.conditions.every(c => typeof c === 'string')))
|
||||||
&& (n.actions === undefined || (Array.isArray(n.actions) && n.actions.every(isDialogAction)));
|
&& (n.actions === undefined || (Array.isArray(n.actions) && n.actions.every(isRPGAction)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDialog(v: unknown): v is Dialog {
|
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)
|
return Array.isArray(d.nodes) && d.nodes.every(isDialogNode)
|
||||||
&& typeof d.startNodeId === 'string';
|
&& typeof d.startNodeId === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isQuestObjective(v: unknown): v is QuestObjective {
|
||||||
|
if (typeof v !== 'object' || v === null) return false;
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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')));
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue