746 lines
30 KiB
TypeScript
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,
|
|
);
|
|
}
|