From 5dec0901ac41c482993b30654dc08ede5dfadb9c Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 28 Apr 2026 21:46:28 +0000 Subject: [PATCH] Pass context to all actions --- src/common/rpg/components/questLog.ts | 31 +++++++++++--------- src/common/rpg/core/world.ts | 12 ++++++-- src/common/rpg/dialog.ts | 3 +- src/common/rpg/systems/questSystem.ts | 16 ++++------ src/common/rpg/types.ts | 5 ++-- src/common/rpg/utils/conditions.ts | 23 ++++++++------- src/common/rpg/utils/variables.ts | 42 ++++++++++++++++----------- src/common/typebox.ts | 28 ++++++++++++++++-- src/games/playground/index.tsx | 10 ++++--- 9 files changed, 106 insertions(+), 64 deletions(-) diff --git a/src/common/rpg/components/questLog.ts b/src/common/rpg/components/questLog.ts index 7bb23ab..1318c93 100644 --- a/src/common/rpg/components/questLog.ts +++ b/src/common/rpg/components/questLog.ts @@ -1,7 +1,6 @@ import { Component, type EvalContext } from "../core/world"; import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types"; -import { evaluateCondition, parseCondition } from "../utils/conditions"; -import { action } from "../utils/decorators"; +import { evaluateCondition } from "../utils/conditions"; export type QuestStatus = 'inactive' | 'active' | 'completed' | 'failed'; @@ -42,22 +41,18 @@ export class QuestLog extends Component { return true; } - @action start(questId: string): boolean { return this.#transition('start', questId, 'inactive', 'active', 'started'); } - @action complete(questId: string): boolean { return this.#transition('complete', questId, 'active', 'completed', 'completed'); } - @action fail(questId: string): boolean { return this.#transition('fail', questId, 'active', 'failed', 'failed'); } - @action abandon(questId: string): boolean { return this.#transition('abandon', questId, 'active', 'inactive', 'abandoned'); } @@ -71,7 +66,7 @@ export class QuestLog extends Component { if (!entry) return false; const { quest } = entry; 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 { @@ -89,7 +84,7 @@ export class QuestLog extends Component { return stage.objectives.map(obj => ({ id: obj.id, 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(); } - /** @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 */ _advance(questId: string): void { 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 { const result: RPGVariables = {}; for (const [questId, { state }] of this.#quests) { diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index 281cc93..1339154 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -59,14 +59,14 @@ export abstract class Component { } get context(): EvalContext { - return { self: this.entity, world: this.entity.world }; + return this.entity.context; } } export abstract class System { onAdd(_world: World): void { } onRemove(_world: World): void { } - abstract update(world: World, dt: number): void; + update(_world: World, _dt: number): void { }; } export class Entity { @@ -77,6 +77,10 @@ export class Entity { readonly world: World, ) { } + get context(): EvalContext { + return { self: this, world: this.world }; + } + add(key: string, component: T): T { const existing = this.#components.get(key); if (existing) existing.onRemove(); @@ -207,7 +211,9 @@ export class World { *query(ctor: Class): Generator<[Entity, string, T]> { for (const entity of this.#entities.values()) { 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] + } } } } diff --git a/src/common/rpg/dialog.ts b/src/common/rpg/dialog.ts index 4a1d97e..1429b0c 100644 --- a/src/common/rpg/dialog.ts +++ b/src/common/rpg/dialog.ts @@ -3,6 +3,7 @@ import { type Dialog, type DialogChoice, type DialogNode, + type RPGAction, type RPGActions, type RPGVariables, } from "./types"; @@ -233,7 +234,7 @@ export class DialogEngine { return this.options.variables; } - private async runAction(action: { type: string; arg?: string | number | boolean }): Promise { + private async runAction(action: RPGAction): Promise { if (isEvalContext(this.options)) { await executeAction(action, this.options); } else { diff --git a/src/common/rpg/systems/questSystem.ts b/src/common/rpg/systems/questSystem.ts index 6892c7a..fb9820e 100644 --- a/src/common/rpg/systems/questSystem.ts +++ b/src/common/rpg/systems/questSystem.ts @@ -1,19 +1,15 @@ 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 { QuestLog } from "../components/questLog"; export class QuestSystem extends System { - override update(world: World, _dt: number): void { + async triggerCheck(world: World): Promise { for (const [entity] of world.query(QuestLog)) { - void this.#checkEntity(entity, world); + await this.#checkEntity(entity, world); } } - async triggerCheck(entity: Entity, world: World): Promise { - await this.#checkEntity(entity, world); - } - async #checkEntity(entity: Entity, world: World): Promise { const questLog = entity.get(QuestLog); if (!questLog) return; @@ -31,16 +27,16 @@ export class QuestSystem extends System { if (stage.failConditions?.length) { const failed = stage.failConditions.some(c => - evaluateCondition(parseCondition(c), ctx) + evaluateCondition(c, ctx) ); if (failed) { - questLog._fail(questId); + questLog.fail(questId); continue; } } const allDone = stage.objectives.every(obj => - evaluateCondition(parseCondition(obj.condition), ctx) + evaluateCondition(obj.condition, ctx) ); if (!allDone) continue; diff --git a/src/common/rpg/types.ts b/src/common/rpg/types.ts index 68540c8..818c475 100644 --- a/src/common/rpg/types.ts +++ b/src/common/rpg/types.ts @@ -1,15 +1,16 @@ import { Type, type Static } from '../typebox'; +import type { EvalContext } from './core/world'; // ── Shared ──────────────────────────────────────────────────────────────────── const RPGActionScheme = Type.Object({ 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 RPGVariables = Record; -export type RPGActions = Record unknown>; +export type RPGActions = Record unknown>; export type RPGAction = Static; diff --git a/src/common/rpg/utils/conditions.ts b/src/common/rpg/utils/conditions.ts index 8bd4a0e..43ebcc6 100644 --- a/src/common/rpg/utils/conditions.ts +++ b/src/common/rpg/utils/conditions.ts @@ -1,5 +1,5 @@ import type { RPGCondition, RPGVariables } from "../types"; -import type { EvalContext } from "../core/world"; +import { isEvalContext, type EvalContext } from "../core/world"; import { resolveVariable } from "./variables"; type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null'; @@ -60,21 +60,22 @@ function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariab return false; } -export function evaluateCondition(parsed: ParsedCondition, variables: RPGVariables): boolean; -export function evaluateCondition(parsed: ParsedCondition, ctx: EvalContext): boolean; -export function evaluateCondition(parsed: ParsedCondition, variablesOrCtx: RPGVariables | EvalContext): boolean { +type Cond = ParsedCondition | RPGCondition; + +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) ? resolveVariable(parsed.variable, variablesOrCtx) : variablesOrCtx[parsed.variable]; + 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 { - return 'self' in v && 'world' in v; +export function evaluateConditions(conditions: Cond[], variables: RPGVariables): boolean; +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)); } diff --git a/src/common/rpg/utils/variables.ts b/src/common/rpg/utils/variables.ts index 67f046d..cb94426 100644 --- a/src/common/rpg/utils/variables.ts +++ b/src/common/rpg/utils/variables.ts @@ -5,23 +5,22 @@ import type { EvalContext, Entity } from "../core/world"; export function resolveVariables(entity: Entity): RPGVariables; export function resolveVariables(world: World): RPGVariables; export function resolveVariables(entityOrWorld: Entity | World): RPGVariables { + const result: RPGVariables = {}; if (entityOrWorld instanceof World) { - const result: RPGVariables = {}; for (const entity of entityOrWorld) { for (const [key, value] of Object.entries(resolveVariables(entity))) { result[`${entity.id}.${key}`] = value; } } - return result; - } - const result: RPGVariables = {}; - for (const [key, component] of entityOrWorld) { - for (const [varKey, value] of Object.entries(component.getVariables())) { - if (value != null) { - if (varKey && varKey !== '.') { - result[`${key}.${varKey}`] = value; - } else { - result[key] = value; + } else { + for (const [key, component] of entityOrWorld) { + for (const [varKey, value] of Object.entries(component.getVariables())) { + if (value != null) { + if (varKey && varKey !== '.') { + result[`${key}.${varKey}`] = value; + } else { + result[key] = value; + } } } } @@ -50,12 +49,21 @@ export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[st // bare name → self entity 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 = {}; - for (const [key, component] of entity) { - for (const [actionKey, fn] of Object.entries(component.getActions())) { - result[`${key}.${actionKey}`] = fn; + if (entityOrWorld instanceof World) { + for (const entity of entityOrWorld) { + 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; @@ -87,5 +95,5 @@ export async function executeAction(action: RPGAction, ctx: EvalContext): Promis console.warn(`[executeAction] action '${actionType}' not found on entity '${entity.id}'`); return; } - return actions[actionType](action.arg); + return actions[actionType](action.arg, ctx); } diff --git a/src/common/typebox.ts b/src/common/typebox.ts index 3be0aa0..99997df 100644 --- a/src/common/typebox.ts +++ b/src/common/typebox.ts @@ -4,6 +4,14 @@ const GlobalNumber = Number; const GlobalArray = Array; export namespace Type { + export function Any(args: { description?: string } = {}) { + const result: TAny = {}; + if (args.description) { + result.description = args.description; + } + return result; + } + export function String(args: { description?: string, enum?: S[] } = {}) { const result: TString = { type: 'string', @@ -106,6 +114,8 @@ export namespace Type { } function check(scheme: TScheme, value: unknown, path: string): CheckError[] { + if (!('type' in scheme)) return []; + if (value == null) { if ((scheme as any)[optional]) return []; 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 { type: 'string'; enum?: T[]; @@ -219,6 +233,10 @@ export interface TObject { required?: string[]; } +export interface TOptionalAny extends TAny { + [optional]: true; +} + export interface TOptionalString extends TString { [optional]: true; } @@ -256,11 +274,12 @@ export type TOptional = T extends TArray ? TOptionalArray : T extends TObject ? TOptionalObject

: T extends TUnion ? TOptionalUnion : + T extends TAny ? TOptionalAny : never; export type IsOptional = 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 = { [K in keyof T]: T[K] } & {}; @@ -279,8 +298,10 @@ type StaticObject = Prettify< type StaticUnion = T extends [infer First extends TScheme, ...infer Rest extends TScheme[]] - ? Static | StaticUnion - : never; + ? Static | StaticUnion + : never; + +type StaticAny = any; export type Static = T extends TString ? S : @@ -289,4 +310,5 @@ export type Static = T extends TArray ? Static[] : T extends TObject ? StaticObject

: T extends TUnion ? StaticUnion : + T extends TAny ? StaticAny : never; diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 56c3877..2e80b07 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -5,7 +5,7 @@ import { Variables } from "@common/rpg/components/variables"; import { QuestLog } from "@common/rpg/components/questLog"; import { QuestSystem } from "@common/rpg/systems/questSystem"; 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() { const world = new World(); @@ -33,9 +33,11 @@ export default async function main() { const inventory = player.get(Inventory)!; inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head' }); - const actions = resolveActions(player); - actions['inventory.add']({ itemId: 'boots', amount: 2 }); + const vars = player.get(Variables)!; + 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)); }