Compare commits
4 Commits
ad1da396e6
...
8b93a732a7
| Author | SHA1 | Date |
|---|---|---|
|
|
8b93a732a7 | |
|
|
b866e0a5de | |
|
|
066271205a | |
|
|
9bec7701e0 |
|
|
@ -11,6 +11,7 @@
|
||||||
"backend:dev": "bun --hot backend/src/index.ts",
|
"backend:dev": "bun --hot backend/src/index.ts",
|
||||||
"register": "bun backend/src/register.ts",
|
"register": "bun backend/src/register.ts",
|
||||||
"login": "bun backend/src/login.ts",
|
"login": "bun backend/src/login.ts",
|
||||||
|
"lint": "bun tsc --noEmit --skipLibCheck",
|
||||||
"docker:build": "docker build -t git.pabloader.ru/pabloid/tsgames:latest backend/",
|
"docker:build": "docker build -t git.pabloader.ru/pabloid/tsgames:latest backend/",
|
||||||
"docker:push": "docker push git.pabloader.ru/pabloid/tsgames:latest",
|
"docker:push": "docker push git.pabloader.ru/pabloid/tsgames:latest",
|
||||||
"docker:update": "docker service update --force --image git.pabloader.ru/pabloid/tsgames:latest tsgames_backend",
|
"docker:update": "docker service update --force --image git.pabloader.ru/pabloid/tsgames:latest tsgames_backend",
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,53 @@
|
||||||
import type { InventorySlotInput, RPGVariables, SlotId } from "../types";
|
import type { InventorySlotInput, RPGVariables, SlotId } from "../types";
|
||||||
import { action } from "../utils/decorators";
|
import { action } from "../utils/decorators";
|
||||||
import { Component, type EvalContext } from "../core/world";
|
import { Component, type EvalContext } from "../core/world";
|
||||||
|
import { component } from "../core/registry";
|
||||||
import { Stackable, Usable } from "./item";
|
import { Stackable, Usable } from "./item";
|
||||||
import { resolveVariables } from "../utils/variables";
|
import { resolveVariables } from "../utils/variables";
|
||||||
|
|
||||||
interface SlotEntry {
|
interface SlotRecord {
|
||||||
readonly slotId: SlotId;
|
slotId: SlotId;
|
||||||
readonly limit: number | undefined;
|
limit: number | undefined;
|
||||||
state: { itemId: string; amount: number } | null;
|
contents: { itemId: string; amount: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SlotUpdateArgs {
|
interface InventoryState {
|
||||||
itemId: string;
|
slots: SlotRecord[];
|
||||||
amount: number;
|
|
||||||
slotId?: SlotId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Inventory extends Component {
|
@component
|
||||||
private readonly slots: Map<SlotId, SlotEntry>;
|
export class Inventory extends Component<InventoryState> {
|
||||||
|
#cachedVars: RPGVariables | null = null;
|
||||||
|
|
||||||
constructor(slotDefs: Array<InventorySlotInput>) {
|
constructor(slotDefs: Array<InventorySlotInput>) {
|
||||||
super();
|
super({
|
||||||
this.slots = new Map(
|
slots: slotDefs.map(def => {
|
||||||
slotDefs.map(def => {
|
|
||||||
const slotId = typeof def === 'object' ? def.slotId : def;
|
const slotId = typeof def === 'object' ? def.slotId : def;
|
||||||
const limit = typeof def === 'object' ? def.limit : undefined;
|
const limit = typeof def === 'object' ? def.limit : undefined;
|
||||||
return [slotId, { slotId, limit, state: null }];
|
return { slotId, limit, contents: null };
|
||||||
})
|
}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private slotCapFor(slot: SlotEntry, itemId: string): number {
|
#slot(slotId: SlotId): SlotRecord | undefined {
|
||||||
|
return this.state.slots.find(s => s.slotId === slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#capFor(slot: SlotRecord, itemId: string): number {
|
||||||
const limitCap = slot.limit ?? Infinity;
|
const limitCap = slot.limit ?? Infinity;
|
||||||
const stackable = this.entity.world.getEntity(itemId)?.get(Stackable);
|
const stackable = this.entity.world.getEntity(itemId)?.get(Stackable);
|
||||||
const stackCap = stackable ? stackable.maxStack : 1;
|
const stackCap = stackable ? stackable.maxStack : 1;
|
||||||
return Math.min(limitCap, stackCap);
|
return Math.min(limitCap, stackCap);
|
||||||
}
|
}
|
||||||
|
|
||||||
private slotRoomFor(slot: SlotEntry, itemId: string): number {
|
#roomFor(slot: SlotRecord, itemId: string): number {
|
||||||
if (slot.state !== null && slot.state.itemId !== itemId) return 0;
|
if (slot.contents !== null && slot.contents.itemId !== itemId) return 0;
|
||||||
return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0);
|
return this.#capFor(slot, itemId) - (slot.contents?.amount ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
add({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
add({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean {
|
||||||
|
this.#cachedVars = null;
|
||||||
if (amount < 0) return false;
|
if (amount < 0) return false;
|
||||||
if (amount === 0) return true;
|
if (amount === 0) return true;
|
||||||
|
|
||||||
|
|
@ -53,19 +57,19 @@ export class Inventory extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slotId !== undefined) {
|
if (slotId !== undefined) {
|
||||||
const slot = this.slots.get(slotId);
|
const slot = this.#slot(slotId);
|
||||||
if (!slot) return false;
|
if (!slot) return false;
|
||||||
if (this.slotRoomFor(slot, itemId) < amount) return false;
|
if (this.#roomFor(slot, itemId) < amount) return false;
|
||||||
slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount };
|
slot.contents = { itemId, amount: (slot.contents?.amount ?? 0) + amount };
|
||||||
this.emit('add', { itemId, amount, slotIds: [slotId] });
|
this.emit('add', { itemId, amount, slotIds: [slotId] });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-phase: pre-check then apply
|
// Two-phase: pre-check then apply
|
||||||
let remaining = amount;
|
let remaining = amount;
|
||||||
for (const slot of this.slots.values()) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.state === null || slot.state.itemId === itemId) {
|
if (slot.contents === null || slot.contents.itemId === itemId) {
|
||||||
remaining -= Math.min(this.slotRoomFor(slot, itemId), remaining);
|
remaining -= Math.min(this.#roomFor(slot, itemId), remaining);
|
||||||
if (remaining === 0) break;
|
if (remaining === 0) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,24 +78,24 @@ export class Inventory extends Component {
|
||||||
// Apply — fill existing slots for this item first, then empty ones
|
// Apply — fill existing slots for this item first, then empty ones
|
||||||
remaining = amount;
|
remaining = amount;
|
||||||
const slotIds: SlotId[] = [];
|
const slotIds: SlotId[] = [];
|
||||||
for (const [id, slot] of this.slots) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.state?.itemId === itemId) {
|
if (slot.contents?.itemId === itemId) {
|
||||||
const take = Math.min(this.slotRoomFor(slot, itemId), remaining);
|
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
||||||
slot.state.amount += take;
|
slot.contents.amount += take;
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(id);
|
slotIds.push(slot.slotId);
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
this.emit('add', { itemId, amount, slotIds });
|
this.emit('add', { itemId, amount, slotIds });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [id, slot] of this.slots) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.state === null) {
|
if (slot.contents === null) {
|
||||||
const take = Math.min(this.slotCapFor(slot, itemId), remaining);
|
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
||||||
slot.state = { itemId, amount: take };
|
slot.contents = { itemId, amount: take };
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(id);
|
slotIds.push(slot.slotId);
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
this.emit('add', { itemId, amount, slotIds });
|
this.emit('add', { itemId, amount, slotIds });
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -103,16 +107,17 @@ export class Inventory extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
remove({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
remove({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean {
|
||||||
|
this.#cachedVars = null;
|
||||||
if (amount < 0) return false;
|
if (amount < 0) return false;
|
||||||
if (amount === 0) return true;
|
if (amount === 0) return true;
|
||||||
|
|
||||||
if (slotId !== undefined) {
|
if (slotId !== undefined) {
|
||||||
const slot = this.slots.get(slotId);
|
const slot = this.#slot(slotId);
|
||||||
if (!slot || slot.state?.itemId !== itemId) return false;
|
if (!slot || slot.contents?.itemId !== itemId) return false;
|
||||||
if (slot.state.amount < amount) return false;
|
if (slot.contents.amount < amount) return false;
|
||||||
slot.state.amount -= amount;
|
slot.contents.amount -= amount;
|
||||||
if (slot.state.amount === 0) slot.state = null;
|
if (slot.contents.amount === 0) slot.contents = null;
|
||||||
this.emit('remove', { itemId, amount, slotIds: [slotId] });
|
this.emit('remove', { itemId, amount, slotIds: [slotId] });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -121,13 +126,13 @@ export class Inventory extends Component {
|
||||||
|
|
||||||
let remaining = amount;
|
let remaining = amount;
|
||||||
const slotIds: SlotId[] = [];
|
const slotIds: SlotId[] = [];
|
||||||
for (const [id, slot] of this.slots) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.state?.itemId === itemId) {
|
if (slot.contents?.itemId === itemId) {
|
||||||
const take = Math.min(slot.state.amount, remaining);
|
const take = Math.min(slot.contents.amount, remaining);
|
||||||
slot.state.amount -= take;
|
slot.contents.amount -= take;
|
||||||
if (slot.state.amount === 0) slot.state = null;
|
if (slot.contents.amount === 0) slot.contents = null;
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(id);
|
slotIds.push(slot.slotId);
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
this.emit('remove', { itemId, amount, slotIds });
|
this.emit('remove', { itemId, amount, slotIds });
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -170,27 +175,29 @@ export class Inventory extends Component {
|
||||||
|
|
||||||
getAmount(itemId: string, slotId?: SlotId): number {
|
getAmount(itemId: string, slotId?: SlotId): number {
|
||||||
if (slotId !== undefined) {
|
if (slotId !== undefined) {
|
||||||
const slot = this.slots.get(slotId);
|
const slot = this.#slot(slotId);
|
||||||
return slot?.state?.itemId === itemId ? slot.state.amount : 0;
|
return slot?.contents?.itemId === itemId ? slot.contents.amount : 0;
|
||||||
}
|
}
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const slot of this.slots.values()) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.state?.itemId === itemId) total += slot.state.amount;
|
if (slot.contents?.itemId === itemId) total += slot.contents.amount;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
getItems(): Map<string, number> {
|
getItems(): Map<string, number> {
|
||||||
const result = new Map<string, number>();
|
const result = new Map<string, number>();
|
||||||
for (const slot of this.slots.values()) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.state) {
|
if (slot.contents) {
|
||||||
result.set(slot.state.itemId, (result.get(slot.state.itemId) ?? 0) + slot.state.amount);
|
const { itemId, amount } = slot.contents;
|
||||||
|
result.set(itemId, (result.get(itemId) ?? 0) + amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
override getVariables(): RPGVariables {
|
override getVariables(): RPGVariables {
|
||||||
|
if (this.#cachedVars) return this.#cachedVars;
|
||||||
const result: RPGVariables = {};
|
const result: RPGVariables = {};
|
||||||
for (const [itemId, amount] of this.getItems()) {
|
for (const [itemId, amount] of this.getItems()) {
|
||||||
result[itemId] = amount;
|
result[itemId] = amount;
|
||||||
|
|
@ -201,6 +208,7 @@ export class Inventory extends Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.#cachedVars = result;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,55 @@
|
||||||
import { Component, type EvalContext, type World } from "../core/world";
|
import { Component, type EvalContext, type World } from "../core/world";
|
||||||
|
import { component } from "../core/registry";
|
||||||
import type { RPGAction } from "../types";
|
import type { RPGAction } from "../types";
|
||||||
import { action, variable } from "../utils/decorators";
|
import { action, variable } from "../utils/decorators";
|
||||||
import { executeAction } from "../utils/variables";
|
import { executeAction } from "../utils/variables";
|
||||||
|
|
||||||
export class Item extends Component {
|
interface ItemState {
|
||||||
constructor(
|
name: string;
|
||||||
readonly name: string,
|
description: string;
|
||||||
readonly description: string = '',
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Stackable extends Component {
|
@component
|
||||||
@variable readonly maxStack: number;
|
export class Item extends Component<ItemState> {
|
||||||
|
constructor(name: string, description = '') {
|
||||||
|
super({ name, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string { return this.state.name; }
|
||||||
|
get description(): string { return this.state.description; }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StackableState {
|
||||||
|
maxStack: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@component
|
||||||
|
export class Stackable extends Component<StackableState> {
|
||||||
constructor(maxStack: number) {
|
constructor(maxStack: number) {
|
||||||
super();
|
super({ maxStack });
|
||||||
this.maxStack = maxStack;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Usable extends Component {
|
@variable get maxStack(): number { return this.state.maxStack; }
|
||||||
@variable readonly consumeOnUse: boolean;
|
|
||||||
constructor(private readonly actions: RPGAction[], consumeOnUse = true) {
|
|
||||||
super();
|
|
||||||
this.consumeOnUse = consumeOnUse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UsableState {
|
||||||
|
actions: RPGAction[];
|
||||||
|
consumeOnUse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@component
|
||||||
|
export class Usable extends Component<UsableState> {
|
||||||
|
constructor(actions: RPGAction[], consumeOnUse = true) {
|
||||||
|
super({ actions, consumeOnUse });
|
||||||
|
}
|
||||||
|
|
||||||
|
@variable get consumeOnUse(): boolean { return this.state.consumeOnUse; }
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async use(arg?: EvalContext, ctx?: EvalContext): Promise<void> {
|
async use(arg?: EvalContext, ctx?: EvalContext): Promise<void> {
|
||||||
ctx = arg ?? ctx ?? this.context;
|
ctx = arg ?? ctx ?? this.context;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
for (const action of this.actions) {
|
for (const action of this.state.actions) {
|
||||||
await executeAction(action, ctx);
|
await executeAction(action, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Component, type EvalContext } from "../core/world";
|
import { Component, type EvalContext } from "../core/world";
|
||||||
|
import { component } from "../core/registry";
|
||||||
import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types";
|
import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types";
|
||||||
import { evaluateCondition } from "../utils/conditions";
|
import { evaluateCondition } from "../utils/conditions";
|
||||||
|
|
||||||
|
|
@ -14,29 +15,49 @@ export interface QuestEntry {
|
||||||
state: QuestRuntimeState;
|
state: QuestRuntimeState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QuestLog extends Component {
|
interface QuestLogState {
|
||||||
readonly #quests = new Map<string, QuestEntry>();
|
quests: Record<string, Quest>;
|
||||||
|
runtimeStates: Record<string, QuestRuntimeState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@component
|
||||||
|
export class QuestLog extends Component<QuestLogState> {
|
||||||
|
#cachedVars: RPGVariables | null = null;
|
||||||
|
|
||||||
|
constructor(quests: Quest[] = []) {
|
||||||
|
const questsRecord: Record<string, Quest> = {};
|
||||||
|
const runtimeStates: Record<string, QuestRuntimeState> = {};
|
||||||
|
for (const q of quests) {
|
||||||
|
questsRecord[q.id] = q;
|
||||||
|
runtimeStates[q.id] = { status: 'inactive', stageIndex: 0 };
|
||||||
|
}
|
||||||
|
super({ quests: questsRecord, runtimeStates });
|
||||||
|
}
|
||||||
|
|
||||||
addQuest(quest: Quest): void {
|
addQuest(quest: Quest): void {
|
||||||
if (this.#quests.has(quest.id)) {
|
if (this.state.quests[quest.id]) {
|
||||||
console.warn(`[QuestLog] quest '${quest.id}' is already registered, ignoring duplicate`);
|
console.warn(`[QuestLog] quest '${quest.id}' is already registered, ignoring duplicate`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#quests.set(quest.id, { quest, state: { status: 'inactive', stageIndex: 0 } });
|
this.state.quests[quest.id] = quest;
|
||||||
|
this.state.runtimeStates[quest.id] = { status: 'inactive', stageIndex: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#invalidate() { this.#cachedVars = null; }
|
||||||
|
|
||||||
#transition(op: string, questId: string, from: QuestStatus, to: QuestStatus, event: string): boolean {
|
#transition(op: string, questId: string, from: QuestStatus, to: QuestStatus, event: string): boolean {
|
||||||
const entry = this.#quests.get(questId);
|
const runtimeState = this.state.runtimeStates[questId];
|
||||||
if (!entry) {
|
if (!runtimeState) {
|
||||||
console.warn(`[QuestLog] ${op}: quest '${questId}' is not registered`);
|
console.warn(`[QuestLog] ${op}: quest '${questId}' is not registered`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (entry.state.status !== from) {
|
if (runtimeState.status !== from) {
|
||||||
console.warn(`[QuestLog] ${op}: quest '${questId}' cannot transition from status '${entry.state.status}'`);
|
console.warn(`[QuestLog] ${op}: quest '${questId}' cannot transition from status '${runtimeState.status}'`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
entry.state.status = to;
|
runtimeState.status = to;
|
||||||
if (to === 'active' || to === 'inactive') entry.state.stageIndex = 0;
|
if (to === 'active' || to === 'inactive') runtimeState.stageIndex = 0;
|
||||||
|
this.#invalidate();
|
||||||
this.emit(event, { questId });
|
this.emit(event, { questId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -58,21 +79,21 @@ export class QuestLog extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(questId: string): QuestRuntimeState | undefined {
|
getState(questId: string): QuestRuntimeState | undefined {
|
||||||
return this.#quests.get(questId)?.state;
|
return this.state.runtimeStates[questId];
|
||||||
}
|
}
|
||||||
|
|
||||||
isAvailable(questId: string, ctx: EvalContext): boolean {
|
isAvailable(questId: string, ctx: EvalContext): boolean {
|
||||||
const entry = this.#quests.get(questId);
|
const quest = this.state.quests[questId];
|
||||||
if (!entry) return false;
|
if (!quest) return false;
|
||||||
const { quest } = entry;
|
|
||||||
if (!quest.conditions?.length) return true;
|
if (!quest.conditions?.length) return true;
|
||||||
return quest.conditions.every(c => evaluateCondition(c, ctx));
|
return quest.conditions.every(c => evaluateCondition(c, ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
getStage(questId: string): QuestStage | undefined {
|
getStage(questId: string): QuestStage | undefined {
|
||||||
const entry = this.#quests.get(questId);
|
const quest = this.state.quests[questId];
|
||||||
if (!entry || entry.state.status !== 'active') return undefined;
|
const runtimeState = this.state.runtimeStates[questId];
|
||||||
return entry.quest.stages[entry.state.stageIndex];
|
if (!quest || runtimeState?.status !== 'active') return undefined;
|
||||||
|
return quest.stages[runtimeState.stageIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
getObjectiveProgress(
|
getObjectiveProgress(
|
||||||
|
|
@ -89,31 +110,33 @@ export class QuestLog extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal used by QuestSystem */
|
/** @internal used by QuestSystem */
|
||||||
entries(): IterableIterator<[string, QuestEntry]> {
|
*entries(): Generator<[string, QuestEntry], void, unknown> {
|
||||||
return this.#quests.entries();
|
for (const [id, quest] of Object.entries(this.state.quests)) {
|
||||||
|
yield [id, { quest, state: this.state.runtimeStates[id]! }];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @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 quest = this.state.quests[questId];
|
||||||
if (!entry) return;
|
const runtimeState = this.state.runtimeStates[questId];
|
||||||
const { quest, state } = entry;
|
if (!quest || !runtimeState) return;
|
||||||
if (state.stageIndex + 1 < quest.stages.length) {
|
this.#invalidate();
|
||||||
state.stageIndex++;
|
if (runtimeState.stageIndex + 1 < quest.stages.length) {
|
||||||
this.emit('stage', { questId, index: state.stageIndex, stage: quest.stages[state.stageIndex] });
|
runtimeState.stageIndex++;
|
||||||
|
this.emit('stage', { questId, index: runtimeState.stageIndex, stage: quest.stages[runtimeState.stageIndex] });
|
||||||
} else {
|
} else {
|
||||||
state.status = 'completed';
|
runtimeState.status = 'completed';
|
||||||
this.emit('completed', { questId });
|
this.emit('completed', { questId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override getActions() {
|
override getActions() {
|
||||||
const result = { ...super.getActions() };
|
const result = { ...super.getActions() };
|
||||||
for (const questId of this.#quests.keys()) {
|
for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) {
|
||||||
const entry = this.#quests.get(questId)!;
|
if (runtimeState.status === 'inactive') {
|
||||||
if (entry.state.status === 'inactive') {
|
|
||||||
result[`${questId}.start`] = this.start.bind(this, questId);
|
result[`${questId}.start`] = this.start.bind(this, questId);
|
||||||
} else if (entry.state.status === 'active') {
|
} else if (runtimeState.status === 'active') {
|
||||||
result[`${questId}.complete`] = this.complete.bind(this, questId);
|
result[`${questId}.complete`] = this.complete.bind(this, questId);
|
||||||
result[`${questId}.fail`] = this.fail.bind(this, questId);
|
result[`${questId}.fail`] = this.fail.bind(this, questId);
|
||||||
result[`${questId}.abandon`] = this.abandon.bind(this, questId);
|
result[`${questId}.abandon`] = this.abandon.bind(this, questId);
|
||||||
|
|
@ -123,11 +146,13 @@ export class QuestLog extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
override getVariables(): RPGVariables {
|
override getVariables(): RPGVariables {
|
||||||
|
if (this.#cachedVars) return this.#cachedVars;
|
||||||
const result: RPGVariables = {};
|
const result: RPGVariables = {};
|
||||||
for (const [questId, { state }] of this.#quests) {
|
for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) {
|
||||||
result[`${questId}.status`] = state.status;
|
result[`${questId}.status`] = runtimeState.status;
|
||||||
result[`${questId}.stage`] = state.stageIndex;
|
result[`${questId}.stage`] = runtimeState.stageIndex;
|
||||||
}
|
}
|
||||||
|
this.#cachedVars = result;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,8 +166,9 @@ export namespace Quests {
|
||||||
|
|
||||||
for (const stage of quest.stages) {
|
for (const stage of quest.stages) {
|
||||||
for (const action of stage.actions) {
|
for (const action of stage.actions) {
|
||||||
if (!actionSet.has(action.type)) {
|
const type = typeof action === 'string' ? action : action.type;
|
||||||
errors.push(`stage '${stage.id}': unknown action type '${action.type}'`);
|
if (!actionSet.has(type)) {
|
||||||
|
errors.push(`stage '${stage.id}': unknown action type '${type}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,49 @@
|
||||||
import { action, variable } from "../utils/decorators";
|
import { action, variable } from "../utils/decorators";
|
||||||
import { Component } from "../core/world";
|
import { Component } from "../core/world";
|
||||||
|
import { component } from "../core/registry";
|
||||||
|
|
||||||
export class Stat extends Component {
|
interface StatState {
|
||||||
@variable('.') private value: number;
|
value: number;
|
||||||
@variable private max: number | undefined;
|
max: number | undefined;
|
||||||
@variable private min: number | undefined;
|
min: number | undefined;
|
||||||
|
|
||||||
constructor(value: number, max?: number, min?: number) {
|
|
||||||
super();
|
|
||||||
this.value = value;
|
|
||||||
this.max = max;
|
|
||||||
this.min = min;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@component
|
||||||
|
export class Stat extends Component<StatState> {
|
||||||
|
constructor(value: number, max?: number, min?: number) {
|
||||||
|
super({ value, max, min });
|
||||||
|
}
|
||||||
|
|
||||||
|
@variable('.') get value(): number { return this.state.value; }
|
||||||
|
@variable get max(): number | undefined { return this.state.max; }
|
||||||
|
@variable get min(): number | undefined { return this.state.min; }
|
||||||
|
|
||||||
@action
|
@action
|
||||||
update(amount: number) {
|
update(amount: number) {
|
||||||
this.set(this.value + amount);
|
this.set(this.state.value + amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
set(value: number) {
|
set(value: number) {
|
||||||
const prev = this.value;
|
const prev = this.state.value;
|
||||||
this.value = value;
|
this.state.value = value;
|
||||||
if (this.min != null) {
|
if (this.state.min != null) {
|
||||||
this.value = Math.max(this.min, this.value);
|
this.state.value = Math.max(this.state.min, this.state.value);
|
||||||
}
|
}
|
||||||
if (this.max != null) {
|
if (this.state.max != null) {
|
||||||
this.value = Math.min(this.value, this.max);
|
this.state.value = Math.min(this.state.value, this.state.max);
|
||||||
}
|
}
|
||||||
if (prev !== this.value) {
|
if (prev !== this.state.value) {
|
||||||
this.emit('set', { prev, value: this.value });
|
this.emit('set', { prev, value: this.state.value });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get current(): number {
|
get current(): number {
|
||||||
return this.value;
|
return this.state.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@component
|
||||||
export class Health extends Stat {
|
export class Health extends Stat {
|
||||||
constructor(value: number, max?: number, min = 0) {
|
constructor(value: number, max?: number, min = 0) {
|
||||||
super(value, max, min);
|
super(value, max, min);
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,58 @@
|
||||||
import type { RPGVariables } from "../types";
|
import type { RPGVariables } from "../types";
|
||||||
import { action } from "../utils/decorators";
|
import { action } from "../utils/decorators";
|
||||||
import { Component } from "../core/world";
|
import { Component } from "../core/world";
|
||||||
|
import { component } from "../core/registry";
|
||||||
|
|
||||||
interface Var {
|
interface Var {
|
||||||
key: string;
|
key: string;
|
||||||
value: RPGVariables[string];
|
value: RPGVariables[string];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Variables extends Component {
|
interface VariablesState {
|
||||||
private readonly variables: RPGVariables = {};
|
vars: RPGVariables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic runtime key-value store set by dialog actions, quest scripts, and game events —
|
||||||
|
* values whose keys are only known at runtime or come from data files.
|
||||||
|
*
|
||||||
|
* Prefer a typed `Component<TState>` when the shape is fixed at compile time
|
||||||
|
* (e.g. health, stats, slot definitions).
|
||||||
|
*/
|
||||||
|
@component
|
||||||
|
export class Variables extends Component<VariablesState> {
|
||||||
|
constructor() {
|
||||||
|
super({ vars: {} });
|
||||||
|
}
|
||||||
|
|
||||||
override getVariables() {
|
override getVariables() {
|
||||||
return this.variables;
|
return this.state.vars;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
set({ key, value }: Var) {
|
set({ key, value }: Var) {
|
||||||
const prev = this.variables[key];
|
const prev = this.state.vars[key];
|
||||||
this.variables[key] = value;
|
this.state.vars[key] = value;
|
||||||
this.emit('set', { key, value, prev });
|
this.emit('set', { key, value, prev });
|
||||||
return this.variables;
|
return this.state.vars;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
unset(key: string) {
|
unset(key: string) {
|
||||||
const prev = this.variables[key];
|
const prev = this.state.vars[key];
|
||||||
delete this.variables[key];
|
delete this.state.vars[key];
|
||||||
this.emit('unset', { key, prev });
|
this.emit('unset', { key, prev });
|
||||||
return this.variables;
|
return this.state.vars;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
increment({ key, value }: Var) {
|
increment({ key, value }: Var) {
|
||||||
const currentValue = this.variables[key] ?? 0;
|
const currentValue = this.state.vars[key] ?? 0;
|
||||||
if (typeof currentValue === 'number' && typeof value === 'number') {
|
if (typeof currentValue === 'number' && typeof value === 'number') {
|
||||||
this.set({ key, value: currentValue + value });
|
this.set({ key, value: currentValue + value });
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[Variables] increment failed: ${key} is not a number`);
|
console.warn(`[Variables] increment failed: ${key} is not a number`);
|
||||||
}
|
}
|
||||||
return this.variables;
|
return this.state.vars;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import type { Component } from './world';
|
||||||
|
|
||||||
|
type ComponentConstructor = abstract new (...args: any[]) => Component<any>;
|
||||||
|
type MigrationFn = (state: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
|
||||||
|
interface ComponentMeta {
|
||||||
|
ctor: ComponentConstructor;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationEntry {
|
||||||
|
toVersion: number;
|
||||||
|
fn: MigrationFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new Map<string, ComponentMeta>();
|
||||||
|
const reverseRegistry = new Map<ComponentConstructor, string>();
|
||||||
|
/** migrations[name][fromVersion] → { toVersion, fn } */
|
||||||
|
const migrations = new Map<string, Map<number, MigrationEntry>>();
|
||||||
|
|
||||||
|
function register(name: string, ctor: ComponentConstructor, version: number): void {
|
||||||
|
registry.set(name, { ctor, version });
|
||||||
|
reverseRegistry.set(ctor, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponentMeta(name: string): ComponentMeta | undefined {
|
||||||
|
return registry.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponentName(ctor: Function): string | undefined {
|
||||||
|
return reverseRegistry.get(ctor as ComponentConstructor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a migration that upgrades component state from `fromVersion` to `toVersion`.
|
||||||
|
* Migrations are chained automatically: registering 0→1 and 1→2 handles saves at version 0.
|
||||||
|
*/
|
||||||
|
export function registerMigration(
|
||||||
|
name: string,
|
||||||
|
fromVersion: number,
|
||||||
|
toVersion: number,
|
||||||
|
fn: MigrationFn,
|
||||||
|
): void {
|
||||||
|
if (!migrations.has(name)) migrations.set(name, new Map());
|
||||||
|
migrations.get(name)!.set(fromVersion, { toVersion, fn });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all registered migrations to bring `state` from `fromVersion` up to the current
|
||||||
|
* registered version. Returns the migrated state (may be the same object if no migrations ran).
|
||||||
|
*/
|
||||||
|
export function migrateState(
|
||||||
|
name: string,
|
||||||
|
state: Record<string, unknown>,
|
||||||
|
fromVersion: number,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const meta = registry.get(name);
|
||||||
|
if (!meta) return state;
|
||||||
|
const chain = migrations.get(name);
|
||||||
|
if (!chain) return state;
|
||||||
|
let current = fromVersion;
|
||||||
|
let s = state;
|
||||||
|
while (current < meta.version) {
|
||||||
|
const entry = chain.get(current);
|
||||||
|
if (!entry) throw new Error(
|
||||||
|
`[registry] No migration for '${name}' from version ${current} to ${meta.version}. ` +
|
||||||
|
`Register one with registerMigration('${name}', ${current}, ...).`
|
||||||
|
);
|
||||||
|
s = entry.fn(s);
|
||||||
|
current = entry.toVersion;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentOptions {
|
||||||
|
name?: string;
|
||||||
|
version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentDecorator = (target: ComponentConstructor, ctx: ClassDecoratorContext) => void;
|
||||||
|
|
||||||
|
export function component(target: ComponentConstructor, ctx: ClassDecoratorContext): void;
|
||||||
|
export function component(name: string): ComponentDecorator;
|
||||||
|
export function component(options: ComponentOptions): ComponentDecorator;
|
||||||
|
export function component(
|
||||||
|
nameOrTargetOrOptions: string | ComponentConstructor | ComponentOptions,
|
||||||
|
ctx?: ClassDecoratorContext,
|
||||||
|
): void | ComponentDecorator {
|
||||||
|
if (typeof nameOrTargetOrOptions === 'string') {
|
||||||
|
const name = nameOrTargetOrOptions;
|
||||||
|
return (target: ComponentConstructor) => register(name, target, 0);
|
||||||
|
}
|
||||||
|
if (typeof nameOrTargetOrOptions === 'object') {
|
||||||
|
const { name, version = 0 } = nameOrTargetOrOptions;
|
||||||
|
return (target: ComponentConstructor, ctx: ClassDecoratorContext) =>
|
||||||
|
register(name ?? String(ctx.name), target, version);
|
||||||
|
}
|
||||||
|
// Used as bare @component
|
||||||
|
register(String(ctx!.name), nameOrTargetOrOptions, 0);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { World, Entity, Component, COMPONENT_STATE, WORLD_ENTITY_COUNTER } from './world';
|
||||||
|
import { getComponentMeta, getComponentName, migrateState } from './registry';
|
||||||
|
|
||||||
|
/** Increment this when the WorldData/EntityData structure itself changes incompatibly. */
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
interface ComponentData {
|
||||||
|
type: 'component';
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
version: number;
|
||||||
|
state: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntityData {
|
||||||
|
type: 'entity';
|
||||||
|
id: string;
|
||||||
|
components: ComponentData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorldData {
|
||||||
|
type: 'world';
|
||||||
|
schemaVersion: number;
|
||||||
|
globals: Record<string, string | number | boolean | undefined>;
|
||||||
|
entityCounter: number;
|
||||||
|
entities: EntityData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnyData = ComponentData | EntityData | WorldData;
|
||||||
|
|
||||||
|
function serializeComponent(component: Component<any>): ComponentData {
|
||||||
|
const name = getComponentName(component.constructor);
|
||||||
|
if (!name) {
|
||||||
|
throw new Error(
|
||||||
|
`Component '${component.constructor.name}' is not registered. ` +
|
||||||
|
`Add @component to the class declaration.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const meta = getComponentMeta(name)!;
|
||||||
|
return { type: 'component', name, key: component.key, version: meta.version, state: component[COMPONENT_STATE]() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeEntity(entity: Entity): EntityData {
|
||||||
|
const components: ComponentData[] = [];
|
||||||
|
for (const [, component] of entity) {
|
||||||
|
components.push(serializeComponent(component));
|
||||||
|
}
|
||||||
|
return { type: 'entity', id: entity.id, components };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeWorld(world: World): WorldData {
|
||||||
|
const entities: EntityData[] = [];
|
||||||
|
for (const entity of world) {
|
||||||
|
entities.push(serializeEntity(entity));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'world',
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
globals: { ...world.globals },
|
||||||
|
entityCounter: world[WORLD_ENTITY_COUNTER],
|
||||||
|
entities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeComponent(data: ComponentData): Component<any> {
|
||||||
|
const meta = getComponentMeta(data.name);
|
||||||
|
if (!meta) {
|
||||||
|
throw new Error(`Unknown component '${data.name}'. Ensure it is imported so @component runs.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedVersion = data.version ?? 0;
|
||||||
|
const state = savedVersion < meta.version
|
||||||
|
? migrateState(data.name, data.state as Record<string, unknown>, savedVersion)
|
||||||
|
: data.state;
|
||||||
|
|
||||||
|
// Bypass constructor: create a bare instance and restore state directly.
|
||||||
|
// Safe because constructors must only call super(state) — all initialization
|
||||||
|
// logic goes in onAdd(), which entity.add() calls after this.
|
||||||
|
const instance = Object.create(meta.ctor.prototype) as Component<any>;
|
||||||
|
(instance as unknown as { state: unknown }).state = state;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeEntity(data: EntityData, world: World): Entity {
|
||||||
|
const entity = world.createEntity(data.id);
|
||||||
|
for (const componentData of data.components) {
|
||||||
|
entity.add(componentData.key, deserializeComponent(componentData));
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeWorld(data: WorldData): World {
|
||||||
|
if (data.schemaVersion !== SCHEMA_VERSION) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported save format: schema version ${data.schemaVersion}, ` +
|
||||||
|
`expected ${SCHEMA_VERSION}. The save file is incompatible with this version of the engine.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const world = new World();
|
||||||
|
Object.assign(world.globals, data.globals);
|
||||||
|
world[WORLD_ENTITY_COUNTER] = data.entityCounter;
|
||||||
|
for (const entityData of data.entities) {
|
||||||
|
deserializeEntity(entityData, world);
|
||||||
|
}
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Serialization {
|
||||||
|
export function serialize(x: World | Entity | Component<any>): string {
|
||||||
|
if (x instanceof World) return JSON.stringify(serializeWorld(x));
|
||||||
|
if (x instanceof Entity) return JSON.stringify(serializeEntity(x));
|
||||||
|
return JSON.stringify(serializeComponent(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserialize(s: string): World | Entity | Component<any> {
|
||||||
|
const data = JSON.parse(s) as AnyData;
|
||||||
|
switch (data.type) {
|
||||||
|
case 'world': return deserializeWorld(data);
|
||||||
|
case 'entity': {
|
||||||
|
const world = new World();
|
||||||
|
return deserializeEntity(data, world);
|
||||||
|
}
|
||||||
|
case 'component': return deserializeComponent(data);
|
||||||
|
default: throw new Error(`Unknown serialized type: '${(data as AnyData).type}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,14 +15,28 @@ type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
|
||||||
type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
|
type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
|
||||||
|
|
||||||
export interface EvalContext {
|
export interface EvalContext {
|
||||||
self: Entity;
|
self: Entity | World;
|
||||||
world: World;
|
world: World;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class Component {
|
/** Symbol used by Serialization to read component state. */
|
||||||
|
export const COMPONENT_STATE = Symbol('rpg.component.state');
|
||||||
|
|
||||||
|
/** Symbol used by Serialization to access World's entity counter. */
|
||||||
|
export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter');
|
||||||
|
|
||||||
|
export abstract class Component<TState = Record<string, unknown>> {
|
||||||
entity!: Entity;
|
entity!: Entity;
|
||||||
key!: string;
|
key!: string;
|
||||||
|
|
||||||
|
protected state: TState;
|
||||||
|
|
||||||
|
constructor(state: TState) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
[COMPONENT_STATE](): TState { return this.state; }
|
||||||
|
|
||||||
protected emit(event: string, data?: unknown): void {
|
protected emit(event: string, data?: unknown): void {
|
||||||
this.entity.emit(`${this.key}.${event}`, data);
|
this.entity.emit(`${this.key}.${event}`, data);
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +95,7 @@ export class Entity {
|
||||||
return { self: this, world: this.world };
|
return { self: this, world: this.world };
|
||||||
}
|
}
|
||||||
|
|
||||||
add<T extends Component>(key: string, component: T): T {
|
add<T extends Component<any>>(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();
|
||||||
component.entity = this;
|
component.entity = this;
|
||||||
|
|
@ -91,10 +105,10 @@ export class Entity {
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T extends Component>(key: string): T | undefined;
|
get<T extends Component<any>>(key: string): T | undefined;
|
||||||
get<T extends Component>(ctor: Class<T>): T | undefined;
|
get<T extends Component<any>>(ctor: Class<T>): T | undefined;
|
||||||
get<T extends Component>(ctor: Class<T>, key: string): T | undefined;
|
get<T extends Component<any>>(ctor: Class<T>, key: string): T | undefined;
|
||||||
get<T extends Component>(ctorOrKey: Class<T> | string, key?: string): T | undefined {
|
get<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): T | undefined {
|
||||||
if (typeof ctorOrKey === 'string') {
|
if (typeof ctorOrKey === 'string') {
|
||||||
return this.#components.get(ctorOrKey) as T | undefined;
|
return this.#components.get(ctorOrKey) as T | undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -109,9 +123,9 @@ export class Entity {
|
||||||
}
|
}
|
||||||
|
|
||||||
has(key: string): boolean;
|
has(key: string): boolean;
|
||||||
has<T extends Component>(ctor: Class<T>): boolean;
|
has<T extends Component<any>>(ctor: Class<T>): boolean;
|
||||||
has<T extends Component>(ctor: Class<T>, key: string): boolean;
|
has<T extends Component<any>>(ctor: Class<T>, key: string): boolean;
|
||||||
has<T extends Component>(ctorOrKey: Class<T> | string, key?: string): boolean {
|
has<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): boolean {
|
||||||
if (typeof ctorOrKey === 'string') return this.#components.has(ctorOrKey);
|
if (typeof ctorOrKey === 'string') return this.#components.has(ctorOrKey);
|
||||||
if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey;
|
if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey;
|
||||||
for (const c of this.#components.values()) {
|
for (const c of this.#components.values()) {
|
||||||
|
|
@ -121,9 +135,9 @@ export class Entity {
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(key: string): void;
|
remove(key: string): void;
|
||||||
remove<T extends Component>(ctor: Class<T>): void;
|
remove<T extends Component<any>>(ctor: Class<T>): void;
|
||||||
remove<T extends Component>(ctor: Class<T>, key: string): void;
|
remove<T extends Component<any>>(ctor: Class<T>, key: string): void;
|
||||||
remove<T extends Component>(ctorOrKey: Class<T> | string, key?: string): void {
|
remove<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): void {
|
||||||
if (typeof ctorOrKey === 'string') {
|
if (typeof ctorOrKey === 'string') {
|
||||||
this.#removeByKey(ctorOrKey);
|
this.#removeByKey(ctorOrKey);
|
||||||
return;
|
return;
|
||||||
|
|
@ -166,12 +180,12 @@ export class Entity {
|
||||||
this.world.destroyEntity(this);
|
this.world.destroyEntity(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal used by World.query and resolveVariables */
|
/** @internal */
|
||||||
[Symbol.iterator](): IterableIterator<[string, Component]> {
|
[Symbol.iterator](): IterableIterator<[string, Component]> {
|
||||||
return this.#components.entries();
|
return this.#components.entries();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal called by World.destroyEntity */
|
/** @internal */
|
||||||
_destroy(): void {
|
_destroy(): void {
|
||||||
for (const c of this.#components.values()) c.onRemove();
|
for (const c of this.#components.values()) c.onRemove();
|
||||||
this.#components.clear();
|
this.#components.clear();
|
||||||
|
|
@ -185,11 +199,33 @@ export class World {
|
||||||
readonly #entities = new Map<string, Entity>();
|
readonly #entities = new Map<string, Entity>();
|
||||||
readonly #handlers = new Map<string, Set<EntityEventHandler>>();
|
readonly #handlers = new Map<string, Set<EntityEventHandler>>();
|
||||||
readonly #globalHandlers = new Map<string, Set<WorldEventHandler>>();
|
readonly #globalHandlers = new Map<string, Set<WorldEventHandler>>();
|
||||||
|
readonly #onceWrappers = new Map<Function, Function>();
|
||||||
readonly #systems: System[] = [];
|
readonly #systems: System[] = [];
|
||||||
#entityCounter = 0;
|
#entityCounter = 0;
|
||||||
|
|
||||||
|
get [WORLD_ENTITY_COUNTER](): number { return this.#entityCounter; }
|
||||||
|
set [WORLD_ENTITY_COUNTER](n: number) { this.#entityCounter = n; }
|
||||||
|
|
||||||
|
get id() { return 'world'; }
|
||||||
|
|
||||||
|
get context(): EvalContext {
|
||||||
|
return { self: this, world: this };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new entity and add it to the world.
|
||||||
|
*
|
||||||
|
* @param id - How the entity ID is determined:
|
||||||
|
* - Omitted → auto-generated: `entity_1`, `entity_2`, …
|
||||||
|
* - Plain string → used as-is: `createEntity('player')` → `'player'`
|
||||||
|
* - Template with `*` → `*` is replaced by the auto-incremented counter:
|
||||||
|
* `createEntity('enemy_*')` → `'enemy_1'`, `'enemy_2'`, …
|
||||||
|
* @throws If an entity with the resolved ID already exists.
|
||||||
|
*/
|
||||||
createEntity(id?: string): Entity {
|
createEntity(id?: string): Entity {
|
||||||
const entityId = id ?? `entity_${++this.#entityCounter}`;
|
const entityId = id == null
|
||||||
|
? `entity_${++this.#entityCounter}`
|
||||||
|
: id.includes('*') ? id.replace('*', String(++this.#entityCounter)) : id;
|
||||||
if (this.#entities.has(entityId)) throw new Error(`Entity '${entityId}' already exists`);
|
if (this.#entities.has(entityId)) throw new Error(`Entity '${entityId}' already exists`);
|
||||||
const entity = new Entity(entityId, this);
|
const entity = new Entity(entityId, this);
|
||||||
this.#entities.set(entityId, entity);
|
this.#entities.set(entityId, entity);
|
||||||
|
|
@ -208,12 +244,38 @@ export class World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*query<T extends Component>(ctor: Class<T>): Generator<[Entity, string, T]> {
|
/**
|
||||||
for (const entity of this.#entities.values()) {
|
* Create a new entity whose components are deep copies of `source`'s components.
|
||||||
for (const [key, component] of entity) {
|
* The clone is immediately live in the world and `onAdd()` fires for each component.
|
||||||
if (component instanceof ctor) {
|
* @param source - Entity to clone.
|
||||||
yield [entity, key, component as T]
|
* @param newId - ID for the clone; follows the same rules as {@link createEntity}.
|
||||||
|
*/
|
||||||
|
cloneEntity(source: Entity, newId?: string): Entity {
|
||||||
|
const target = this.createEntity(newId);
|
||||||
|
for (const [key, component] of source) {
|
||||||
|
const clone = Object.create(component.constructor.prototype) as Component<any>;
|
||||||
|
(clone as unknown as { state: unknown }).state = structuredClone(component[COMPONENT_STATE]());
|
||||||
|
target.add(key, clone);
|
||||||
}
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
query<T extends Component<any>>(ctor: Class<T>): Generator<[Entity, string, T]>;
|
||||||
|
query<A extends Component<any>, B extends Component<any>>(ctorA: Class<A>, ctorB: Class<B>): Generator<[Entity, A, B]>;
|
||||||
|
query<A extends Component<any>, B extends Component<any>, C extends Component<any>>(ctorA: Class<A>, ctorB: Class<B>, ctorC: Class<C>): Generator<[Entity, A, B, C]>;
|
||||||
|
*query(...ctors: Class<Component<any>>[]): Generator<unknown[]> {
|
||||||
|
const entities = this.#entities;
|
||||||
|
if (ctors.length === 1) {
|
||||||
|
const ctor = ctors[0];
|
||||||
|
for (const entity of entities.values()) {
|
||||||
|
for (const [key, component] of entity) {
|
||||||
|
if (component instanceof ctor) yield [entity, key, component];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const entity of entities.values()) {
|
||||||
|
const components = ctors.map(ctor => entity.get(ctor));
|
||||||
|
if (components.every(c => c !== undefined)) yield [entity, ...components];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,11 +300,7 @@ export class World {
|
||||||
emit(entityId: string, event: string, data?: unknown): void {
|
emit(entityId: string, event: string, data?: unknown): void {
|
||||||
const entity = this.getEntity(entityId);
|
const entity = this.getEntity(entityId);
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
|
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({ target: entity, data }));
|
||||||
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({
|
|
||||||
target: entity,
|
|
||||||
data,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitGlobal(event: string, data?: unknown): void {
|
emitGlobal(event: string, data?: unknown): void {
|
||||||
|
|
@ -262,9 +320,13 @@ export class World {
|
||||||
off(entityId: string, event: string, handler: EntityEventHandler): void;
|
off(entityId: string, event: string, handler: EntityEventHandler): void;
|
||||||
off(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): void {
|
off(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): void {
|
||||||
if (typeof arg2 === 'string') {
|
if (typeof arg2 === 'string') {
|
||||||
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(arg3!);
|
const handler = (this.#onceWrappers.get(arg3!) ?? arg3!) as EntityEventHandler;
|
||||||
|
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(handler);
|
||||||
|
this.#onceWrappers.delete(arg3!);
|
||||||
} else {
|
} else {
|
||||||
this.#globalHandlers.get(arg1)?.delete(arg2);
|
const handler = (this.#onceWrappers.get(arg2) ?? arg2) as WorldEventHandler;
|
||||||
|
this.#globalHandlers.get(arg1)?.delete(handler);
|
||||||
|
this.#onceWrappers.delete(arg2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,17 +334,24 @@ export class World {
|
||||||
once(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
once(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
||||||
once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
||||||
if (typeof arg2 === 'string') {
|
if (typeof arg2 === 'string') {
|
||||||
const wrapped: EntityEventHandler = data => { unsub(); arg3!(data); };
|
const original = arg3!;
|
||||||
|
const wrapped: EntityEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
||||||
|
this.#onceWrappers.set(original, wrapped);
|
||||||
const unsub = this.on(arg1, arg2, wrapped);
|
const unsub = this.on(arg1, arg2, wrapped);
|
||||||
return unsub;
|
return () => { this.#onceWrappers.delete(original); unsub(); };
|
||||||
}
|
}
|
||||||
const h = arg2;
|
const original = arg2;
|
||||||
const wrapped: WorldEventHandler = data => { unsub(); h(data); };
|
const wrapped: WorldEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
||||||
|
this.#onceWrappers.set(original, wrapped);
|
||||||
const unsub = this.on(arg1, wrapped);
|
const unsub = this.on(arg1, wrapped);
|
||||||
return unsub;
|
return () => { this.#onceWrappers.delete(original); unsub(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
#addHandler<T extends WorldEventHandler | EntityEventHandler>(map: Map<string, Set<T>>, key: string, handler: T): () => void {
|
#addHandler<T extends WorldEventHandler | EntityEventHandler>(
|
||||||
|
map: Map<string, Set<T>>,
|
||||||
|
key: string,
|
||||||
|
handler: T,
|
||||||
|
): () => void {
|
||||||
if (!map.has(key)) map.set(key, new Set());
|
if (!map.has(key)) map.set(key, new Set());
|
||||||
map.get(key)!.add(handler);
|
map.get(key)!.add(handler);
|
||||||
return () => map.get(key)?.delete(handler);
|
return () => map.get(key)?.delete(handler);
|
||||||
|
|
@ -295,6 +364,14 @@ export class World {
|
||||||
|
|
||||||
export function isEvalContext(v: unknown): v is EvalContext {
|
export function isEvalContext(v: unknown): v is EvalContext {
|
||||||
return typeof v === 'object' && v != null
|
return typeof v === 'object' && v != null
|
||||||
&& (v as EvalContext).self instanceof Entity
|
&& (
|
||||||
|
(v as EvalContext).self instanceof Entity
|
||||||
|
|| (v as EvalContext).self instanceof World
|
||||||
|
)
|
||||||
&& (v as EvalContext).world instanceof World;
|
&& (v as EvalContext).world instanceof World;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Narrows an {@link EvalContext} to one where `self` is an `Entity`, not a `World`. */
|
||||||
|
export function isEntityContext(ctx: EvalContext): ctx is { self: Entity; world: World } {
|
||||||
|
return ctx.self instanceof Entity;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export namespace Dialogs {
|
||||||
const actions = new Set<string>();
|
const actions = new Set<string>();
|
||||||
for (const node of dialog.nodes) {
|
for (const node of dialog.nodes) {
|
||||||
for (const action of node.actions ?? []) {
|
for (const action of node.actions ?? []) {
|
||||||
actions.add(action.type);
|
actions.add(typeof action === 'string' ? action : action.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
|
|
@ -76,8 +76,9 @@ export namespace Dialogs {
|
||||||
errors.push(`node '${node.id}': nextNodeId '${node.nextNodeId}' does not match any node id`);
|
errors.push(`node '${node.id}': nextNodeId '${node.nextNodeId}' does not match any node id`);
|
||||||
|
|
||||||
for (const action of node.actions ?? []) {
|
for (const action of node.actions ?? []) {
|
||||||
if (!actionSet.has(action.type)) {
|
const type = typeof action === 'string' ? action : action.type;
|
||||||
errors.push(`node '${node.id}': unknown action type '${action.type}'`);
|
if (!actionSet.has(type)) {
|
||||||
|
errors.push(`node '${node.id}': unknown action type '${type}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,7 +239,9 @@ export class DialogEngine {
|
||||||
if (isEvalContext(this.options)) {
|
if (isEvalContext(this.options)) {
|
||||||
await executeAction(action, this.options);
|
await executeAction(action, this.options);
|
||||||
} else {
|
} else {
|
||||||
await this.options.actions[action.type]?.(action.arg);
|
const type = typeof action === 'string' ? action: action.type;
|
||||||
|
const arg = typeof action === 'string' ? undefined : action.arg;
|
||||||
|
await this.options.actions[type]?.(arg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ import type { EvalContext } from './core/world';
|
||||||
|
|
||||||
// ── Shared ────────────────────────────────────────────────────────────────────
|
// ── Shared ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RPGActionScheme = Type.Object({
|
const RPGActionScheme = Type.Union([
|
||||||
|
Type.Object({
|
||||||
type: Type.String(),
|
type: Type.String(),
|
||||||
arg: Type.Optional(Type.Any()),
|
arg: Type.Optional(Type.Any()),
|
||||||
});
|
}),
|
||||||
|
Type.String(),
|
||||||
|
]);
|
||||||
|
|
||||||
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>;
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,29 @@ export interface ParsedCondition {
|
||||||
value?: ConditionValue;
|
value?: ConditionValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseCache = new Map<string, ParsedCondition>();
|
||||||
|
|
||||||
export function parseCondition(s: RPGCondition): ParsedCondition {
|
export function parseCondition(s: RPGCondition): ParsedCondition {
|
||||||
|
const cached = parseCache.get(s);
|
||||||
|
if (cached) return cached;
|
||||||
|
let result: ParsedCondition;
|
||||||
|
|
||||||
// ~variable — falsy check, nothing else allowed
|
// ~variable — falsy check, nothing else allowed
|
||||||
if (s.startsWith('~') && !s.includes(' '))
|
if (s.startsWith('~') && !s.includes(' ')) {
|
||||||
return { variable: s.slice(1), negate: true };
|
result = { variable: s.slice(1), negate: true };
|
||||||
|
} else {
|
||||||
const spaceIdx = s.indexOf(' ');
|
const spaceIdx = s.indexOf(' ');
|
||||||
if (spaceIdx === -1)
|
if (spaceIdx === -1) {
|
||||||
return { variable: s, negate: false };
|
result = { variable: s, negate: false };
|
||||||
|
} else {
|
||||||
const variable = s.slice(0, spaceIdx);
|
const variable = s.slice(0, spaceIdx);
|
||||||
const rest = s.slice(spaceIdx + 1).trim();
|
const rest = s.slice(spaceIdx + 1).trim();
|
||||||
|
|
||||||
if (rest === 'null') return { variable, negate: false, operator: 'null' };
|
if (rest === 'null') {
|
||||||
if (rest === '~null') return { variable, negate: false, operator: '~null' };
|
result = { variable, negate: false, operator: 'null' };
|
||||||
|
} else if (rest === '~null') {
|
||||||
|
result = { variable, negate: false, operator: '~null' };
|
||||||
|
} else {
|
||||||
const opMatch = rest.match(/^(==|!=|>=|<=|>|<)\s*(.+)$/);
|
const opMatch = rest.match(/^(==|!=|>=|<=|>|<)\s*(.+)$/);
|
||||||
if (!opMatch) throw new Error(`Invalid condition: "${s}"`);
|
if (!opMatch) throw new Error(`Invalid condition: "${s}"`);
|
||||||
|
|
||||||
|
|
@ -38,7 +46,13 @@ export function parseCondition(s: RPGCondition): ParsedCondition {
|
||||||
else if (!isNaN(Number(rawValue))) value = Number(rawValue);
|
else if (!isNaN(Number(rawValue))) value = Number(rawValue);
|
||||||
else value = rawValue.replace(/^['"]|['"]$/g, '');
|
else value = rawValue.replace(/^['"]|['"]$/g, '');
|
||||||
|
|
||||||
return { variable, negate: false, operator: operator as ConditionOperator, value };
|
result = { variable, negate: false, operator: operator as ConditionOperator, value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCache.set(s, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariables[string]): boolean {
|
function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariables[string]): boolean {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
import type { RPGAction, RPGActions, RPGVariables } from "../types";
|
import type { RPGAction, RPGActions, RPGVariables } from "../types";
|
||||||
import { World } from "../core/world";
|
import { isEvalContext, World } from "../core/world";
|
||||||
import type { EvalContext, Entity } from "../core/world";
|
import type { EvalContext, Entity } from "../core/world";
|
||||||
|
|
||||||
export function resolveVariables(entity: Entity): RPGVariables;
|
export function resolveVariables(target: Entity | World): RPGVariables {
|
||||||
export function resolveVariables(world: World): RPGVariables;
|
|
||||||
export function resolveVariables(entityOrWorld: Entity | World): RPGVariables {
|
|
||||||
const result: RPGVariables = {};
|
const result: RPGVariables = {};
|
||||||
if (entityOrWorld instanceof World) {
|
if (target instanceof World) {
|
||||||
for (const entity of entityOrWorld) {
|
for (const entity of target) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const [key, component] of entityOrWorld) {
|
for (const [key, component] of target) {
|
||||||
for (const [varKey, value] of Object.entries(component.getVariables())) {
|
for (const [varKey, value] of Object.entries(component.getVariables())) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
if (varKey && varKey !== '.') {
|
if (varKey && varKey !== '.') {
|
||||||
|
|
@ -49,18 +47,16 @@ 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(target: Entity | World): RPGActions {
|
||||||
export function resolveActions(world: World): RPGActions;
|
|
||||||
export function resolveActions(entityOrWorld: Entity | World): RPGActions {
|
|
||||||
const result: RPGActions = {};
|
const result: RPGActions = {};
|
||||||
if (entityOrWorld instanceof World) {
|
if (target instanceof World) {
|
||||||
for (const entity of entityOrWorld) {
|
for (const entity of target) {
|
||||||
for (const [key, value] of Object.entries(resolveActions(entity))) {
|
for (const [key, value] of Object.entries(resolveActions(entity))) {
|
||||||
result[`${entity.id}.${key}`] = value;
|
result[`${entity.id}.${key}`] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const [key, component] of entityOrWorld) {
|
for (const [key, component] of target) {
|
||||||
for (const [actionKey, fn] of Object.entries(component.getActions())) {
|
for (const [actionKey, fn] of Object.entries(component.getActions())) {
|
||||||
result[`${key}.${actionKey}`] = fn;
|
result[`${key}.${actionKey}`] = fn;
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +65,20 @@ export function resolveActions(entityOrWorld: Entity | World): RPGActions {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeAction(action: RPGAction, ctx: EvalContext): Promise<unknown> {
|
interface Contextable {
|
||||||
|
readonly context: EvalContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeAction(action: RPGAction, ctx: EvalContext | Contextable): Promise<unknown> {
|
||||||
|
if (typeof action === 'string') {
|
||||||
|
action = { type: action };
|
||||||
|
}
|
||||||
|
if ('context' in ctx) {
|
||||||
|
ctx = ctx.context;
|
||||||
|
}
|
||||||
|
if (!action.type) {
|
||||||
|
throw new TypeError(`[executeAction] action object is missing a 'type' property`);
|
||||||
|
}
|
||||||
let entity = ctx.self;
|
let entity = ctx.self;
|
||||||
let actionType = action.type;
|
let actionType = action.type;
|
||||||
|
|
||||||
|
|
@ -77,12 +86,12 @@ export async function executeAction(action: RPGAction, ctx: EvalContext): Promis
|
||||||
if (action.type.startsWith('@')) {
|
if (action.type.startsWith('@')) {
|
||||||
const dotIdx = action.type.indexOf('.', 1);
|
const dotIdx = action.type.indexOf('.', 1);
|
||||||
if (dotIdx === -1) {
|
if (dotIdx === -1) {
|
||||||
console.warn(`[executeAction] malformed cross-entity action '${action.type}': missing '.' after entity id`);
|
throw new Error(`[executeAction] malformed cross-entity action '${action.type}': missing '.' after entity id`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const entityId = action.type.slice(1, dotIdx);
|
const entityId = action.type.slice(1, dotIdx);
|
||||||
const found = ctx.world.getEntity(entityId);
|
const found = ctx.world.getEntity(entityId);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
|
// Entity may have been destroyed legitimately — warn and skip rather than throw.
|
||||||
console.warn(`[executeAction] entity '${entityId}' not found (action '${action.type}')`);
|
console.warn(`[executeAction] entity '${entityId}' not found (action '${action.type}')`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -92,8 +101,7 @@ export async function executeAction(action: RPGAction, ctx: EvalContext): Promis
|
||||||
|
|
||||||
const actions = resolveActions(entity);
|
const actions = resolveActions(entity);
|
||||||
if (!(actionType in actions)) {
|
if (!(actionType in actions)) {
|
||||||
console.warn(`[executeAction] action '${actionType}' not found on entity '${entity.id}'`);
|
throw new Error(`[executeAction] action '${actionType}' not found on entity '${entity instanceof World ? 'world' : entity.id}'`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
return actions[actionType](action.arg, ctx);
|
return actions[actionType](action.arg, ctx);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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, executeAction } from "@common/rpg/utils/variables";
|
import { resolveVariables, resolveActions, executeAction } from "@common/rpg/utils/variables";
|
||||||
|
import { Serialization } from "@common/rpg/core/serialization";
|
||||||
|
|
||||||
export default async function main() {
|
export default async function main() {
|
||||||
const world = new World();
|
const world = new World();
|
||||||
|
|
@ -18,15 +19,12 @@ export default async function main() {
|
||||||
player.add('inventory', new Inventory(['head', 'legs']));
|
player.add('inventory', new Inventory(['head', 'legs']));
|
||||||
player.add('health', new Health(100, 100));
|
player.add('health', new Health(100, 100));
|
||||||
player.add('vars', new Variables());
|
player.add('vars', new Variables());
|
||||||
player.add('quests', new QuestLog());
|
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: [],
|
||||||
});
|
}]));
|
||||||
|
|
||||||
console.log(resolveVariables(world));
|
console.log(resolveVariables(world));
|
||||||
|
|
||||||
|
|
@ -36,8 +34,10 @@ export default async function main() {
|
||||||
const vars = player.get(Variables)!;
|
const vars = player.get(Variables)!;
|
||||||
vars.set({ key: 'test', value: 'test' });
|
vars.set({ key: 'test', value: 'test' });
|
||||||
|
|
||||||
await executeAction({ type: 'inventory.add', arg: { itemId: 'boots', amount: 2 } }, player.context);
|
await executeAction({ type: 'inventory.add', arg: { itemId: 'boots', amount: 2 } }, player);
|
||||||
|
await executeAction('player.quests.test.start', world);
|
||||||
|
|
||||||
console.log(resolveActions(world));
|
console.log(resolveActions(world));
|
||||||
console.log(resolveVariables(world));
|
console.log(resolveVariables(world));
|
||||||
|
console.log(Serialization.serialize(world));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue