1
0
Fork 0

Compare commits

..

No commits in common. "89651f6674a47d0c4f58693487a712cbba40a054" and "ab68a5ffc694b3aaaa65a5a76804b4bd519df9b2" have entirely different histories.

15 changed files with 375 additions and 721 deletions

View File

@ -0,0 +1,74 @@
import type { RPGActions, RPGVariables } from "../types";
import { ACTION_KEYS, VARIABLE_KEYS } from "../decorators";
export interface RPGComponent {
getVariables: () => RPGVariables;
getActions: () => RPGActions;
}
export abstract class RPGComponentBase implements RPGComponent {
getVariables(): RPGVariables {
const meta = (this.constructor as Function)[Symbol.metadata];
const keys = meta?.[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
if (!keys) return {};
const vars: RPGVariables = {};
for (const [methodKey, exportName] of keys) {
const k = String(methodKey);
const v = (this as unknown as Record<string, RPGVariables[string]>)[k];
if (v != null) {
vars[exportName] = v;
}
}
return vars;
}
getActions(): RPGActions {
const meta = (this.constructor as Function)[Symbol.metadata];
const keys = meta?.[ACTION_KEYS] as Set<string | symbol> | undefined;
if (!keys) return {};
const actions: RPGActions = {};
for (const key of keys) {
const k = String(key);
actions[k] = (arg?: unknown) => (this as unknown as Record<string, (a?: unknown) => unknown>)[k](arg);
}
return actions;
}
}
export class RPGEntity implements RPGComponent {
private components = new Map<string, RPGComponent>();
addComponent(id: string, component: RPGComponent): void {
this.components.set(id, component);
}
removeComponent(id: string): void {
this.components.delete(id);
}
getComponent<T extends RPGComponent>(id: string): T | undefined {
return this.components.get(id) as T | undefined;
}
getVariables(): RPGVariables {
const variables: RPGVariables = {};
for (const [componentKey, component] of this.components) {
for (const [key, value] of Object.entries(component.getVariables())) {
if (value != null) {
variables[`${componentKey}.${key}`] = value;
}
}
}
return variables;
}
getActions() {
const actions: RPGActions = {};
for (const [componentKey, component] of this.components) {
for (const [key, action] of Object.entries(component.getActions())) {
actions[`${componentKey}.${key}`] = action;
}
}
return actions;
}
}

View File

@ -1,20 +1,19 @@
import type { InventoryOptions, InventorySlotInput, SlotId } from "../types"; import type { InventoryOptions, InventorySlotInput, SlotId } from "../types";
import { action } from "../utils/decorators"; import { action } from "../decorators";
import { Component } from "../core/world"; import { RPGComponentBase } from "./entity";
interface SlotEntry { interface SlotEntry {
readonly slotId: SlotId; readonly slotId: SlotId;
readonly limit: number | undefined; readonly limit: number | undefined;
state: { itemId: string; amount: number } | null; state: { itemId: string; amount: number } | null;
} }
interface SlotUpdateArgs { interface SlotUpdateArgs {
itemId: string; itemId: string;
amount: number; amount: number;
slotId?: SlotId; slotId?: SlotId;
} }
export class Inventory extends Component { export class Inventory extends RPGComponentBase {
private readonly slots: Map<SlotId, SlotEntry>; private readonly slots: Map<SlotId, SlotEntry>;
private readonly maxAmountPerItem: Record<string, number>; private readonly maxAmountPerItem: Record<string, number>;
@ -37,14 +36,14 @@ export class Inventory extends Component {
return Math.min(limitCap, itemCap); return Math.min(limitCap, itemCap);
} }
// Remaining space in slot for itemId // Remaining space in slot for itemId (min of slotCap and remaining amount)
private slotRoomFor(slot: SlotEntry, itemId: string): number { private slotRoomFor(slot: SlotEntry, itemId: string): number {
if (slot.state !== null && slot.state.itemId !== itemId) return 0; if (slot.state !== null && slot.state.itemId !== itemId) return 0;
return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0); return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0);
} }
@action @action
add({ itemId, amount, slotId }: SlotUpdateArgs): boolean { addItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
if (amount < 0) return false; if (amount < 0) return false;
if (amount === 0) return true; if (amount === 0) return true;
@ -53,7 +52,6 @@ export class Inventory extends Component {
if (!slot) return false; if (!slot) return false;
if (this.slotRoomFor(slot, itemId) < amount) return false; if (this.slotRoomFor(slot, itemId) < amount) return false;
slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount }; slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount };
this.emit('add', { itemId, amount, slotIds: [slotId] });
return true; return true;
} }
@ -67,31 +65,22 @@ export class Inventory extends Component {
} }
if (remaining > 0) return false; if (remaining > 0) return false;
// Apply — fill existing slots for this item first, then empty ones // Apply
remaining = amount; remaining = amount;
const slotIds: SlotId[] = []; for (const slot of this.slots.values()) {
for (const [id, slot] of this.slots) {
if (slot.state?.itemId === itemId) { if (slot.state?.itemId === itemId) {
const take = Math.min(this.slotRoomFor(slot, itemId), remaining); const take = Math.min(this.slotRoomFor(slot, itemId), remaining);
slot.state.amount += take; slot.state.amount += take;
remaining -= take; remaining -= take;
slotIds.push(id); if (remaining === 0) return true;
if (remaining === 0) {
this.emit('add', { itemId, amount, slotIds });
return true;
} }
} }
} for (const slot of this.slots.values()) {
for (const [id, slot] of this.slots) {
if (slot.state === null) { if (slot.state === null) {
const take = Math.min(this.slotCapFor(slot, itemId), remaining); const take = Math.min(this.slotCapFor(slot, itemId), remaining);
slot.state = { itemId, amount: take }; slot.state = { itemId, amount: take };
remaining -= take; remaining -= take;
slotIds.push(id); if (remaining === 0) return true;
if (remaining === 0) {
this.emit('add', { itemId, amount, slotIds });
return true;
}
} }
} }
@ -99,7 +88,7 @@ export class Inventory extends Component {
} }
@action @action
remove({ itemId, amount, slotId }: SlotUpdateArgs): boolean { removeItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
if (amount < 0) return false; if (amount < 0) return false;
if (amount === 0) return true; if (amount === 0) return true;
@ -109,29 +98,23 @@ export class Inventory extends Component {
if (slot.state.amount < amount) return false; if (slot.state.amount < amount) return false;
slot.state.amount -= amount; slot.state.amount -= amount;
if (slot.state.amount === 0) slot.state = null; if (slot.state.amount === 0) slot.state = null;
this.emit('remove', { itemId, amount, slotIds: [slotId] });
return true; return true;
} }
if (this.getAmount(itemId) < amount) return false; if (this.getAmount(itemId) < amount) return false;
let remaining = amount; let remaining = amount;
const slotIds: SlotId[] = []; for (const slot of this.slots.values()) {
for (const [id, slot] of this.slots) {
if (slot.state?.itemId === itemId) { if (slot.state?.itemId === itemId) {
const take = Math.min(slot.state.amount, remaining); const take = Math.min(slot.state.amount, remaining);
slot.state.amount -= take; slot.state.amount -= take;
if (slot.state.amount === 0) slot.state = null; if (slot.state.amount === 0) slot.state = null;
remaining -= take; remaining -= take;
slotIds.push(id); if (remaining === 0) return true;
if (remaining === 0) {
this.emit('remove', { itemId, amount, slotIds });
return true;
}
} }
} }
return false; return remaining === 0;
} }
getAmount(itemId: string, slotId?: SlotId): number { getAmount(itemId: string, slotId?: SlotId): number {

View File

@ -1,99 +0,0 @@
import { Component, type EvalContext } from "../core/world";
import { isQuest, type Quest, type RPGVariables } from "../types";
import { evaluateCondition, parseCondition } from "../utils/conditions";
import { action } from "../utils/decorators";
export type QuestStatus = 'inactive' | 'active' | 'completed';
export interface QuestRuntimeState {
status: QuestStatus;
stageIndex: number;
}
export interface QuestEntry {
quest: Quest;
state: QuestRuntimeState;
}
export class QuestLog extends Component {
readonly #quests = new Map<string, QuestEntry>();
addQuest(quest: Quest): void {
if (!this.#quests.has(quest.id)) {
this.#quests.set(quest.id, { quest, state: { status: 'inactive', stageIndex: 0 } });
}
}
@action
start(questId: string): boolean {
const entry = this.#quests.get(questId);
if (!entry || entry.state.status !== 'inactive') return false;
entry.state.status = 'active';
entry.state.stageIndex = 0;
this.emit('started', { questId });
return true;
}
getState(questId: string): QuestRuntimeState | undefined {
return this.#quests.get(questId)?.state;
}
isAvailable(questId: string, ctx: EvalContext): boolean {
const entry = this.#quests.get(questId);
if (!entry) return false;
const { quest } = entry;
if (!quest.conditions?.length) return true;
return quest.conditions.every(c => evaluateCondition(parseCondition(c), ctx));
}
/** @internal used by QuestSystem */
entries(): IterableIterator<[string, QuestEntry]> {
return this.#quests.entries();
}
/** @internal called by QuestSystem after stage actions complete */
_advance(questId: string): void {
const entry = this.#quests.get(questId);
if (!entry) return;
const { quest, state } = entry;
if (state.stageIndex + 1 < quest.stages.length) {
state.stageIndex++;
this.emit('stage', { questId, index: state.stageIndex, stage: quest.stages[state.stageIndex] });
} else {
state.status = 'completed';
this.emit('completed', { questId });
}
}
override getVariables(): RPGVariables {
const result: RPGVariables = {};
for (const [questId, { state }] of this.#quests) {
result[`${questId}.status`] = state.status;
result[`${questId}.stage`] = state.stageIndex;
}
return result;
}
}
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;
}
}

