Files
healer-man/src/game/dungeonEncounterRuntime.ts
T
2026-09-20 15:05:14 -04:00

746 lines
30 KiB
TypeScript

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<string>();
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<string>();
const actionIds = new Set<string>();
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<Record<string, boolean>>;
readonly counters: Readonly<Record<string, number>>;
readonly worldObjectStates: Readonly<Record<string, string>>;
readonly transitionStates: Readonly<Record<string, boolean>>;
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<string>();
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<string, DungeonEncounterProvider>();
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<DungeonEncounterEvent, { readonly kind: "tick" }>,
): 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,
);
}