1
0
Fork 0

Pass context to all actions

This commit is contained in:
Pabloader 2026-04-28 21:46:28 +00:00
parent f6b3b5b66e
commit 5dec0901ac
9 changed files with 106 additions and 64 deletions

View File

@ -1,7 +1,6 @@
import { Component, type EvalContext } from "../core/world"; import { Component, type EvalContext } from "../core/world";
import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types"; import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types";
import { evaluateCondition, parseCondition } from "../utils/conditions"; import { evaluateCondition } from "../utils/conditions";
import { action } from "../utils/decorators";
export type QuestStatus = 'inactive' | 'active' | 'completed' | 'failed'; export type QuestStatus = 'inactive' | 'active' | 'completed' | 'failed';
@ -42,22 +41,18 @@ export class QuestLog extends Component {
return true; return true;
} }
@action
start(questId: string): boolean { start(questId: string): boolean {
return this.#transition('start', questId, 'inactive', 'active', 'started'); return this.#transition('start', questId, 'inactive', 'active', 'started');
} }
@action
complete(questId: string): boolean { complete(questId: string): boolean {
return this.#transition('complete', questId, 'active', 'completed', 'completed'); return this.#transition('complete', questId, 'active', 'completed', 'completed');
} }
@action
fail(questId: string): boolean { fail(questId: string): boolean {
return this.#transition('fail', questId, 'active', 'failed', 'failed'); return this.#transition('fail', questId, 'active', 'failed', 'failed');
} }
@action
abandon(questId: string): boolean { abandon(questId: string): boolean {
return this.#transition('abandon', questId, 'active', 'inactive', 'abandoned'); return this.#transition('abandon', questId, 'active', 'inactive', 'abandoned');
} }
@ -71,7 +66,7 @@ export class QuestLog extends Component {
if (!entry) return false; if (!entry) return false;
const { quest } = entry; const { quest } = entry;
if (!quest.conditions?.length) return true; if (!quest.conditions?.length) return true;
return quest.conditions.every(c => evaluateCondition(parseCondition(c), ctx)); return quest.conditions.every(c => evaluateCondition(c, ctx));
} }
getStage(questId: string): QuestStage | undefined { getStage(questId: string): QuestStage | undefined {
@ -89,7 +84,7 @@ export class QuestLog extends Component {
return stage.objectives.map(obj => ({ return stage.objectives.map(obj => ({
id: obj.id, id: obj.id,
description: obj.description, description: obj.description,
done: evaluateCondition(parseCondition(obj.condition), ctx), done: evaluateCondition(obj.condition, ctx),
})); }));
} }
@ -98,11 +93,6 @@ export class QuestLog extends Component {
return this.#quests.entries(); return this.#quests.entries();
} }
/** @internal called by QuestSystem when a fail condition is met */
_fail(questId: string): void {
this.#transition('_fail', questId, 'active', 'failed', 'failed');
}
/** @internal called by QuestSystem after stage actions complete */ /** @internal called by QuestSystem after stage actions complete */
_advance(questId: string): void { _advance(questId: string): void {
const entry = this.#quests.get(questId); const entry = this.#quests.get(questId);
@ -117,6 +107,21 @@ export class QuestLog extends Component {
} }
} }
override getActions() {
const result = { ...super.getActions() };
for (const questId of this.#quests.keys()) {
const entry = this.#quests.get(questId)!;
if (entry.state.status === 'inactive') {
result[`${questId}.start`] = this.start.bind(this, questId);
} else if (entry.state.status === 'active') {
result[`${questId}.complete`] = this.complete.bind(this, questId);
result[`${questId}.fail`] = this.fail.bind(this, questId);
result[`${questId}.abandon`] = this.abandon.bind(this, questId);
}
}
return result;
}
override getVariables(): RPGVariables { override getVariables(): RPGVariables {
const result: RPGVariables = {}; const result: RPGVariables = {};
for (const [questId, { state }] of this.#quests) { for (const [questId, { state }] of this.#quests) {

View File

@ -59,14 +59,14 @@ export abstract class Component {
} }
get context(): EvalContext { get context(): EvalContext {
return { self: this.entity, world: this.entity.world }; return this.entity.context;
} }
} }
export abstract class System { export abstract class System {
onAdd(_world: World): void { } onAdd(_world: World): void { }
onRemove(_world: World): void { } onRemove(_world: World): void { }
abstract update(world: World, dt: number): void; update(_world: World, _dt: number): void { };
} }
export class Entity { export class Entity {
@ -77,6 +77,10 @@ export class Entity {
readonly world: World, readonly world: World,
) { } ) { }
get context(): EvalContext {
return { self: this, world: this.world };
}
add<T extends Component>(key: string, component: T): T { add<T extends Component>(key: string, component: T): T {
const existing = this.#components.get(key); const existing = this.#components.get(key);
if (existing) existing.onRemove(); if (existing) existing.onRemove();
@ -207,7 +211,9 @@ export class World {
*query<T extends Component>(ctor: Class<T>): Generator<[Entity, string, T]> { *query<T extends Component>(ctor: Class<T>): Generator<[Entity, string, T]> {
for (const entity of this.#entities.values()) { for (const entity of this.#entities.values()) {
for (const [key, component] of entity) { for (const [key, component] of entity) {
if (component instanceof ctor) yield [entity, key, component as T]; if (component instanceof ctor) {
yield [entity, key, component as T]
}
} }
} }
} }

View File

@ -3,6 +3,7 @@ import {
type Dialog, type Dialog,
type DialogChoice, type DialogChoice,
type DialogNode, type DialogNode,
type RPGAction,
type RPGActions, type RPGActions,
type RPGVariables, type RPGVariables,
} from "./types"; } from "./types";
@ -233,7 +234,7 @@ export class DialogEngine {
return this.options.variables; return this.options.variables;
} }
private async runAction(action: { type: string; arg?: string | number | boolean }): Promise<void> { private async runAction(action: RPGAction): Promise<void> {
if (isEvalContext(this.options)) { if (isEvalContext(this.options)) {
await executeAction(action, this.options); await executeAction(action, this.options);
} else { } else {

View File

@ -1,19 +1,15 @@
import { System, type Entity, type World } from "../core/world"; import { System, type Entity, type World } from "../core/world";
import { evaluateCondition, parseCondition } from "../utils/conditions"; import { evaluateCondition } from "../utils/conditions";
import { executeAction } from "../utils/variables"; import { executeAction } from "../utils/variables";
import { QuestLog } from "../components/questLog"; import { QuestLog } from "../components/questLog";
export class QuestSystem extends System { export class QuestSystem extends System {
override update(world: World, _dt: number): void { async triggerCheck(world: World): Promise<void> {
for (const [entity] of world.query(QuestLog)) { for (const [entity] of world.query(QuestLog)) {
void this.#checkEntity(entity, world); await this.#checkEntity(entity, world);
} }
} }
async triggerCheck(entity: Entity, world: World): Promise<void> {
await this.#checkEntity(entity, world);
}
async #checkEntity(entity: Entity, world: World): Promise<void> { async #checkEntity(entity: Entity, world: World): Promise<void> {
const questLog = entity.get(QuestLog); const questLog = entity.get(QuestLog);
if (!questLog) return; if (!questLog) return;
@ -31,16 +27,16 @@ export class QuestSystem extends System {
if (stage.failConditions?.length) { if (stage.failConditions?.length) {
const failed = stage.failConditions.some(c => const failed = stage.failConditions.some(c =>
evaluateCondition(parseCondition(c), ctx) evaluateCondition(c, ctx)
); );
if (failed) { if (failed) {
questLog._fail(questId); questLog.fail(questId);
continue; continue;
} }
} }
const allDone = stage.objectives.every(obj => const allDone = stage.objectives.every(obj =>
evaluateCondition(parseCondition(obj.condition), ctx) evaluateCondition(obj.condition, ctx)
); );
if (!allDone) continue; if (!allDone) continue;

View File

@ -1,15 +1,16 @@
import { Type, type Static } from '../typebox'; import { Type, type Static } from '../typebox';
import type { EvalContext } from './core/world';
// ── Shared ──────────────────────────────────────────────────────────────────── // ── Shared ────────────────────────────────────────────────────────────────────
const RPGActionScheme = Type.Object({ const RPGActionScheme = Type.Object({
type: Type.String(), type: Type.String(),
arg: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Boolean()])), arg: Type.Optional(Type.Any()),
}); });
export type RPGCondition = string; export type RPGCondition = string;
export type RPGVariables = Record<string, string | number | boolean | undefined>; export type RPGVariables = Record<string, string | number | boolean | undefined>;
export type RPGActions = Record<string, (arg?: any) => unknown>; export type RPGActions = Record<string, (arg?: any, ctx?: EvalContext) => unknown>;
export type RPGAction = Static<typeof RPGActionScheme>; export type RPGAction = Static<typeof RPGActionScheme>;

View File

@ -1,5 +1,5 @@
import type { RPGCondition, RPGVariables } from "../types"; import type { RPGCondition, RPGVariables } from "../types";
import type { EvalContext } from "../core/world"; import { isEvalContext, type EvalContext } from "../core/world";
import { resolveVariable } from "./variables"; import { resolveVariable } from "./variables";
type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null'; type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null';
@ -60,21 +60,22 @@ function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariab
return false; return false;
} }
export function evaluateCondition(parsed: ParsedCondition, variables: RPGVariables): boolean; type Cond = ParsedCondition | RPGCondition;
export function evaluateCondition(parsed: ParsedCondition, ctx: EvalContext): boolean;
export function evaluateCondition(parsed: ParsedCondition, variablesOrCtx: RPGVariables | EvalContext): boolean { export function evaluateCondition(condition: Cond, variables: RPGVariables): boolean;
export function evaluateCondition(condition: Cond, ctx: EvalContext): boolean;
export function evaluateCondition(condition: Cond, variablesOrCtx: RPGVariables | EvalContext): boolean {
const parsed = typeof condition === 'string' ? parseCondition(condition) : condition;
const val = isEvalContext(variablesOrCtx) const val = isEvalContext(variablesOrCtx)
? resolveVariable(parsed.variable, variablesOrCtx) ? resolveVariable(parsed.variable, variablesOrCtx)
: variablesOrCtx[parsed.variable]; : variablesOrCtx[parsed.variable];
return evalParsed(parsed, val); return evalParsed(parsed, val);
} }
export function evaluateConditions(conditions: RPGCondition[], variables: RPGVariables): boolean;
export function evaluateConditions(conditions: RPGCondition[], ctx: EvalContext): boolean;
export function evaluateConditions(conditions: RPGCondition[], variablesOrCtx: RPGVariables | EvalContext): boolean {
return conditions.every(c => evaluateCondition(parseCondition(c), variablesOrCtx as RPGVariables));
}
function isEvalContext(v: RPGVariables | EvalContext): v is EvalContext { export function evaluateConditions(conditions: Cond[], variables: RPGVariables): boolean;
return 'self' in v && 'world' in v; export function evaluateConditions(conditions: Cond[], ctx: EvalContext): boolean;
export function evaluateConditions(conditions: Cond[], variablesOrCtx: RPGVariables | EvalContext): boolean {
return conditions.every(c => evaluateCondition(c, variablesOrCtx as RPGVariables));
} }

View File

@ -5,23 +5,22 @@ import type { EvalContext, Entity } from "../core/world";
export function resolveVariables(entity: Entity): RPGVariables; export function resolveVariables(entity: Entity): RPGVariables;
export function resolveVariables(world: World): RPGVariables; export function resolveVariables(world: World): RPGVariables;
export function resolveVariables(entityOrWorld: Entity | World): RPGVariables { export function resolveVariables(entityOrWorld: Entity | World): RPGVariables {
const result: RPGVariables = {};
if (entityOrWorld instanceof World) { if (entityOrWorld instanceof World) {
const result: RPGVariables = {};
for (const entity of entityOrWorld) { for (const entity of entityOrWorld) {
for (const [key, value] of Object.entries(resolveVariables(entity))) { for (const [key, value] of Object.entries(resolveVariables(entity))) {
result[`${entity.id}.${key}`] = value; result[`${entity.id}.${key}`] = value;
} }
} }
return result; } else {
} for (const [key, component] of entityOrWorld) {
const result: RPGVariables = {}; for (const [varKey, value] of Object.entries(component.getVariables())) {
for (const [key, component] of entityOrWorld) { if (value != null) {
for (const [varKey, value] of Object.entries(component.getVariables())) { if (varKey && varKey !== '.') {
if (value != null) { result[`${key}.${varKey}`] = value;
if (varKey && varKey !== '.') { } else {
result[`${key}.${varKey}`] = value; result[key] = value;
} else { }
result[key] = value;
} }
} }
} }
@ -50,12 +49,21 @@ export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[st
// bare name → self entity // bare name → self entity
return resolveVariables(ctx.self)[name]; return resolveVariables(ctx.self)[name];
} }
export function resolveActions(entity: Entity): RPGActions;
export function resolveActions(entity: Entity): RPGActions { export function resolveActions(world: World): RPGActions;
export function resolveActions(entityOrWorld: Entity | World): RPGActions {
const result: RPGActions = {}; const result: RPGActions = {};
for (const [key, component] of entity) { if (entityOrWorld instanceof World) {
for (const [actionKey, fn] of Object.entries(component.getActions())) { for (const entity of entityOrWorld) {
result[`${key}.${actionKey}`] = fn; for (const [key, value] of Object.entries(resolveActions(entity))) {
result[`${entity.id}.${key}`] = value;
}
}
} else {
for (const [key, component] of entityOrWorld) {
for (const [actionKey, fn] of Object.entries(component.getActions())) {
result[`${key}.${actionKey}`] = fn;
}
} }
} }
return result; return result;
@ -87,5 +95,5 @@ export async function executeAction(action: RPGAction, ctx: EvalContext): Promis
console.warn(`[executeAction] action '${actionType}' not found on entity '${entity.id}'`); console.warn(`[executeAction] action '${actionType}' not found on entity '${entity.id}'`);
return; return;
} }
return actions[actionType](action.arg); return actions[actionType](action.arg, ctx);
} }

View File

@ -4,6 +4,14 @@ const GlobalNumber = Number;
const GlobalArray = Array; const GlobalArray = Array;
export namespace Type { export namespace Type {
export function Any(args: { description?: string } = {}) {
const result: TAny = {};
if (args.description) {
result.description = args.description;
}
return result;
}
export function String<S extends string = string>(args: { description?: string, enum?: S[] } = {}) { export function String<S extends string = string>(args: { description?: string, enum?: S[] } = {}) {
const result: TString<S> = { const result: TString<S> = {
type: 'string', type: 'string',
@ -106,6 +114,8 @@ export namespace Type {
} }
function check(scheme: TScheme, value: unknown, path: string): CheckError[] { function check(scheme: TScheme, value: unknown, path: string): CheckError[] {
if (!('type' in scheme)) return [];
if (value == null) { if (value == null) {
if ((scheme as any)[optional]) return []; if ((scheme as any)[optional]) return [];
return [{ path, message: `Expected ${scheme.type} at ${path}, got ${value}` }]; return [{ path, message: `Expected ${scheme.type} at ${path}, got ${value}` }];
@ -187,6 +197,10 @@ export namespace Type {
} }
} }
export interface TAny {
description?: string;
}
export interface TString<T extends string = string> { export interface TString<T extends string = string> {
type: 'string'; type: 'string';
enum?: T[]; enum?: T[];
@ -219,6 +233,10 @@ export interface TObject<T extends TProperties = TProperties> {
required?: string[]; required?: string[];
} }
export interface TOptionalAny extends TAny {
[optional]: true;
}
export interface TOptionalString<T extends string = string> extends TString<T> { export interface TOptionalString<T extends string = string> extends TString<T> {
[optional]: true; [optional]: true;
} }
@ -256,11 +274,12 @@ export type TOptional<T extends TScheme = TScheme> =
T extends TArray<infer I> ? TOptionalArray<I> : T extends TArray<infer I> ? TOptionalArray<I> :
T extends TObject<infer P> ? TOptionalObject<P> : T extends TObject<infer P> ? TOptionalObject<P> :
T extends TUnion<infer U> ? TOptionalUnion<U> : T extends TUnion<infer U> ? TOptionalUnion<U> :
T extends TAny ? TOptionalAny :
never; never;
export type IsOptional<T> = T extends { [optional]: true } ? true : false; export type IsOptional<T> = T extends { [optional]: true } ? true : false;
export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TUnion | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject | TOptionalUnion; export type TScheme = TAny | TString | TNumber | TBoolean | TArray | TObject | TUnion | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject | TOptionalUnion;
type Prettify<T> = { [K in keyof T]: T[K] } & {}; type Prettify<T> = { [K in keyof T]: T[K] } & {};
@ -279,8 +298,10 @@ type StaticObject<T extends TProperties> = Prettify<
type StaticUnion<T extends TScheme[]> = type StaticUnion<T extends TScheme[]> =
T extends [infer First extends TScheme, ...infer Rest extends TScheme[]] T extends [infer First extends TScheme, ...infer Rest extends TScheme[]]
? Static<First> | StaticUnion<Rest> ? Static<First> | StaticUnion<Rest>
: never; : never;
type StaticAny = any;
export type Static<T extends TScheme> = export type Static<T extends TScheme> =
T extends TString<infer S> ? S : T extends TString<infer S> ? S :
@ -289,4 +310,5 @@ export type Static<T extends TScheme> =
T extends TArray<infer I> ? Static<I>[] : T extends TArray<infer I> ? Static<I>[] :
T extends TObject<infer P> ? StaticObject<P> : T extends TObject<infer P> ? StaticObject<P> :
T extends TUnion<infer U> ? StaticUnion<U> : T extends TUnion<infer U> ? StaticUnion<U> :
T extends TAny ? StaticAny :
never; never;

View File

@ -5,7 +5,7 @@ import { Variables } from "@common/rpg/components/variables";
import { QuestLog } from "@common/rpg/components/questLog"; import { QuestLog } from "@common/rpg/components/questLog";
import { QuestSystem } from "@common/rpg/systems/questSystem"; import { QuestSystem } from "@common/rpg/systems/questSystem";
import { Items } from "@common/rpg/components/item"; import { Items } from "@common/rpg/components/item";
import { resolveVariables, resolveActions } from "@common/rpg/utils/variables"; import { resolveVariables, resolveActions, executeAction } from "@common/rpg/utils/variables";
export default async function main() { export default async function main() {
const world = new World(); const world = new World();
@ -33,9 +33,11 @@ export default async function main() {
const inventory = player.get(Inventory)!; const inventory = player.get(Inventory)!;
inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head' }); inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head' });
const actions = resolveActions(player); const vars = player.get(Variables)!;
actions['inventory.add']({ itemId: 'boots', amount: 2 }); vars.set({ key: 'test', value: 'test' });
console.log(actions); await executeAction({ type: 'inventory.add', arg: { itemId: 'boots', amount: 2 } }, player.context);
console.log(resolveActions(world));
console.log(resolveVariables(world)); console.log(resolveVariables(world));
} }