View File

@ -1,51 +1,21 @@
import { action, variable } from "../utils/decorators"; import { action, variable } from "../decorators";
import { Component } from "../core/world"; import { RPGComponentBase } from "./entity";
export class Stat extends Component { export class Stat extends RPGComponentBase {
@variable('.') private value: number; @variable private value: number;
@variable private max: number | undefined; @variable private maxValue: number | undefined;
@variable private min: number | undefined;
constructor(value: number, max?: number, min?: number) { constructor(value: number, maxValue?: number) {
super(); super();
this.value = value; this.value = value;
this.max = max; this.maxValue = maxValue;
this.min = min;
} }
@action @action
update(amount: number) { update(amount: number) {
this.set(this.value + amount); this.value = Math.max(0, this.value + amount);
} if (this.maxValue) {
this.value = Math.min(this.value, this.maxValue);
@action
set(value: number) {
const prev = this.value;
this.value = value;
if (this.min != null) {
this.value = Math.max(this.min, this.value);
}
if (this.max != null) {
this.value = Math.min(this.value, this.max);
}
if (prev !== this.value) {
this.emit('set', { prev, value: this.value });
} }
} }
get current(): number {
return this.value;
}
}
export class Health extends Stat {
constructor(value: number, max?: number, min = 0) {
super(value, max, min);
}
@action
kill() {
this.set(0);
this.emit('killed');
}
} }

