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';
|
||||
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<string, string | number | boolean>;
|
||||
export type DialogActions = Record<string, (arg?: unknown) => Promise<void> | 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
arg?: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export type RPGActions = Record<string, (arg?: unknown) => Promise<void> | 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<string, unknown>).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<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