From 693b078144e6eb6d1ebc5204bb31f40bdebd2fc8 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Wed, 29 Apr 2026 13:27:39 +0000 Subject: [PATCH] Subscribe quests --- build/html.ts | 6 +- src/common/rpg/TODO.md | 26 ++++ src/common/rpg/components/questLog.ts | 4 + src/common/rpg/systems/questSystem.ts | 182 ++++++++++++++++++++++---- src/common/rpg/utils/decorators.ts | 3 + 5 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 src/common/rpg/TODO.md 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');