1
0
Fork 0

RPG Quest system

This commit is contained in:
Pabloader 2026-04-27 12:25:09 +00:00
parent ad327b17d7
commit 89dbf9f8ff
4 changed files with 249 additions and 79 deletions

View File

@ -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));
}

View File

@ -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;
}
@ -336,4 +282,4 @@ export class DialogEngine {
get currentSpeaker(): string | null {
return this.currentNode?.speaker || null;
}
}
}

106
src/common/rpg/quest.ts Normal file
View File

@ -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;
}
}

View File

@ -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')));
}