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 { #tracking = new Map(); override onAdd(world: World): void { for (const [entity, key, questLog] of world.query(QuestLog)) { this.#initTracking(entity, key, questLog); } } override onRemove(_world: World): void { for (const t of this.#tracking.values()) t.unsubscribe(); this.#tracking.clear(); } override update(world: World) { for (const [entity, key, questLog] of world.query(QuestLog)) { if (!this.#tracking.has(entity.id)) { this.#initTracking(entity, key, questLog); } 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. */ triggerCheck(world: World) { for (const [entity] of world.query(QuestLog)) { 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 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); 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); } } } #diffAndCheck(entity: Entity, world: World) { 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) this.#checkEntity(entity, world, dirty); } #checkEntity(entity: Entity, world: World, filter: Set | 'all') { 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}' active but missing stage ${state.stageIndex}`); continue; } if (stage.failConditions?.some(c => evaluateCondition(c, ctx))) { questLog.fail(questId); continue; } if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue; for (const action of stage.actions) executeAction(action, ctx); questLog._advance(questId); } } }