View File

@ -1,13 +1,13 @@
import type { RPGVariables } from "../types"; import type { RPGVariables } from "../types";
import { action } from "../utils/decorators"; import { action } from "../decorators";
import { Component } from "../core/world"; import { RPGComponentBase } from "./entity";
interface Var { interface Var {
key: string; key: string;
value: RPGVariables[string]; value: RPGVariables[string];
} }
export class Variables extends Component { export class Variables extends RPGComponentBase {
private readonly variables: RPGVariables = {}; private readonly variables: RPGVariables = {};
override getVariables() { override getVariables() {
@ -16,17 +16,13 @@ export class Variables extends Component {
@action @action
set({ key, value }: Var) { set({ key, value }: Var) {
const prev = this.variables[key];
this.variables[key] = value; this.variables[key] = value;
this.emit('set', { key, value, prev });
return this.variables; return this.variables;
} }
@action @action
unset(key: string) { unset(key: string) {
const prev = this.variables[key];
delete this.variables[key]; delete this.variables[key];
this.emit('unset', { key, prev });
return this.variables; return this.variables;
} }
@ -34,7 +30,7 @@ export class Variables extends Component {
increment({ key, value }: Var) { increment({ key, value }: Var) {
const currentValue = this.variables[key] ?? 0; const currentValue = this.variables[key] ?? 0;
if (typeof currentValue === 'number' && typeof value === 'number') { if (typeof currentValue === 'number' && typeof value === 'number') {
this.set({ key, value: currentValue + value }); this.variables[key] = currentValue + value;
} }
return this.variables; return this.variables;
} }

View File

@ -1,6 +1,4 @@
import type { RPGCondition, RPGVariables } from "../types"; import type { RPGCondition, RPGVariables } from "./types";
import type { EvalContext } from "../core/world";
import { resolveVariable } from "./variables";
type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null'; type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null';
type ConditionValue = string | number | boolean | null; type ConditionValue = string | number | boolean | null;
@ -41,7 +39,9 @@ export function parseCondition(s: RPGCondition): ParsedCondition {
return { variable, negate: false, operator: operator as ConditionOperator, value }; return { variable, negate: false, operator: operator as ConditionOperator, value };
} }
function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariables[string]): boolean { 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 === '~null') return val != null; if (operator === '~null') return val != null;
@ -60,21 +60,6 @@ function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariab
return false; return false;
} }
export function evaluateCondition(parsed: ParsedCondition, variables: RPGVariables): boolean; export function evaluateConditions(conditions: RPGCondition[], variables: RPGVariables): boolean {
export function evaluateCondition(parsed: ParsedCondition, ctx: EvalContext): boolean; return conditions.every(c => evaluateCondition(parseCondition(c), variables));
export function evaluateCondition(parsed: ParsedCondition, variablesOrCtx: RPGVariables | EvalContext): boolean {
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;
} }

View File

@ -1,267 +0,0 @@
import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators';
import type { RPGActions, RPGVariables } from '../types';
type Class<T> = abstract new (...args: any[]) => T;
type EventHandler = (data: unknown) => void;
export interface EvalContext {
self: Entity;
world: World;
}
export abstract class Component {
entity!: Entity;
key!: string;
protected emit(event: string, data?: unknown): void {
this.entity.emit(`${this.key}.${event}`, data);
}
onAdd(): void {}
onRemove(): void {}
getVariables(): RPGVariables {
const meta = (this.constructor as Function)[Symbol.metadata];
const keys = meta?.[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
if (!keys) return {};
const vars: RPGVariables = {};
for (const [methodKey, exportName] of keys) {
const v = (this as Record<string, unknown>)[String(methodKey)];
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') {
vars[exportName] = v;
}
}
return vars;
}
getActions(): RPGActions {
const meta = (this.constructor as Function)[Symbol.metadata];
const keys = meta?.[ACTION_KEYS] as Set<string | symbol> | undefined;
if (!keys) return {};
const actions: RPGActions = {};
for (const key of keys) {
const fn = (this as Record<string, unknown>)[String(key)];
if (typeof fn === 'function') {
actions[String(key)] = fn.bind(this);
}
}
return actions;
}
}
export abstract class System {
onAdd(_world: World): void {}
onRemove(_world: World): void {}
abstract update(world: World, dt: number): void;
}
export class Entity {
readonly #components = new Map<string, Component>();
constructor(
readonly id: string,
private readonly world: World,
) {}
add<T extends Component>(key: string, component: T): T {
const existing = this.#components.get(key);
if (existing) existing.onRemove();
component.entity = this;
component.key = key;
this.#components.set(key, component);
component.onAdd();
return component;
}
get<T extends Component>(key: string): T | undefined;
get<T extends Component>(ctor: Class<T>): T | undefined;
get<T extends Component>(ctor: Class<T>, key: string): T | undefined;
get<T extends Component>(ctorOrKey: Class<T> | string, key?: string): T | undefined {
if (typeof ctorOrKey === 'string') {
return this.#components.get(ctorOrKey) as T | undefined;
}
if (key !== undefined) {
const c = this.#components.get(key);
return c instanceof ctorOrKey ? c as T : undefined;
}
for (const c of this.#components.values()) {
if (c instanceof ctorOrKey) return c as T;
}
return undefined;
}
has(key: string): boolean;
has<T extends Component>(ctor: Class<T>): boolean;
has<T extends Component>(ctor: Class<T>, key: string): boolean;
has<T extends Component>(ctorOrKey: Class<T> | string, key?: string): boolean {
if (typeof ctorOrKey === 'string') return this.#components.has(ctorOrKey);
if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey;
for (const c of this.#components.values()) {
if (c instanceof ctorOrKey) return true;
}
return false;
}
remove(key: string): void;
remove<T extends Component>(ctor: Class<T>): void;
remove<T extends Component>(ctor: Class<T>, key: string): void;
remove<T extends Component>(ctorOrKey: Class<T> | string, key?: string): void {
if (typeof ctorOrKey === 'string') {
this.#removeByKey(ctorOrKey);
return;
}
if (key !== undefined) {
if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key);
return;
}
for (const [k, c] of this.#components) {
if (c instanceof ctorOrKey) { this.#removeByKey(k); return; }
}
}
#removeByKey(key: string): void {
const c = this.#components.get(key);
if (c) { c.onRemove(); this.#components.delete(key); }
}
emit(event: string, data?: unknown): void {
this.world.emit(this.id, event, data);
}
emitGlobal(event: string, data?: unknown): void {
this.world.emitGlobal(event, data);
}
on(event: string, handler: EventHandler): () => void {
return this.world.on(this.id, event, handler);
}
off(event: string, handler: EventHandler): void {
this.world.off(this.id, event, handler);
}
once(event: string, handler: EventHandler): () => void {
return this.world.once(this.id, event, handler);
}
destroy(): void {
this.world.destroyEntity(this);
}
/** @internal used by World.query and resolveVariables */
[Symbol.iterator](): IterableIterator<[string, Component]> {
return this.#components.entries();
}
/** @internal called by World.destroyEntity */
_destroy(): void {
for (const c of this.#components.values()) c.onRemove();
this.#components.clear();
}
}
export class World {
/** World-level variables, accessible in conditions via the $. prefix */
readonly globals: RPGVariables = {};
readonly #entities = new Map<string, Entity>();
readonly #handlers = new Map<string, Set<EventHandler>>();
readonly #globalHandlers = new Map<string, Set<EventHandler>>();
readonly #systems: System[] = [];
#entityCounter = 0;
createEntity(id?: string): Entity {
const entityId = id ?? `entity_${++this.#entityCounter}`;
if (this.#entities.has(entityId)) throw new Error(`Entity '${entityId}' already exists`);
const entity = new Entity(entityId, this);
this.#entities.set(entityId, entity);
return entity;
}
getEntity(id: string): Entity | undefined {
return this.#entities.get(id);
}
destroyEntity(entity: Entity): void {
entity._destroy();
this.#entities.delete(entity.id);
for (const key of this.#handlers.keys()) {
if (key.startsWith(`${entity.id}\0`)) this.#handlers.delete(key);
}
}
*query<T extends Component>(ctor: Class<T>): 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];
}
}
}
addSystem(system: System): void {
this.#systems.push(system);
system.onAdd(this);
}
removeSystem(system: System): void {
const idx = this.#systems.indexOf(system);
if (idx !== -1) { this.#systems.splice(idx, 1); system.onRemove(this); }
}
tick(dt: number): void {
for (const system of this.#systems) system.update(this, dt);
}
emit(entityId: string, event: string, data?: unknown): void {
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h(data));
}
emitGlobal(event: string, data?: unknown): void {
this.#globalHandlers.get(event)?.forEach(h => h(data));
}
on(event: string, handler: EventHandler): () => void;
on(entityId: string, event: string, handler: EventHandler): () => void;
on(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): () => void {
if (typeof arg2 === 'string') {
return this.#addHandler(this.#handlers, `${arg1}\0${arg2}`, arg3!);
}
return this.#addHandler(this.#globalHandlers, arg1, arg2);
}
off(event: string, handler: EventHandler): void;
off(entityId: string, event: string, handler: EventHandler): void;
off(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): void {
if (typeof arg2 === 'string') {
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(arg3!);
} else {
this.#globalHandlers.get(arg1)?.delete(arg2);
}
}
once(event: string, handler: EventHandler): () => void;
once(entityId: string, event: string, handler: EventHandler): () => void;
once(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): () => void {
if (typeof arg2 === 'string') {
const wrapped: EventHandler = data => { unsub(); arg3!(data); };
const unsub = this.on(arg1, arg2, wrapped);
return unsub;
}
const h = arg2;
const wrapped: EventHandler = data => { unsub(); h(data); };
const unsub = this.on(arg1, wrapped);
return unsub;
}
#addHandler(map: Map<string, Set<EventHandler>>, key: string, handler: EventHandler): () => void {
if (!map.has(key)) map.set(key, new Set());
map.get(key)!.add(handler);
return () => map.get(key)?.delete(handler);
}
}
export function isEvalContext(v: unknown): v is EvalContext {
return typeof v === 'object' && v != null
&& (v as EvalContext).self instanceof Entity
&& (v as EvalContext).world instanceof World;
}

