Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -0,0 +1,600 @@
|
||||
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());
|
||||
}
|
||||
|
||||
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`);
|
||||
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 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounterGuard): boolean {
|
||||
if (guard.kind === "phase") return guard.phases.includes(snapshot.phase);
|
||||
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;
|
||||
}
|
||||
|
||||
function triggerMatches(
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
trigger: DungeonEncounterRule["trigger"],
|
||||
event: DungeonEncounterEvent,
|
||||
): boolean {
|
||||
if (trigger.kind === "tick") return event.kind === "tick";
|
||||
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId;
|
||||
if (trigger.kind === "entity-engaged" || trigger.kind === "entity-death") {
|
||||
return event.kind === trigger.kind && event.entityId === trigger.entityId;
|
||||
}
|
||||
if (trigger.kind === "entity-health") {
|
||||
return event.kind === "entity-health"
|
||||
&& event.entityId === trigger.entityId
|
||||
&& event.healthPercent <= trigger.atOrBelowPercent;
|
||||
}
|
||||
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId;
|
||||
if (trigger.kind === "elapsed") {
|
||||
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs;
|
||||
}
|
||||
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));
|
||||
const timedOut = trigger.timeoutMs !== undefined
|
||||
&& event.now - snapshot.phaseStartedAt >= trigger.timeoutMs;
|
||||
return allDead || timedOut;
|
||||
}
|
||||
|
||||
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: unique([...next.consumedActionIds, action.id]) }
|
||||
: next,
|
||||
effect: actionEffect(action),
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceDungeonEncounter(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
event: DungeonEncounterEvent,
|
||||
): 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;
|
||||
if (!triggerMatches(next, rule.trigger, event)) continue;
|
||||
if (!rule.guards?.every((guard) => guardMatches(next, guard)) && 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 retainedStateKeys(definition: DungeonEncounterDefinition): {
|
||||
readonly flags: ReadonlySet<string>;
|
||||
readonly counters: ReadonlySet<string>;
|
||||
readonly worldObjects: ReadonlySet<string>;
|
||||
readonly transitions: ReadonlySet<string>;
|
||||
readonly objectives: ReadonlySet<string>;
|
||||
readonly checkpoints: boolean;
|
||||
} {
|
||||
const actions = definition.rules.flatMap((rule) => rule.actions)
|
||||
.filter((action) => action.persistence === "checkpoint" || action.persistence === "run");
|
||||
return {
|
||||
flags: new Set(actions.filter((action) => action.kind === "set-flag").map((action) => action.flagId)),
|
||||
counters: new Set(actions
|
||||
.filter((action) => action.kind === "set-counter" || action.kind === "increment-counter")
|
||||
.map((action) => action.counterId)),
|
||||
worldObjects: new Set(actions
|
||||
.filter((action) => action.kind === "set-world-object-state")
|
||||
.map((action) => action.objectId)),
|
||||
transitions: new Set(actions
|
||||
.filter((action) => action.kind === "set-transition-active")
|
||||
.map((action) => action.transitionId)),
|
||||
objectives: new Set(actions
|
||||
.filter((action) => action.kind === "complete-objective")
|
||||
.map((action) => action.objectiveId)),
|
||||
checkpoints: actions.some((action) => action.kind === "set-checkpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
function retainedRecord<T>(
|
||||
initial: Readonly<Record<string, T>>,
|
||||
current: Readonly<Record<string, T>>,
|
||||
keys: ReadonlySet<string>,
|
||||
): Readonly<Record<string, T>> {
|
||||
const retained: Record<string, T> = { ...initial };
|
||||
for (const key of keys) {
|
||||
if (current[key] !== undefined) retained[key] = current[key];
|
||||
}
|
||||
return Object.freeze(retained);
|
||||
}
|
||||
|
||||
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 = retainedStateKeys(definition);
|
||||
return Object.freeze({
|
||||
...initial,
|
||||
phase,
|
||||
phaseStartedAt: finiteTime(now),
|
||||
flags: retainedRecord(initial.flags, snapshot.flags, retained.flags),
|
||||
counters: retainedRecord(initial.counters, snapshot.counters, retained.counters),
|
||||
worldObjectStates: retainedRecord(
|
||||
initial.worldObjectStates,
|
||||
snapshot.worldObjectStates,
|
||||
retained.worldObjects,
|
||||
),
|
||||
transitionStates: retainedRecord(
|
||||
initial.transitionStates,
|
||||
snapshot.transitionStates,
|
||||
retained.transitions,
|
||||
),
|
||||
completedObjectiveIds: Object.freeze(snapshot.completedObjectiveIds
|
||||
.filter((id) => retained.objectives.has(id))),
|
||||
consumedActionIds: retainedActionIds(definition, snapshot, scope),
|
||||
checkpointId: retained.checkpoints ? snapshot.checkpointId : null,
|
||||
});
|
||||
}
|
||||
|
||||
export interface DungeonEncounterProvider {
|
||||
readonly definition: DungeonEncounterDefinition;
|
||||
readonly encounterId: string;
|
||||
readonly dungeonId: string;
|
||||
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;
|
||||
}
|
||||
|
||||
const providers = new Map<string, DungeonEncounterProvider>();
|
||||
const pendingEffects: DungeonEncounterEffectEnvelope[] = [];
|
||||
const effectHandlers = new Set<(value: DungeonEncounterEffectEnvelope) => void>();
|
||||
let activeDungeonId: string | null = null;
|
||||
|
||||
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,
|
||||
});
|
||||
pendingEffects.push(value);
|
||||
for (const handler of effectHandlers) handler(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 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 {
|
||||
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[],
|
||||
): void {
|
||||
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`);
|
||||
continue;
|
||||
}
|
||||
let snapshot = createDungeonEncounterSnapshot(definition);
|
||||
const provider: DungeonEncounterProvider = {
|
||||
definition,
|
||||
encounterId: definition.id,
|
||||
dungeonId,
|
||||
getSnapshot: () => snapshot,
|
||||
dispatch: (event) => {
|
||||
const result = reduceDungeonEncounter(definition, snapshot, event);
|
||||
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);
|
||||
},
|
||||
};
|
||||
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: unique(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[],
|
||||
): 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 (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; effects also remain drainable for frame-bound consumers. */
|
||||
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());
|
||||
}
|
||||
Reference in new issue
Block a user