diff --git a/build/html.ts b/build/html.ts
index b64ee2f..0b27438 100644
--- a/build/html.ts
+++ b/build/html.ts
@@ -70,10 +70,6 @@ function connect() {
connect();
`;
-const POLYFILL = ``;
-
async function buildBundle(game: string, production: boolean) {
const assetsDir = path.resolve(import.meta.dir, 'assets');
const srcDir = path.resolve(import.meta.dir, '..', 'src');
@@ -192,7 +188,7 @@ export async function buildHTML(game: string, {
}
script = script.replaceAll('await Promise.resolve().then(() =>', '(');
- let scriptPrefix = POLYFILL;
+ let scriptPrefix = '';
if (production) {
const minifyResult = UglifyJS.minify(script, {
module: true,
diff --git a/src/common/rpg/TODO.md b/src/common/rpg/TODO.md
new file mode 100644
index 0000000..bfb10a5
--- /dev/null
+++ b/src/common/rpg/TODO.md
@@ -0,0 +1,26 @@
+# RPG Engine — Remaining Work
+
+## Missing Foundational Components
+
+### `Faction` / `Relationship` (`components/faction.ts`)
+Reputation score per faction ID. Drives dialog availability, shop access, hostile
+aggro thresholds, and quest unlock conditions. A separate world-level faction
+definition registry (neutral/friendly/hostile thresholds) pairs with this.
+
+---
+
+## Missing Systems
+
+### `CombatSystem` (`systems/combatSystem.ts`)
+Resolves attack attempts between entities. Needs a lightweight `Attack` marker
+component (attacker entity ID, target entity ID, damage, damage type) that is added
+to an entity to queue an attack for the next tick. Each tick the system:
+1. Processes queued `Attack` components.
+2. Applies damage to the target's `Health` stat (accounting for any defense modifier
+ Effects on the target).
+3. Removes the `Attack` component after resolution.
+4. Emits `'hit'` / `'kill'` events on the target entity as appropriate.
+
+Keeping the attack as a component (rather than a direct method call) lets other
+systems react before resolution (parry window, shield-block effects, etc.).
+
diff --git a/src/common/rpg/components/questLog.ts b/src/common/rpg/components/questLog.ts
index ef5f0ef..e0b5e3a 100644
--- a/src/common/rpg/components/questLog.ts
+++ b/src/common/rpg/components/questLog.ts
@@ -82,6 +82,10 @@ export class QuestLog extends Component {
return this.state.runtimeStates[questId];
}
+ getQuest(questId: string): Quest | undefined {
+ return this.state.quests[questId];
+ }
+
isAvailable(questId: string, ctx: EvalContext): boolean {
const quest = this.state.quests[questId];
if (!quest) return false;
diff --git a/src/common/rpg/systems/questSystem.ts b/src/common/rpg/systems/questSystem.ts
index fb9820e..f3907a4 100644
--- a/src/common/rpg/systems/questSystem.ts
+++ b/src/common/rpg/systems/questSystem.ts
@@ -1,49 +1,177 @@
-import { System, type Entity, type World } from "../core/world";
-import { evaluateCondition } from "../utils/conditions";
-import { executeAction } from "../utils/variables";
+import { System, type Entity, type World, type EvalContext } from "../core/world";
+import { parseCondition, evaluateCondition } from "../utils/conditions";
+import { resolveVariable, executeAction } from "../utils/variables";
import { QuestLog } from "../components/questLog";
+import type { QuestStage, RPGVariables } from "../types";
+
+interface EntityTracking {
+ /** varName → last resolved value */
+ snapshot: Map;
+ /** varName → set of questIds that reference it in the current stage */
+ varToQuests: Map>;
+ /** removes questlog event subscriptions for this entity */
+ unsubscribe: () => void;
+}
export class QuestSystem extends System {
- async triggerCheck(world: World): Promise {
- for (const [entity] of world.query(QuestLog)) {
- await this.#checkEntity(entity, world);
+ #tracking = new Map();
+
+ override onAdd(world: World): void {
+ for (const [entity, key, questLog] of world.query(QuestLog)) {
+ this.#initTracking(entity, key, questLog);
}
}
- async #checkEntity(entity: Entity, world: World): Promise {
- const questLog = entity.get(QuestLog);
- if (!questLog) return;
+ override onRemove(_world: World): void {
+ for (const t of this.#tracking.values()) t.unsubscribe();
+ this.#tracking.clear();
+ }
- const ctx = { self: entity, world };
+ override update(world: World, _dt: number): void {
+ for (const [entity, key, questLog] of world.query(QuestLog)) {
+ if (!this.#tracking.has(entity.id)) {
+ this.#initTracking(entity, key, questLog);
+ }
+ void this.#diffAndCheck(entity, world);
+ }
+
+ // Prune tracking for entities that no longer exist
+ for (const entityId of this.#tracking.keys()) {
+ if (!world.getEntity(entityId)) {
+ this.#tracking.get(entityId)!.unsubscribe();
+ this.#tracking.delete(entityId);
+ }
+ }
+ }
+
+ /** Force a full re-evaluation of all active quests. Use as an escape hatch. */
+ async triggerCheck(world: World): Promise {
+ for (const [entity] of world.query(QuestLog)) {
+ await this.#checkEntity(entity, world, 'all');
+ }
+ }
+
+ #initTracking(entity: Entity, key: string, questLog: QuestLog): EntityTracking {
+ const existing = this.#tracking.get(entity.id);
+ if (existing) existing.unsubscribe();
+
+ const tracking: EntityTracking = {
+ snapshot: new Map(),
+ varToQuests: new Map(),
+ unsubscribe: () => { },
+ };
for (const [questId, { quest, state }] of questLog.entries()) {
if (state.status !== 'active') continue;
+ const stage = quest.stages[state.stageIndex];
+ if (stage) this.#addQuestVars(tracking, questId, stage);
+ }
+
+ // Keep tracking fresh as quest state changes
+ const onStarted = ({ data }: { data?: unknown }) => {
+ const { questId } = data as { questId: string };
+ const quest = questLog.getQuest(questId);
+ const state = questLog.getState(questId);
+ const stage = quest?.stages[state?.stageIndex ?? 0];
+ if (stage) {
+ this.#addQuestVars(tracking, questId, stage);
+ // Evaluate immediately — conditions may already be satisfied at start
+ void this.#checkEntity(entity, entity.world, new Set([questId]));
+ }
+ };
+
+ const onStage = ({ data }: { data?: unknown }) => {
+ const { questId, stage } = data as { questId: string; stage: QuestStage };
+ this.#removeQuestVars(tracking, questId);
+ this.#addQuestVars(tracking, questId, stage);
+ void this.#checkEntity(entity, entity.world, new Set([questId]));
+ };
+
+ const onDone = ({ data }: { data?: unknown }) => {
+ this.#removeQuestVars(tracking, (data as { questId: string }).questId);
+ };
+
+ const u1 = entity.on(`${key}.started`, onStarted);
+ const u2 = entity.on(`${key}.stage`, onStage);
+ const u3 = entity.on(`${key}.completed`, onDone);
+ const u4 = entity.on(`${key}.failed`, onDone);
+ const u5 = entity.on(`${key}.abandoned`, onDone);
+ tracking.unsubscribe = () => { u1(); u2(); u3(); u4(); u5(); };
+
+ this.#tracking.set(entity.id, tracking);
+ return tracking;
+ }
+
+ #addQuestVars(tracking: EntityTracking, questId: string, stage: QuestStage): void {
+ const conditions = [
+ ...stage.objectives.map(o => o.condition),
+ ...(stage.failConditions ?? []),
+ ];
+ for (const cond of conditions) {
+ const varName = parseCondition(cond).variable;
+ // Do NOT seed the snapshot here. #diffAndCheck treats "not in snapshot"
+ // as dirty on first encounter, ensuring a fresh eval after every init or start.
+ if (!tracking.varToQuests.has(varName)) {
+ tracking.varToQuests.set(varName, new Set());
+ }
+ tracking.varToQuests.get(varName)!.add(questId);
+ }
+ }
+
+ #removeQuestVars(tracking: EntityTracking, questId: string): void {
+ for (const [varName, questIds] of tracking.varToQuests) {
+ questIds.delete(questId);
+ if (questIds.size === 0) {
+ tracking.varToQuests.delete(varName);
+ tracking.snapshot.delete(varName);
+ }
+ }
+ }
+
+ async #diffAndCheck(entity: Entity, world: World): Promise {
+ const tracking = this.#tracking.get(entity.id);
+ if (!tracking || tracking.varToQuests.size === 0) return;
+
+ const ctx: EvalContext = { self: entity, world };
+ const dirty = new Set();
+
+ for (const [varName, questIds] of tracking.varToQuests) {
+ const curr = resolveVariable(varName, ctx);
+ const prev = tracking.snapshot.get(varName);
+ // Treat "not yet in snapshot" as dirty — catches conditions already met at init
+ if (!tracking.snapshot.has(varName) || curr !== prev) {
+ tracking.snapshot.set(varName, curr);
+ for (const questId of questIds) dirty.add(questId);
+ }
+ }
+
+ if (dirty.size > 0) await this.#checkEntity(entity, world, dirty);
+ }
+
+ async #checkEntity(entity: Entity, world: World, filter: Set | 'all'): Promise {
+ const questLog = entity.get(QuestLog);
+ if (!questLog) return;
+
+ const ctx: EvalContext = { self: entity, world };
+
+ for (const [questId, { quest, state }] of questLog.entries()) {
+ if (state.status !== 'active') continue;
+ if (filter !== 'all' && !filter.has(questId)) continue;
const stage = quest.stages[state.stageIndex];
if (!stage) {
- console.warn(`[QuestSystem] quest '${questId}' is active but has no stage at index ${state.stageIndex}`);
+ console.warn(`[QuestSystem] quest '${questId}' active but missing stage ${state.stageIndex}`);
continue;
}
- if (stage.failConditions?.length) {
- const failed = stage.failConditions.some(c =>
- evaluateCondition(c, ctx)
- );
- if (failed) {
- questLog.fail(questId);
- continue;
- }
+ if (stage.failConditions?.some(c => evaluateCondition(c, ctx))) {
+ questLog.fail(questId);
+ continue;
}
- const allDone = stage.objectives.every(obj =>
- evaluateCondition(obj.condition, ctx)
- );
- if (!allDone) continue;
-
- for (const action of stage.actions) {
- await executeAction(action, ctx);
- }
+ if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue;
+ for (const action of stage.actions) await executeAction(action, ctx);
questLog._advance(questId);
}
}
diff --git a/src/common/rpg/utils/decorators.ts b/src/common/rpg/utils/decorators.ts
index c829023..1bca716 100644
--- a/src/common/rpg/utils/decorators.ts
+++ b/src/common/rpg/utils/decorators.ts
@@ -1,5 +1,8 @@
import type { RPGVariables } from "../types";
+// TC39 stage-3 decorators use Symbol.metadata; polyfill for runtimes that lack it (e.g. Bun).
+(Symbol as { metadata?: symbol }).metadata ??= Symbol.for('Symbol.metadata');
+
export const ACTION_KEYS = Symbol('rpg.actions');
export const VARIABLE_KEYS = Symbol('rpg.variables');