View File

@ -1,4 +1,4 @@
import type { RPGVariables } from "../types"; import type { RPGVariables } from "./types";
export const ACTION_KEYS = Symbol('rpg.actions'); export const ACTION_KEYS = Symbol('rpg.actions');
export const VARIABLE_KEYS = Symbol('rpg.variables'); export const VARIABLE_KEYS = Symbol('rpg.variables');

View File

@ -11,10 +11,7 @@ import {
evaluateConditions, evaluateConditions,
parseCondition, parseCondition,
type ParsedCondition, type ParsedCondition,
} from "./utils/conditions"; } from "./conditions";
import { isEvalContext, type EvalContext } from "./core/world";
import { resolveVariables, executeAction } from "./utils/variables";
export interface DialogRuntimeOptions { export interface DialogRuntimeOptions {
variables: RPGVariables; variables: RPGVariables;
@ -48,7 +45,6 @@ export namespace Dialogs {
actions.add(action.type); actions.add(action.type);
} }
} }
return Array.from(actions);
} }
export function validate( export function validate(
@ -222,25 +218,12 @@ export class DialogEngine {
constructor( constructor(
dialog: Dialog, dialog: Dialog,
private readonly options: DialogRuntimeOptions | EvalContext, private readonly options: DialogRuntimeOptions,
) { ) {
this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n])); this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n]));
this.startNodeId = dialog.startNodeId; this.startNodeId = dialog.startNodeId;
} }
private getVariables(): RPGVariables {
if (isEvalContext(this.options)) return resolveVariables(this.options.self);
return this.options.variables;
}
private async runAction(action: { type: string; arg?: string | number | boolean }): Promise<void> {
if (isEvalContext(this.options)) {
await executeAction(action, this.options);
} else {
await this.options.actions[action.type]?.(action.arg);
}
}
async advance(nodeId?: string): Promise<DialogNodeView | null> { async advance(nodeId?: string): Promise<DialogNodeView | null> {
let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined); let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined);
@ -248,8 +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;
const vars = this.getVariables(); if (!evaluateConditions(node.conditions ?? [], this.options.variables)) {
if (!evaluateConditions(node.conditions ?? [], vars)) {
targetId = node.nextNodeId; targetId = node.nextNodeId;
continue; continue;
} }
@ -257,7 +239,7 @@ export class DialogEngine {
this.currentNode = node; this.currentNode = node;
for (const action of node.actions ?? []) { for (const action of node.actions ?? []) {
await this.runAction(action); await this.options.actions[action.type]?.(action.arg);
} }
const choices = this.filterChoices(node.choices); const choices = this.filterChoices(node.choices);
@ -276,11 +258,10 @@ export class DialogEngine {
private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined { private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined {
if (!choices) return undefined; if (!choices) return undefined;
const vars = this.getVariables();
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 ? evaluateConditions(target.conditions ?? [], vars) : false; return target ? evaluateConditions(target.conditions ?? [], this.options.variables) : false;
}); });
} }

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

@ -0,0 +1,134 @@
import { RPGComponentBase } from "./components/entity";
import { evaluateConditions } from "./conditions";
import { action, variable } from "./decorators";
import {
isQuest,
type Quest,
type QuestStage,
type RPGActions,
type RPGVariables,
} from "./types";
export type QuestStatus = 'inactive' | 'active' | 'completed';
export interface QuestRuntimeOptions {
getVariables(): RPGVariables;
getActions(): 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 extends RPGComponentBase {
@variable('status') private _status: QuestStatus = 'inactive';
@variable('stage') private _stageIndex: number = 0;
constructor(
private readonly quest: Quest,
private readonly options: QuestRuntimeOptions,
) {
super();
}
get id(): string {
return this.quest.id;
}
isAvailable(): boolean {
return evaluateConditions(this.quest.conditions ?? [], this.options.getVariables());
}
@action
start(): void {
this._status = 'active';
this._stageIndex = 0;
}
@action
async checkAndAdvance(): Promise<void> {
if (this._status !== 'active') return;
const stage = this.quest.stages[this._stageIndex];
if (!stage) return;
const allDone = evaluateConditions(stage.objectives.map(obj => obj.condition), this.options.getVariables());
if (!allDone) return;
const actions = this.options.getActions();
for (const action of stage.actions) {
await actions[action.type]?.(action.arg);
}
if (this._stageIndex + 1 < this.quest.stages.length) {
this._stageIndex++;
} else {
this._status = 'completed';
}
}
get currentStage(): QuestStage | null {
return this.quest.stages[this._stageIndex] ?? null;
}
get status(): QuestStatus {
return this._status;
}
}
export class QuestManager extends RPGComponentBase {
private readonly engines: Map<string, QuestEngine>;
constructor(quests: Quest[], options: QuestRuntimeOptions) {
super();
this.engines = new Map(quests.map(q => [q.id, new QuestEngine(q, options)]));
}
@action
start(questId: string): void {
this.engines.get(questId)?.start();
}
@action
async checkAndAdvance(): Promise<void> {
for (const engine of this.engines.values()) {
await engine.checkAndAdvance();
}
}
getEngine(questId: string): QuestEngine | undefined {
return this.engines.get(questId);
}
override getVariables(): RPGVariables {
const result: RPGVariables = {};
for (const [key, engine] of this.engines) {
for (const [varKey, value] of Object.entries(engine.getVariables())) {
if (value != null) {
result[`${key}.${varKey}`] = value;
}
}
}
return result;
}
}

View File

@ -1,41 +0,0 @@
import { System, type Entity, type World } from "../core/world";
import { evaluateCondition, parseCondition } 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 {
for (const [entity] of world.query(QuestLog)) {
void 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> {
const questLog = entity.get(QuestLog);
if (!questLog) return;
const ctx = { self: entity, world };
for (const [questId, { quest, state }] of questLog.entries()) {
if (state.status !== 'active') continue;
const stage = quest.stages[state.stageIndex];
if (!stage) continue;
const allDone = stage.objectives.every(obj =>
evaluateCondition(parseCondition(obj.condition), ctx)
);
if (!allDone) continue;
for (const action of stage.actions) {
await executeAction(action, ctx);
}
questLog._advance(questId);
}
}
}

View File

@ -1,79 +1,101 @@
import { Type, type Static } from '../typebox';
// ── Shared ────────────────────────────────────────────────────────────────────
const RPGActionScheme = Type.Object({
type: Type.String(),
arg: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Boolean()])),
});
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 interface RPGAction {
type: string;
arg?: string | number | boolean | null;
}
export type RPGActions = Record<string, (arg?: any) => unknown>; export type RPGActions = Record<string, (arg?: any) => unknown>;
export type RPGAction = Static<typeof RPGActionScheme>;
// ── Dialog ──────────────────────────────────────────────────────────────────── export interface DialogChoice {
text: string;
nextNodeId?: string;
}
const DialogChoiceScheme = Type.Object({ export interface DialogNode {
text: Type.String(), id: string;
nextNodeId: Type.Optional(Type.String()), speaker: string;
}); text: string;
nextNodeId?: string;
choices?: DialogChoice[];
conditions?: RPGCondition[];
actions?: RPGAction[];
}
const DialogNodeScheme = Type.Object({ export interface Dialog {
id: Type.String(), nodes: DialogNode[];
speaker: Type.String(), startNodeId: string;
text: Type.String(), }
nextNodeId: Type.Optional(Type.String()),
choices: Type.Optional(Type.Array(DialogChoiceScheme)),
conditions: Type.Optional(Type.Array(Type.String())),
actions: Type.Optional(Type.Array(RPGActionScheme)),
});
const DialogScheme = Type.Object({ export interface QuestObjective {
nodes: Type.Array(DialogNodeScheme), id: string;
startNodeId: Type.String(), description: string;
}); condition: RPGCondition;
}
export type DialogChoice = Static<typeof DialogChoiceScheme>; export interface QuestStage {
export type DialogNode = Static<typeof DialogNodeScheme>; id: string;
export type Dialog = Static<typeof DialogScheme>; 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';
}
function isDialogChoice(v: unknown): v is DialogChoice {
if (typeof v !== 'object' || v === null) return false;
const c = v as Record<string, unknown>;
return typeof c.text === 'string'
&& (c.nextNodeId === undefined || typeof c.nextNodeId === 'string');
}
function isDialogNode(v: unknown): v is DialogNode {
if (typeof v !== 'object' || v === null) return false;
const n = v as Record<string, unknown>;
return typeof n.id === 'string'
&& typeof n.speaker === 'string'
&& 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(c => typeof c === 'string')))
&& (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 {
return Type.Is(DialogScheme, v); if (typeof v !== 'object' || v === null) return false;
const d = v as Record<string, unknown>;
return Array.isArray(d.nodes) && d.nodes.every(isDialogNode)
&& typeof d.startNodeId === 'string';
} }
// ── Quest ───────────────────────────────────────────────────────────────────── function isQuestObjective(v: unknown): v is QuestObjective {
if (typeof v !== 'object' || v === null) return false;
const QuestObjectiveScheme = Type.Object({ const o = v as Record<string, unknown>;
id: Type.String(), return typeof o.id === 'string'
description: Type.String(), && typeof o.description === 'string'
condition: Type.String(), && typeof o.condition === 'string';
});
const QuestStageScheme = Type.Object({
id: Type.String(),
description: Type.String(),
objectives: Type.Array(QuestObjectiveScheme),
actions: Type.Array(RPGActionScheme),
});
const QuestScheme = Type.Object({
id: Type.String(),
title: Type.String(),
description: Type.String(),
conditions: Type.Optional(Type.Array(Type.String())),
stages: Type.Array(QuestStageScheme),
});
export type QuestObjective = Static<typeof QuestObjectiveScheme>;
export type QuestStage = Static<typeof QuestStageScheme>;
export type Quest = Static<typeof QuestScheme>;
export function isQuest(v: unknown): v is Quest {
return Type.Is(QuestScheme, v);
} }
// ── Inventory ───────────────────────────────────────────────────────────────── 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 type SlotId = string | number; export type SlotId = string | number;
@ -87,3 +109,13 @@ export type InventorySlotInput = SlotId | InventorySlotDefinition;
export interface InventoryOptions { export interface InventoryOptions {
maxAmountPerItem?: Record<string, number>; maxAmountPerItem?: Record<string, number>;
} }
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')));
}

