import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION, type DungeonEncounterAction, type DungeonEncounterDefinition, type DungeonEncounterEffect, type DungeonEncounterEffectEnvelope, type DungeonEncounterEvent, type DungeonEncounterGuard, type DungeonEncounterReduction, type DungeonEncounterResetScope, type DungeonEncounterRule, type DungeonEncounterSnapshot, } from "./dungeonEncounterTypes"; function finiteTime(value: number): number { return Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; } function unique(values: readonly string[]): readonly string[] { return Object.freeze([...new Set(values)].sort()); } function orderedUnique(values: readonly string[]): readonly string[] { const seen = new Set(); return Object.freeze(values.filter((value) => { if (seen.has(value)) return false; seen.add(value); return true; })); } export function createDungeonEncounterSnapshot( definition: DungeonEncounterDefinition, now = 0, revision = 0, ): DungeonEncounterSnapshot { return Object.freeze({ schemaVersion: DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION, encounterId: definition.id, phase: definition.initialPhase, phaseStartedAt: finiteTime(now), flags: Object.freeze({ ...definition.initialState?.flags }), counters: Object.freeze({ ...definition.initialState?.counters }), timers: Object.freeze({}), worldObjectStates: Object.freeze({ ...definition.initialState?.worldObjectStates }), transitionStates: Object.freeze({ ...definition.initialState?.transitionStates }), activeSpawnGroupIds: Object.freeze([]), completedObjectiveIds: Object.freeze([]), consumedActionIds: Object.freeze([]), checkpointId: null, revision, }); } function validateStableId(value: string, label: string): void { if (!value.trim() || !/^[a-z0-9][a-z0-9:._-]*$/i.test(value)) { throw new Error(`${label} must have a stable non-empty id: ${JSON.stringify(value)}`); } } export function validateDungeonEncounterDefinition(definition: DungeonEncounterDefinition): void { validateStableId(definition.id, "Encounter"); validateStableId(definition.dungeonId, `Encounter ${definition.id} dungeon`); validateStableId(definition.initialPhase, `Encounter ${definition.id} initial phase`); if (definition.source) { if (!definition.source.files.length || definition.source.files.some((file) => !file.trim())) { throw new Error(`Encounter ${definition.id} source review must name at least one source file`); } if (!definition.source.reviewedBehavior.trim()) { throw new Error(`Encounter ${definition.id} source review must describe the reviewed behavior`); } if (definition.source.fidelity === "source-exact" && (definition.source.adaptations.length || definition.source.remainingGaps.length)) { throw new Error(`Source-exact encounter ${definition.id} cannot retain adaptations or gaps`); } } const ruleIds = new Set(); const actionIds = new Set(); for (const rule of definition.rules) { validateStableId(rule.id, `Encounter ${definition.id} rule`); if (ruleIds.has(rule.id)) throw new Error(`Encounter ${definition.id} has duplicate rule id ${rule.id}`); ruleIds.add(rule.id); for (const guard of rule.guards ?? []) { if (guard.kind === "difficulty") { if (!guard.difficultyIds.length) { throw new Error(`Encounter ${definition.id} rule ${rule.id} has an empty difficulty guard`); } for (const difficultyId of guard.difficultyIds) { validateStableId( difficultyId, `Encounter ${definition.id} rule ${rule.id} difficulty`, ); } } } for (const action of rule.actions) { validateStableId(action.id, `Encounter ${definition.id} action`); if (actionIds.has(action.id)) { throw new Error(`Encounter ${definition.id} has duplicate action id ${action.id}`); } actionIds.add(action.id); if (action.kind === "grant-loot" && action.persistence !== "run") { throw new Error(`Encounter ${definition.id} loot action ${action.id} must use run persistence`); } } } } export interface DungeonEncounterRuntimeContext { readonly difficultyId?: string; } function guardMatches( snapshot: DungeonEncounterSnapshot, guard: DungeonEncounterGuard, context: DungeonEncounterRuntimeContext, ): boolean { if (guard.kind === "phase") return guard.phases.includes(snapshot.phase); if (guard.kind === "difficulty") { return context.difficultyId !== undefined && guard.difficultyIds.includes(context.difficultyId); } if (guard.kind === "flag") return (snapshot.flags[guard.flagId] ?? false) === guard.equals; if (guard.kind === "objective-complete") { return snapshot.completedObjectiveIds.includes(guard.objectiveId) === guard.equals; } if (guard.kind === "spawn-group-active") { return snapshot.activeSpawnGroupIds.includes(guard.spawnGroupId) === guard.equals; } if (guard.kind === "world-object-state") { return guard.states.includes(snapshot.worldObjectStates[guard.objectId] ?? "inactive"); } const current = snapshot.counters[guard.counterId] ?? 0; if (guard.comparison === "equal") return current === guard.value; if (guard.comparison === "at-least") return current >= guard.value; return current <= guard.value; } type DungeonEncounterTriggerMatch = "event" | "timeout" | false; function triggerMatches( snapshot: DungeonEncounterSnapshot, trigger: DungeonEncounterRule["trigger"], event: DungeonEncounterEvent, ): DungeonEncounterTriggerMatch { if (trigger.kind === "tick") return event.kind === "tick" ? "event" : false; if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId ? "event" : false; if (trigger.kind === "entity-engaged" || trigger.kind === "entity-death") { return event.kind === trigger.kind && event.entityId === trigger.entityId ? "event" : false; } if (trigger.kind === "entity-health") { return event.kind === "entity-health" && event.entityId === trigger.entityId && event.healthPercent <= trigger.atOrBelowPercent ? "event" : false; } if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId ? "event" : false; if (trigger.kind === "elapsed") { return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs ? "event" : false; } if (event.kind !== "tick") return false; const registered = new Set(event.facts?.registeredEntityIds ?? []); const dead = new Set(event.facts?.deadEntityIds ?? []); const allDead = trigger.entityIds.length > 0 && trigger.entityIds.every((id) => registered.has(id) && dead.has(id)); if (allDead) return "event"; const hasAuthoritativeFacts = event.facts?.registeredEntityIds !== undefined && event.facts.deadEntityIds !== undefined; const hasRegisteredLivingTarget = trigger.entityIds.some((id) => ( registered.has(id) && !dead.has(id) )); const timedOut = trigger.timeoutMs !== undefined && hasAuthoritativeFacts && !hasRegisteredLivingTarget && event.now - snapshot.phaseStartedAt >= trigger.timeoutMs; return timedOut ? "timeout" : false; } function ruleCanAdvanceOnMissingEntityTimeout(rule: DungeonEncounterRule): boolean { return !rule.actions.some((action) => ( action.persistence === "checkpoint" || action.persistence === "run" || action.kind === "complete-objective" || action.kind === "set-checkpoint" || action.kind === "grant-loot" )); } function ruleAlreadyConsumed(snapshot: DungeonEncounterSnapshot, rule: DungeonEncounterRule): boolean { return (rule.once ?? true) && rule.actions.length > 0 && rule.actions.every((action) => snapshot.consumedActionIds.includes(action.id)); } function actionEffect(action: DungeonEncounterAction): DungeonEncounterEffect | null { if (action.kind === "activate-spawn-group") { return { kind: "spawn-group", actionId: action.id, spawnGroupId: action.spawnGroupId }; } if (action.kind === "deactivate-spawn-group") { return { kind: "despawn-group", actionId: action.id, spawnGroupId: action.spawnGroupId }; } if (action.kind === "set-world-object-state") { return { kind: "world-object-state", actionId: action.id, objectId: action.objectId, state: action.state }; } if (action.kind === "set-transition-active") { return { kind: "transition-state", actionId: action.id, transitionId: action.transitionId, active: action.active }; } if (action.kind === "teleport-party") { return { kind: "party-teleport", actionId: action.id, destination: action.destination }; } if (action.kind === "grant-loot") { return { kind: "loot-grant", actionId: action.id, lootTableId: action.lootTableId }; } if (action.kind === "emit-signal") { return { kind: "signal", actionId: action.id, signalId: action.signalId }; } return null; } function applyAction( snapshot: DungeonEncounterSnapshot, action: DungeonEncounterAction, now: number, consume: boolean, ): { readonly snapshot: DungeonEncounterSnapshot; readonly effect: DungeonEncounterEffect | null } { if (consume && snapshot.consumedActionIds.includes(action.id)) return { snapshot, effect: null }; let next = snapshot; if (action.kind === "set-phase") { next = { ...next, phase: action.phase, phaseStartedAt: finiteTime(now) }; } else if (action.kind === "set-flag") { next = { ...next, flags: Object.freeze({ ...next.flags, [action.flagId]: action.value }) }; } else if (action.kind === "set-counter") { next = { ...next, counters: Object.freeze({ ...next.counters, [action.counterId]: action.value }) }; } else if (action.kind === "increment-counter") { next = { ...next, counters: Object.freeze({ ...next.counters, [action.counterId]: (next.counters[action.counterId] ?? 0) + (action.amount ?? 1), }), }; } else if (action.kind === "start-timer") { next = { ...next, timers: Object.freeze({ ...next.timers, [action.timerId]: finiteTime(now + action.durationMs) }) }; } else if (action.kind === "clear-timer") { const timers = { ...next.timers }; delete timers[action.timerId]; next = { ...next, timers: Object.freeze(timers) }; } else if (action.kind === "activate-spawn-group") { next = { ...next, activeSpawnGroupIds: unique([...next.activeSpawnGroupIds, action.spawnGroupId]) }; } else if (action.kind === "deactivate-spawn-group") { next = { ...next, activeSpawnGroupIds: Object.freeze(next.activeSpawnGroupIds.filter((id) => id !== action.spawnGroupId)) }; } else if (action.kind === "set-world-object-state") { next = { ...next, worldObjectStates: Object.freeze({ ...next.worldObjectStates, [action.objectId]: action.state }) }; } else if (action.kind === "set-transition-active") { next = { ...next, transitionStates: Object.freeze({ ...next.transitionStates, [action.transitionId]: action.active }) }; } else if (action.kind === "complete-objective") { next = { ...next, completedObjectiveIds: unique([...next.completedObjectiveIds, action.objectiveId]) }; } else if (action.kind === "set-checkpoint") { next = { ...next, checkpointId: action.checkpointId }; } return { snapshot: consume ? { ...next, consumedActionIds: orderedUnique([...next.consumedActionIds, action.id]) } : next, effect: actionEffect(action), }; } export function reduceDungeonEncounter( definition: DungeonEncounterDefinition, snapshot: DungeonEncounterSnapshot, event: DungeonEncounterEvent, context: DungeonEncounterRuntimeContext = {}, ): DungeonEncounterReduction { if (snapshot.encounterId !== definition.id) { throw new Error(`Snapshot ${snapshot.encounterId} does not belong to encounter ${definition.id}`); } let next = snapshot; const effects: DungeonEncounterEffect[] = []; const firedRuleIds: string[] = []; const rules = definition.rules .map((rule, index) => ({ rule, index })) .sort((left, right) => (right.rule.priority ?? 0) - (left.rule.priority ?? 0) || left.index - right.index); for (const { rule } of rules) { if (ruleAlreadyConsumed(next, rule)) continue; const triggerMatch = triggerMatches(next, rule.trigger, event); if (!triggerMatch) continue; if (triggerMatch === "timeout" && !ruleCanAdvanceOnMissingEntityTimeout(rule)) continue; if (!rule.guards?.every((guard) => guardMatches(next, guard, context)) && rule.guards?.length) continue; let fired = false; for (const action of rule.actions) { const applied = applyAction(next, action, event.now, rule.once ?? true); if (applied.snapshot !== next) fired = true; next = applied.snapshot; if (applied.effect) effects.push(applied.effect); } if (fired || rule.actions.length === 0) firedRuleIds.push(rule.id); } if (next === snapshot) return { snapshot, effects: Object.freeze(effects), firedRuleIds: Object.freeze(firedRuleIds) }; return { snapshot: Object.freeze({ ...next, revision: snapshot.revision + 1 }), effects: Object.freeze(effects), firedRuleIds: Object.freeze(firedRuleIds), }; } function retainedActionIds( definition: DungeonEncounterDefinition, snapshot: DungeonEncounterSnapshot, scope: DungeonEncounterResetScope, ): readonly string[] { if (scope === "dungeon") return []; const retained = new Set(definition.rules.flatMap((rule) => rule.actions) .filter((action) => action.persistence === "checkpoint" || action.persistence === "run") .map((action) => action.id)); return Object.freeze(snapshot.consumedActionIds.filter((id) => retained.has(id))); } function reconstructedPersistentState( definition: DungeonEncounterDefinition, snapshot: DungeonEncounterSnapshot, initial: DungeonEncounterSnapshot, ): { readonly flags: Readonly>; readonly counters: Readonly>; readonly worldObjectStates: Readonly>; readonly transitionStates: Readonly>; readonly completedObjectiveIds: readonly string[]; readonly checkpointId: string | null; } { const actionsById = new Map(definition.rules .flatMap((rule) => rule.actions) .map((action) => [action.id, action] as const)); const actions = snapshot.consumedActionIds .map((id) => actionsById.get(id)) .filter((action): action is DungeonEncounterAction => action !== undefined) .filter((action) => action.persistence === "checkpoint" || action.persistence === "run"); const flags = { ...initial.flags }; const counters = { ...initial.counters }; const worldObjectStates = { ...initial.worldObjectStates }; const transitionStates = { ...initial.transitionStates }; const completedObjectiveIds = new Set(); let checkpointId: string | null = null; for (const action of actions) { if (action.kind === "set-flag") flags[action.flagId] = action.value; else if (action.kind === "set-counter") counters[action.counterId] = action.value; else if (action.kind === "increment-counter") { counters[action.counterId] = (counters[action.counterId] ?? 0) + (action.amount ?? 1); } else if (action.kind === "set-world-object-state") { worldObjectStates[action.objectId] = action.state; } else if (action.kind === "set-transition-active") { transitionStates[action.transitionId] = action.active; } else if (action.kind === "complete-objective") { completedObjectiveIds.add(action.objectiveId); } else if (action.kind === "set-checkpoint") { checkpointId = action.checkpointId; } } return { flags: Object.freeze(flags), counters: Object.freeze(counters), worldObjectStates: Object.freeze(worldObjectStates), transitionStates: Object.freeze(transitionStates), completedObjectiveIds: Object.freeze([...completedObjectiveIds].sort()), checkpointId, }; } export function resetDungeonEncounterSnapshot( definition: DungeonEncounterDefinition, snapshot: DungeonEncounterSnapshot, scope: DungeonEncounterResetScope, now = 0, ): DungeonEncounterSnapshot { const initial = createDungeonEncounterSnapshot(definition, now, snapshot.revision + 1); if (scope === "dungeon") return initial; const phase = scope === "boss-wipe" ? definition.resetPolicy?.bossWipePhase ?? definition.initialPhase : definition.resetPolicy?.checkpointPhase ?? definition.initialPhase; const retained = reconstructedPersistentState(definition, snapshot, initial); return Object.freeze({ ...initial, phase, phaseStartedAt: finiteTime(now), flags: retained.flags, counters: retained.counters, worldObjectStates: retained.worldObjectStates, transitionStates: retained.transitionStates, completedObjectiveIds: retained.completedObjectiveIds, consumedActionIds: retainedActionIds(definition, snapshot, scope), checkpointId: retained.checkpointId, }); } export interface DungeonEncounterProvider { readonly definition: DungeonEncounterDefinition; readonly encounterId: string; readonly dungeonId: string; /** Framework-created providers may be rebuilt when a seeded run changes its definition. */ readonly runtimeManaged?: boolean; readonly getSnapshot: () => DungeonEncounterSnapshot; readonly dispatch: (event: DungeonEncounterEvent) => readonly DungeonEncounterEffect[]; readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void; /** Hydrates authoritative online/save state without involving renderer state. */ readonly replaceSnapshot?: (snapshot: DungeonEncounterSnapshot) => void; /** Updates ephemeral run context without polluting serialized snapshots. */ readonly setContext?: (context: DungeonEncounterRuntimeContext) => void; } const providers = new Map(); const pendingEffects: DungeonEncounterEffectEnvelope[] = []; const effectHandlers = new Set<(value: DungeonEncounterEffectEnvelope) => void>(); let activeDungeonId: string | null = null; function clearPendingDungeonEncounterEffects(dungeonId?: string): void { for (let index = pendingEffects.length - 1; index >= 0; index -= 1) { if (!dungeonId || pendingEffects[index].dungeonId === dungeonId) { pendingEffects.splice(index, 1); } } } function queueProviderEffects( provider: DungeonEncounterProvider, effects: readonly DungeonEncounterEffect[], ): readonly DungeonEncounterEffect[] { for (const effect of effects) { const value = Object.freeze({ dungeonId: provider.dungeonId, encounterId: provider.encounterId, effect, }); if (effectHandlers.size) { for (const handler of effectHandlers) handler(value); } else { pendingEffects.push(value); } } return effects; } export function registerDungeonEncounterProvider(provider: DungeonEncounterProvider): () => void { validateDungeonEncounterDefinition(provider.definition); if (provider.definition.id !== provider.encounterId || provider.definition.dungeonId !== provider.dungeonId) { throw new Error(`Encounter provider ${provider.encounterId} does not match its definition`); } const current = providers.get(provider.encounterId); if (current && current !== provider) throw new Error(`Duplicate encounter provider ${provider.encounterId}`); providers.set(provider.encounterId, provider); return () => { if (providers.get(provider.encounterId) === provider) providers.delete(provider.encounterId); }; } export function dungeonEncounterSnapshot(encounterId: string): DungeonEncounterSnapshot | null { return providers.get(encounterId)?.getSnapshot() ?? null; } export function dungeonEncounterStateMatches(encounterId: string, states: readonly string[]): boolean { const snapshot = dungeonEncounterSnapshot(encounterId); return Boolean(snapshot && states.includes(snapshot.phase)); } export function dispatchDungeonEncounterEvent( encounterId: string, event: DungeonEncounterEvent, ): readonly DungeonEncounterEffect[] { const provider = providers.get(encounterId); if (!provider) return []; const effects = queueProviderEffects(provider, provider.dispatch(event)); notifyEncounterSubscribers(); return effects; } export function dispatchDungeonEncounterSignal(signalId: string, now: number): readonly DungeonEncounterEffect[] { const effects: DungeonEncounterEffect[] = []; for (const provider of providers.values()) { if (activeDungeonId && provider.dungeonId !== activeDungeonId) continue; effects.push(...queueProviderEffects(provider, provider.dispatch({ kind: "signal", signalId, now }))); } notifyEncounterSubscribers(); return Object.freeze(effects); } export function dispatchActiveDungeonInteraction( objectId: string, actorId?: string, now = Date.now(), ): readonly DungeonEncounterEffect[] { if (!activeDungeonId) return []; const effects: DungeonEncounterEffect[] = []; for (const provider of providers.values()) { if (provider.dungeonId !== activeDungeonId) continue; effects.push(...queueProviderEffects(provider, provider.dispatch({ kind: "interact", objectId, ...(actorId ? { actorId } : {}), now, }))); } notifyEncounterSubscribers(); return Object.freeze(effects); } export function dispatchActiveDungeonEntityEvent( event: Extract< DungeonEncounterEvent, { readonly kind: "entity-engaged" | "entity-health" | "entity-death" } >, ): readonly DungeonEncounterEffect[] { if (!activeDungeonId) return []; const effects: DungeonEncounterEffect[] = []; for (const provider of providers.values()) { if (provider.dungeonId !== activeDungeonId) continue; effects.push(...queueProviderEffects(provider, provider.dispatch(event))); } notifyEncounterSubscribers(); return Object.freeze(effects); } export function advanceDungeonEncounters( dungeonId: string, event: Extract, ): readonly DungeonEncounterEffect[] { const effects: DungeonEncounterEffect[] = []; for (const provider of providers.values()) { if (provider.dungeonId === dungeonId) { effects.push(...queueProviderEffects(provider, provider.dispatch(event))); } } notifyEncounterSubscribers(); return Object.freeze(effects); } export function resetDungeonEncounters( scope: DungeonEncounterResetScope, dungeonId?: string, now = 0, ): void { clearPendingDungeonEncounterEffects(dungeonId); for (const provider of providers.values()) { if (!dungeonId || provider.dungeonId === dungeonId) provider.reset(scope, now); } notifyEncounterSubscribers(); } /** * Makes every definition in the active dungeon executable. Authored adapters * (such as Naralex) win; otherwise a framework-neutral in-memory provider is * created and can later be hydrated by persistence or multiplayer. */ export function activateDungeonEncounterDefinitions( dungeonId: string, definitions: readonly DungeonEncounterDefinition[], difficultyId?: string, ): void { if (activeDungeonId && activeDungeonId !== dungeonId) { clearPendingDungeonEncounterEffects(activeDungeonId); } activeDungeonId = dungeonId; for (const definition of definitions) { validateDungeonEncounterDefinition(definition); if (definition.dungeonId !== dungeonId) { throw new Error(`Encounter ${definition.id} belongs to ${definition.dungeonId}, not ${dungeonId}`); } const existing = providers.get(definition.id); if (existing) { if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`); if (!existing.runtimeManaged || existing.definition === definition) { existing.setContext?.({ difficultyId }); continue; } // Seeded variants can keep a stable encounter id while changing the // boss/objective rules. Reusing that provider would leave completion // listening for the previous run's boss. providers.delete(definition.id); } let snapshot = createDungeonEncounterSnapshot(definition); let context: DungeonEncounterRuntimeContext = { difficultyId }; const provider: DungeonEncounterProvider = { definition, encounterId: definition.id, dungeonId, runtimeManaged: true, getSnapshot: () => snapshot, dispatch: (event) => { const result = reduceDungeonEncounter(definition, snapshot, event, context); snapshot = result.snapshot; return result.effects; }, reset: (scope, now = 0) => { snapshot = resetDungeonEncounterSnapshot(definition, snapshot, scope, now); }, replaceSnapshot: (value) => { if (value.encounterId !== definition.id) { throw new Error(`Cannot hydrate ${definition.id} from ${value.encounterId}`); } snapshot = cloneEncounterSnapshot(value); }, setContext: (value) => { context = Object.freeze({ ...value }); }, }; providers.set(definition.id, provider); } notifyEncounterSubscribers(); } function cloneEncounterSnapshot(value: DungeonEncounterSnapshot): DungeonEncounterSnapshot { if (value.schemaVersion !== DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION) { throw new Error(`Unsupported encounter snapshot schema ${value.schemaVersion}`); } return Object.freeze({ ...value, phaseStartedAt: finiteTime(value.phaseStartedAt), flags: Object.freeze({ ...value.flags }), counters: Object.freeze({ ...value.counters }), timers: Object.freeze({ ...value.timers }), worldObjectStates: Object.freeze({ ...value.worldObjectStates }), transitionStates: Object.freeze({ ...value.transitionStates }), activeSpawnGroupIds: unique(value.activeSpawnGroupIds), completedObjectiveIds: unique(value.completedObjectiveIds), consumedActionIds: orderedUnique(value.consumedActionIds), revision: Math.max(0, Math.trunc(value.revision)), }); } export function activeDungeonEncounterId(): string | null { return activeDungeonId; } export function exportDungeonEncounterSnapshots(dungeonId = activeDungeonId): readonly DungeonEncounterSnapshot[] { if (!dungeonId) return []; return Object.freeze([...providers.values()] .filter((provider) => provider.dungeonId === dungeonId) .sort((left, right) => left.encounterId.localeCompare(right.encounterId)) .map((provider) => cloneEncounterSnapshot(provider.getSnapshot()))); } /** Save-safe projection that strips timers and temporary waves without changing live state. */ export function exportDungeonEncounterCheckpointSnapshots( dungeonId = activeDungeonId, now = Date.now(), ): readonly DungeonEncounterSnapshot[] { if (!dungeonId) return []; return Object.freeze([...providers.values()] .filter((provider) => provider.dungeonId === dungeonId) .sort((left, right) => left.encounterId.localeCompare(right.encounterId)) .map((provider) => { const live = provider.getSnapshot(); const checkpoint = resetDungeonEncounterSnapshot( provider.definition, live, "checkpoint", now, ); return cloneEncounterSnapshot({ ...checkpoint, revision: live.revision }); })); } export function importDungeonEncounterSnapshots( dungeonId: string, snapshots: readonly DungeonEncounterSnapshot[], options: { readonly authoritative?: boolean } = {}, ): readonly string[] { const imported: string[] = []; for (const raw of snapshots) { const provider = providers.get(raw.encounterId); if (!provider || provider.dungeonId !== dungeonId || !provider.replaceSnapshot) continue; const snapshot = cloneEncounterSnapshot(raw); if (!options.authoritative && snapshot.revision < provider.getSnapshot().revision) continue; provider.replaceSnapshot(snapshot); imported.push(snapshot.encounterId); } if (imported.length) notifyEncounterSubscribers(); return Object.freeze(imported.sort()); } export function drainDungeonEncounterEffects( dungeonId = activeDungeonId, ): readonly DungeonEncounterEffectEnvelope[] { if (!dungeonId) return []; const drained = pendingEffects.filter((value) => value.dungeonId === dungeonId); for (let index = pendingEffects.length - 1; index >= 0; index -= 1) { if (pendingEffects[index].dungeonId === dungeonId) pendingEffects.splice(index, 1); } return Object.freeze(drained); } /** Immediate application hook. While one is mounted, effects bypass the headless drain queue. */ export function registerDungeonEncounterEffectHandler( handler: (value: DungeonEncounterEffectEnvelope) => void, ): () => void { effectHandlers.add(handler); return () => effectHandlers.delete(handler); } const encounterSubscribers = new Set<() => void>(); function notifyEncounterSubscribers(): void { for (const subscriber of encounterSubscribers) subscriber(); } /** React can consume this with useSyncExternalStore without owning simulation. */ export function subscribeDungeonEncounterSnapshots(subscriber: () => void): () => void { encounterSubscribers.add(subscriber); return () => encounterSubscribers.delete(subscriber); } export function activeDungeonEncounterRevision(): number { return exportDungeonEncounterSnapshots().reduce( (revision, snapshot) => revision + snapshot.revision, 0, ); } export function activeDungeonEncounterSpawnGroupIds(): readonly string[] { return Object.freeze([...new Set(exportDungeonEncounterSnapshots() .flatMap((snapshot) => snapshot.activeSpawnGroupIds))].sort()); } export function activeDungeonCompletedObjectiveIds(dungeonId = activeDungeonId): readonly string[] { return unique(exportDungeonEncounterSnapshots(dungeonId) .flatMap((snapshot) => snapshot.completedObjectiveIds)); } function definitionControlsDungeonCompletion(definition: DungeonEncounterDefinition): boolean { return definition.rules.some((rule) => rule.actions.some((action) => ( action.kind === "set-flag" && action.flagId === "dungeon-complete" && action.value ))); } export function activeDungeonRunIsComplete(dungeonId = activeDungeonId): boolean { if (!dungeonId) return false; const completionProviders = [...providers.values()].filter((provider) => ( provider.dungeonId === dungeonId && definitionControlsDungeonCompletion(provider.definition) )); return completionProviders.length > 0 && completionProviders.every( (provider) => provider.getSnapshot().flags["dungeon-complete"] === true, ); }