Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+205 -69
View File
@@ -20,6 +20,15 @@ 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,
@@ -53,12 +62,37 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
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)) {
@@ -72,8 +106,19 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
}
}
function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounterGuard): boolean {
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;
@@ -90,33 +135,55 @@ function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounte
return current <= guard.value;
}
type DungeonEncounterTriggerMatch = "event" | "timeout" | false;
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;
): 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;
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.healthPercent <= trigger.atOrBelowPercent
? "event"
: false;
}
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId;
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;
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 allDead || timedOut;
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 {
@@ -193,7 +260,7 @@ function applyAction(
}
return {
snapshot: consume
? { ...next, consumedActionIds: unique([...next.consumedActionIds, action.id]) }
? { ...next, consumedActionIds: orderedUnique([...next.consumedActionIds, action.id]) }
: next,
effect: actionEffect(action),
};
@@ -203,6 +270,7 @@ 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}`);
@@ -215,8 +283,10 @@ export function reduceDungeonEncounter(
.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;
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);
@@ -246,44 +316,54 @@ function retainedActionIds(
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;
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 actions = definition.rules.flatMap((rule) => rule.actions)
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");
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];
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 Object.freeze(retained);
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(
@@ -297,27 +377,18 @@ export function resetDungeonEncounterSnapshot(
const phase = scope === "boss-wipe"
? definition.resetPolicy?.bossWipePhase ?? definition.initialPhase
: definition.resetPolicy?.checkpointPhase ?? definition.initialPhase;
const retained = retainedStateKeys(definition);
const retained = reconstructedPersistentState(definition, snapshot, initial);
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))),
flags: retained.flags,
counters: retained.counters,
worldObjectStates: retained.worldObjectStates,
transitionStates: retained.transitionStates,
completedObjectiveIds: retained.completedObjectiveIds,
consumedActionIds: retainedActionIds(definition, snapshot, scope),
checkpointId: retained.checkpoints ? snapshot.checkpointId : null,
checkpointId: retained.checkpointId,
});
}
@@ -330,6 +401,8 @@ export interface DungeonEncounterProvider {
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>();
@@ -337,6 +410,14 @@ 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[],
@@ -347,8 +428,11 @@ function queueProviderEffects(
encounterId: provider.encounterId,
effect,
});
pendingEffects.push(value);
for (const handler of effectHandlers) handler(value);
if (effectHandlers.size) {
for (const handler of effectHandlers) handler(value);
} else {
pendingEffects.push(value);
}
}
return effects;
}
@@ -416,6 +500,22 @@ export function dispatchActiveDungeonInteraction(
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" }>,
@@ -435,6 +535,7 @@ export function resetDungeonEncounters(
dungeonId?: string,
now = 0,
): void {
clearPendingDungeonEncounterEffects(dungeonId);
for (const provider of providers.values()) {
if (!dungeonId || provider.dungeonId === dungeonId) provider.reset(scope, now);
}
@@ -449,7 +550,11 @@ export function resetDungeonEncounters(
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);
@@ -459,16 +564,18 @@ export function activateDungeonEncounterDefinitions(
const existing = providers.get(definition.id);
if (existing) {
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
existing.setContext?.({ difficultyId });
continue;
}
let snapshot = createDungeonEncounterSnapshot(definition);
let context: DungeonEncounterRuntimeContext = { difficultyId };
const provider: DungeonEncounterProvider = {
definition,
encounterId: definition.id,
dungeonId,
getSnapshot: () => snapshot,
dispatch: (event) => {
const result = reduceDungeonEncounter(definition, snapshot, event);
const result = reduceDungeonEncounter(definition, snapshot, event, context);
snapshot = result.snapshot;
return result.effects;
},
@@ -481,6 +588,9 @@ export function activateDungeonEncounterDefinitions(
}
snapshot = cloneEncounterSnapshot(value);
},
setContext: (value) => {
context = Object.freeze({ ...value });
},
};
providers.set(definition.id, provider);
}
@@ -501,7 +611,7 @@ function cloneEncounterSnapshot(value: DungeonEncounterSnapshot): DungeonEncount
transitionStates: Object.freeze({ ...value.transitionStates }),
activeSpawnGroupIds: unique(value.activeSpawnGroupIds),
completedObjectiveIds: unique(value.completedObjectiveIds),
consumedActionIds: unique(value.consumedActionIds),
consumedActionIds: orderedUnique(value.consumedActionIds),
revision: Math.max(0, Math.trunc(value.revision)),
});
}
@@ -542,13 +652,14 @@ export function exportDungeonEncounterCheckpointSnapshots(
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 (snapshot.revision < provider.getSnapshot().revision) continue;
if (!options.authoritative && snapshot.revision < provider.getSnapshot().revision) continue;
provider.replaceSnapshot(snapshot);
imported.push(snapshot.encounterId);
}
@@ -567,7 +678,7 @@ export function drainDungeonEncounterEffects(
return Object.freeze(drained);
}
/** Immediate application hook; effects also remain drainable for frame-bound consumers. */
/** Immediate application hook. While one is mounted, effects bypass the headless drain queue. */
export function registerDungeonEncounterEffectHandler(
handler: (value: DungeonEncounterEffectEnvelope) => void,
): () => void {
@@ -598,3 +709,28 @@ 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,
);
}