View File

@ -1,66 +0,0 @@
import type { RPGAction, RPGActions, RPGVariables } from "../types";
import type { EvalContext, Entity } from "../core/world";
export function resolveVariables(entity: Entity): RPGVariables {
const result: RPGVariables = {};
for (const [key, component] of entity) {
for (const [varKey, value] of Object.entries(component.getVariables())) {
if (value != null) {
if (varKey && varKey !== '.') {
result[`${key}.${varKey}`] = value;
} else {
result[key] = value;
}
}
}
}
return result;
}
export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[string] {
// $. prefix → world globals
if (name.startsWith('$.')) {
return ctx.world.globals[name.slice(2)];
}
// @entityId.component.variable → another entity
if (name.startsWith('@')) {
const dotIdx = name.indexOf('.', 1);
if (dotIdx === -1) return undefined;
const entityId = name.slice(1, dotIdx);
const varName = name.slice(dotIdx + 1);
const entity = ctx.world.getEntity(entityId);
if (!entity) return undefined;
return resolveVariables(entity)[varName];
}
// bare name → self entity
return resolveVariables(ctx.self)[name];
}
export function resolveActions(entity: Entity): RPGActions {
const result: RPGActions = {};
for (const [key, component] of entity) {
for (const [actionKey, fn] of Object.entries(component.getActions())) {
result[`${key}.${actionKey}`] = fn;
}
}
return result;
}
export async function executeAction(action: RPGAction, ctx: EvalContext): Promise<unknown> {
let entity = ctx.self;
let actionType = action.type;
// @entityId.component.action → dispatch to another entity
if (action.type.startsWith('@')) {
const dotIdx = action.type.indexOf('.', 1);
if (dotIdx === -1) return;
const entityId = action.type.slice(1, dotIdx);
const found = ctx.world.getEntity(entityId);
if (!found) return;
entity = found;
actionType = action.type.slice(dotIdx + 1);
}
const actions = resolveActions(entity);
return actions[actionType]?.(action.arg);
}

