Files
healer-man/src/game/dungeonRegistry.ts
T
2026-09-20 18:08:43 -04:00

305 lines
13 KiB
TypeScript

import type { DungeonDefinition } from "./dungeonTypes";
import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
import { WAILING_CAVERNS_DEFINITION } from "./dungeons/wailingCaverns";
import availability from "./generated/dungeonAvailability.json";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { gunzipSync, strFromU8 } from "fflate";
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
import { withRunewakerCompletion } from "./runewakerCompletion";
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
import { withWowWrathEncounterContent } from "./wowWrathEncounterContentCatalog";
import { withWowClassicEncounterContent } from "./wowClassicEncounterContentCatalog";
import { withWowTbcEncounterContent } from "./wowTbcEncounterContentCatalog";
import { withWowWrathEncounterBatchTwoContent } from "./wowWrathEncounterBatchTwoCatalog";
import { withWowWrathEncounterBatchThreeContent } from "./wowWrathEncounterBatchThreeCatalog";
import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCatalog";
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentSourceId,
} from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import {
createRandomizedBiteSizedStockadesDefinition,
randomizedBiteSizedCreatureCatalog,
} from "./dungeons/randomizedBiteSizedStockades";
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
};
const generatedDefinitions = campaign.definitions as unknown as readonly DungeonDefinition[];
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
/** Keep authored WC presentation and encounters while restoring its complete server population. */
function enrichAuthoredDefinition(
authored: DungeonDefinition,
generated: DungeonDefinition | undefined,
): DungeonDefinition {
if (!generated) return authored;
const authoredByName = new Map(
Object.values(authored.entities).map((entity) => [normalizedName(entity.name), entity]),
);
const generatedEntities = Object.fromEntries(
Object.entries(generated.entities).filter(([, entity]) => entity.kind !== "boss").map(([id, entity]) => {
const authoredEntity = authoredByName.get(normalizedName(entity.name));
return [
id,
authoredEntity
? {
...entity,
visual: authoredEntity.visual,
...(authoredEntity.combat ? { combat: authoredEntity.combat } : {}),
}
: entity,
];
}),
);
const generatedBossEntityIds = new Set(
Object.entries(generated.entities)
.filter(([, entity]) => entity.kind === "boss")
.map(([id]) => id),
);
const authoredBossEntityIds = new Set(
Object.entries(authored.entities)
.filter(([, entity]) => entity.kind === "boss")
.map(([id]) => id),
);
const authoredObjectiveByName = new Map(
authored.objectiveOrder.map((objective) => [normalizedName(objective.name), objective.id]),
);
const generatedObjectiveAliases = new Map(
generated.objectiveOrder.map((objective) => [
objective.id,
authoredObjectiveByName.get(normalizedName(objective.name)) ?? objective.id,
]),
);
const authoredId = (id: string): string => generatedObjectiveAliases.get(id) ?? id;
const remappedGeneratedEncounters = (generated.encounters ?? []).map((encounter) => (
encounter.source?.scope !== "boss-progression"
? encounter
: {
...encounter,
rules: encounter.rules.map((rule) => ({
...rule,
trigger: rule.trigger.kind === "entities-dead"
? { ...rule.trigger, entityIds: rule.trigger.entityIds.map(authoredId) }
: rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged"
? { ...rule.trigger, entityId: authoredId(rule.trigger.entityId) }
: rule.trigger,
guards: rule.guards?.map((guard) => (
guard.kind === "objective-complete"
? { ...guard, objectiveId: authoredId(guard.objectiveId) }
: guard
)),
actions: rule.actions.map((action) => {
if (action.kind === "complete-objective") {
return { ...action, objectiveId: authoredId(action.objectiveId) };
}
if (action.kind === "set-checkpoint") {
return { ...action, checkpointId: authoredId(action.checkpointId) };
}
return action;
}),
})),
}
));
return {
...authored,
entities: {
...generatedEntities,
...authored.entities,
},
staticSpawns: [
...generated.staticSpawns.filter((spawn) => !generatedBossEntityIds.has(spawn.entityId)),
...authored.staticSpawns,
],
roamingPacks: [
...generated.roamingPacks,
...authored.roamingPacks.filter((pack) => (
pack.members.some((member) => authoredBossEntityIds.has(member.entityId))
)),
],
encounters: [
...remappedGeneratedEncounters,
...(authored.encounters ?? []),
],
encounterSpawnGroups: [
...(generated.encounterSpawnGroups ?? []),
...(authored.encounterSpawnGroups ?? []),
],
serverGameObjects: generated.serverGameObjects,
};
}
const authoredWailingCaverns = enrichAuthoredDefinition(
WAILING_CAVERNS_DEFINITION,
generatedDefinitions.find((definition) => definition.id === WAILING_CAVERNS_DEFINITION.id),
);
if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
throw new Error(`Default dungeon id mismatch: ${authoredWailingCaverns.id}`);
}
const DUNGEON_CONTENT_ADAPTERS: readonly (
(definition: DungeonDefinition) => DungeonDefinition
)[] = Object.freeze([
withWowDungeonEnvironment,
withWowEncounterContent,
withWowClassicEncounterContent,
withWowClassicEncounterBatchTwo,
withWowClassicEncounterBatchThree,
withWowVerifiedNoOpInstanceContent,
withWowWrathEncounterContent,
withWowWrathEncounterBatchTwoContent,
withWowWrathEncounterBatchThreeContent,
withWowProgressionObjectContent,
withWowTbcEncounterContent,
withWowTbcEncounterBatchTwoContent,
withAscensionCustomEncounterContent,
withRunewakerEncounterContent,
withRunewakerCompletion,
]);
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
return DUNGEON_CONTENT_ADAPTERS.reduce(
(current, applyContent) => applyContent(current),
definition,
);
}
const sourceDungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
[authoredWailingCaverns.id, authoredWailingCaverns],
[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION],
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => [definition.id, definition] as const),
...generatedDefinitions
.filter((definition) => definition.id !== authoredWailingCaverns.id)
.map((definition) => [definition.id, definition] as const),
];
const adaptedSourceEntries = sourceDungeonRegistryEntries.map(
([id, definition]) => [id, applyDungeonContentAdapters(definition)] as const,
);
const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readonly<Record<string, DungeonDefinition>>;
const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID];
if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant.");
export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
);
export function randomizedBiteSizedDefinitionForSeed(seed: string): DungeonDefinition {
return createRandomizedBiteSizedStockadesDefinition(
stockadesDefinition,
seed,
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
);
}
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
[randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate],
];
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
Object.fromEntries(dungeonRegistryEntries),
);
export type DungeonId = string;
export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY));
// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable.
const bossTestingDefinitions = new Map<string, { definition: DungeonDefinition; sourceDungeonId: string }>();
const sessionDungeonDefinitions = new Map<string, DungeonDefinition>();
export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void {
if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) {
throw new Error("Invalid boss testing dungeon.");
}
bossTestingDefinitions.clear();
bossTestingDefinitions.set(definition.id, { definition, sourceDungeonId });
}
export function bossTestingSourceDungeonId(id: string): string | null {
return bossTestingDefinitions.get(id)?.sourceDungeonId ?? null;
}
export function clearBossTestingDungeons(): void {
bossTestingDefinitions.clear();
}
/** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */
export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null {
if (id !== BITE_SIZED_STOCKADES_DUNGEON_ID && id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
return dungeonDefinitionById(id);
}
const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID
? createBiteSizedStockadesDefinition(stockadesDefinition, seed)
: randomizedBiteSizedDefinitionForSeed(seed);
sessionDungeonDefinitions.set(id, definition);
return definition;
}
export function dungeonDefinitionById(id: string): DungeonDefinition | null {
return bossTestingDefinitions.get(id)?.definition
?? sessionDungeonDefinitions.get(id)
?? DUNGEON_REGISTRY[id]
?? null;
}
export function requireDungeonDefinition(id: string): DungeonDefinition {
const definition = dungeonDefinitionById(id);
if (!definition) throw new Error(`Unknown dungeon definition: ${id}`);
return definition;
}
/** Portable package metadata is the offline validation gate for independently generated dungeons. */
export function dungeonPackagePassesOfflineGate(definition: DungeonDefinition): boolean {
return definition.availability === "available"
&& definition.validation.status === "green"
&& definition.validation.blockers.length === 0;
}
export function dungeonIsEnabled(id: string): boolean {
const definition = dungeonDefinitionById(id);
if (!definition) return false;
const configured = (availability.enabled as Readonly<Record<string, boolean>>)[dungeonContentSourceId(id)];
if (definition.expansion !== "runewaker") return configured === true;
if (!dungeonPackagePassesOfflineGate(definition)) return false;
if (configured !== undefined) return configured;
// The WoW and RuneWaker package generators are independent. A WoW catalog
// refresh can legitimately omit RuneWaker keys, so those packages fall back
// to the green validation metadata bundled into their generated definition.
return true;
}
export function dungeonCanEnter(id: string): boolean {
const definition = dungeonDefinitionById(id);
return Boolean(definition && dungeonIsEnabled(id) && definition.assets.visual.length && definition.assets.collision.length);
}