View File

@ -64,14 +64,6 @@ export namespace Type {
return result; return result;
} }
export function Union<T extends TScheme[]>(anyOf: [...T], args: { description?: string } = {}): TUnion<T> {
const result: TUnion<T> = { type: 'union', anyOf };
if (args.description) {
result.description = args.description;
}
return result;
}
export function Optional<T extends TScheme = TScheme>(scheme: T): TOptional<T> { export function Optional<T extends TScheme = TScheme>(scheme: T): TOptional<T> {
const result = { ...scheme }; const result = { ...scheme };
GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false }); GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false });
@ -166,13 +158,6 @@ export namespace Type {
} }
return errors; return errors;
} }
case 'union': {
const s = scheme as TUnion;
for (const branch of s.anyOf) {
if (check(branch, value, path).length === 0) return [];
}
return [{ path, message: `Expected union type at ${path}, got ${typeof value}` }];
}
default: default:
return []; return [];
} }
@ -239,28 +224,17 @@ export interface TOptionalObject<T extends TProperties = TProperties> extends TO
[optional]: true; [optional]: true;
} }
export interface TUnion<T extends TScheme[] = TScheme[]> {
type: 'union';
anyOf: T;
description?: string;
}
export interface TOptionalUnion<T extends TScheme[] = TScheme[]> extends TUnion<T> {
[optional]: true;
}
export type TOptional<T extends TScheme = TScheme> = export type TOptional<T extends TScheme = TScheme> =
T extends TString<infer S> ? TOptionalString<S> : T extends TString<infer S> ? TOptionalString<S> :
T extends TNumber<infer N> ? TOptionalNumber<N> : T extends TNumber<infer N> ? TOptionalNumber<N> :
T extends TBoolean ? TOptionalBoolean : T extends TBoolean ? TOptionalBoolean :
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> :
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 = TString | TNumber | TBoolean | TArray | TObject | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject;
type Prettify<T> = { [K in keyof T]: T[K] } & {}; type Prettify<T> = { [K in keyof T]: T[K] } & {};
@ -277,16 +251,10 @@ type StaticObject<T extends TProperties> = Prettify<
{ [K in OptionalKeys<T>]?: Static<T[K]> } { [K in OptionalKeys<T>]?: Static<T[K]> }
>; >;
type StaticUnion<T extends TScheme[]> =
T extends [infer First extends TScheme, ...infer Rest extends TScheme[]]
? Static<First> | StaticUnion<Rest>
: never;
export type Static<T extends TScheme> = export type Static<T extends TScheme> =
T extends TString<infer S> ? S : T extends TString<infer S> ? S :
T extends TNumber<infer N> ? N : T extends TNumber<infer N> ? N :
T extends TBoolean ? boolean : T extends TBoolean ? boolean :
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> :
never; never;

View File

@ -1,37 +1,41 @@
import { World } from "@common/rpg/core/world"; import { RPGEntity } from "@common/rpg/components/entity";
import { Inventory } from "@common/rpg/components/inventory"; import { Inventory } from "@common/rpg/components/inventory";
import { Health } from "@common/rpg/components/stat"; import { Stat } from "@common/rpg/components/stat";
import { Variables } from "@common/rpg/components/variables"; import { Variables } from "@common/rpg/components/variables";
import { QuestLog } from "@common/rpg/components/questLog"; import { QuestManager } from "@common/rpg/quest";
import { QuestSystem } from "@common/rpg/systems/questSystem";
import { resolveVariables, resolveActions } from "@common/rpg/utils/variables";
export default async function main() { export default async function main() {
const world = new World(); const game = new RPGEntity();
world.addSystem(new QuestSystem()); const player = new RPGEntity();
const inventory = new Inventory(['head', 'legs']);
const player = world.createEntity('player'); const quests = new QuestManager([{
player.add('inventory', new Inventory(['head', 'legs']));
player.add('health', new Health(100, 100));
player.add('vars', new Variables());
player.add('quests', new QuestLog());
const quests = player.get(QuestLog)!;
quests.addQuest({
id: 'test', id: 'test',
description: 'Test quest', description: 'Test quest',
title: 'Test', title: 'Test',
stages: [], stages: [],
}], game);
const vars = new Variables();
game.addComponent('variables', vars);
game.addComponent('player', player);
game.addComponent('quests', quests);
player.addComponent('inventory', inventory);
player.addComponent('health', new Stat(100));
console.log(game.getActions());
inventory.addItem({
itemId: 'helmet',
amount: 1,
slotId: 'head',
}); });
inventory.addItem({
console.log(resolveVariables(player)); itemId: 'boots',
amount: 2,
const inventory = player.get(Inventory)!; });
inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head' }); inventory.addItem({
itemId: 'belt',
const actions = resolveActions(player); amount: 1,
actions['inventory.add']({ itemId: 'boots', amount: 2 }); });
console.log(game.getVariables());
console.log(actions);
console.log(resolveVariables(player));
} }