Files
healer-man/src/game/dungeonRegistry.ts
T
2026-09-27 10:16:42 -04:00

415 lines
20 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,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
BITE_SIZED_BARREN_CAVES_DUNGEON_ID,
BITE_SIZED_HOTO_DUNGEON_ID,
BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID,
BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentSourceId,
isRandomizedBiteSizedDungeonId,
} from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import {
createBiteSizedDeadminesDefinition,
createBiteSizedUtgardePinnacleDefinition,
createBiteSizedWailingCavernsDefinition,
} from "./dungeons/biteSizedWoWDungeons";
import {
createBiteSizedForsakenAbbeyDefinition,
createBiteSizedMysticAltarDefinition,
createBiteSizedPaspersShrineDefinition,
createBiteSizedGrafuCastleDefinition,
createBiteSizedBarrenCavesDefinition,
createBiteSizedHotoDefinition,
createBiteSizedKawaksTombDefinition,
createBiteSizedHallOfSurvivorsDefinition,
} from "./dungeons/biteSizedRoMDungeons";
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.");
const wailingCavernsDefinition = adaptedSourceRegistry["wailing-caverns"];
const deadminesDefinition = adaptedSourceRegistry["deadmines"];
const utgardePinnacleDefinition = adaptedSourceRegistry["utgarde-pinnacle"];
if (!wailingCavernsDefinition || !deadminesDefinition || !utgardePinnacleDefinition) {
throw new Error("Wailing Caverns, The Deadmines, and Utgarde Pinnacle are required for their bite-sized variants.");
}
export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
);
const AVAILABLE_BITE_SIZED_SOURCE_IDS = new Set([
"stormwind-stockade", "wailing-caverns", "deadmines", "utgarde-pinnacle",
"forsaken-abbey", "paspers-shrine", "mystic-altar",
"grafu-castle", "barren-caves",
"hoto", "kawaks-tomb", "hall-of-survivors",
]);
function sourceIsAvailable(source: DungeonDefinition): boolean {
const configured = (availability.enabled as Readonly<Record<string, boolean>>)[source.id];
return Boolean(source.assets.visual.length && source.assets.collision.length
&& (source.expansion === "runewaker"
? dungeonPackagePassesOfflineGate(source) && configured !== false
: configured === true));
}
const availableRandomizedSources = adaptedSourceEntries.map(([, definition]) => definition).filter(sourceIsAvailable);
export const AVAILABLE_RANDOMIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(availableRandomizedSources),
);
export const BITE_SIZED_RANDOMIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(availableRandomizedSources.filter((source) => AVAILABLE_BITE_SIZED_SOURCE_IDS.has(source.id))),
);
export function randomizedBiteSizedDefinitionForSeed(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): DungeonDefinition {
if (dungeonId !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
&& dungeonId !== RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID) {
throw new Error(`Unknown Randomized Bite-Sized pool: ${dungeonId}`);
}
return createRandomizedBiteSizedStockadesDefinition(
stockadesDefinition,
seed,
dungeonId === RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID
? BITE_SIZED_RANDOMIZED_CREATURE_CATALOG : AVAILABLE_RANDOMIZED_CREATURE_CATALOG,
dungeonId,
);
}
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
const randomizedBiteSizedAvailableTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template", RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID);
const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition, "catalog-template");
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition, "catalog-template");
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, "catalog-template");
const forsakenAbbeyDefinition = adaptedSourceRegistry["forsaken-abbey"];
const paspersShrineDefinition = adaptedSourceRegistry["paspers-shrine"];
const mysticAltarDefinition = adaptedSourceRegistry["mystic-altar"];
const grafuCastleDefinition = adaptedSourceRegistry["grafu-castle"];
const barrenCavesDefinition = adaptedSourceRegistry["barren-caves"];
const hotoDefinition = adaptedSourceRegistry["hoto"];
const kawaksTombDefinition = adaptedSourceRegistry["kawaks-tomb"];
const hallOfSurvivorsDefinition = adaptedSourceRegistry["hall-of-survivors"];
if (!forsakenAbbeyDefinition || !paspersShrineDefinition || !mysticAltarDefinition
|| !grafuCastleDefinition || !barrenCavesDefinition || !hotoDefinition
|| !kawaksTombDefinition || !hallOfSurvivorsDefinition) {
throw new Error("The eight RoM source dungeons are required for their bite-sized variants.");
}
const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, "catalog-template");
const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, "catalog-template");
const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, "catalog-template");
const biteSizedGrafuCastle = createBiteSizedGrafuCastleDefinition(grafuCastleDefinition, "catalog-template");
const biteSizedBarrenCaves = createBiteSizedBarrenCavesDefinition(barrenCavesDefinition, "catalog-template");
const biteSizedHoto = createBiteSizedHotoDefinition(hotoDefinition, "catalog-template");
const biteSizedKawaksTomb = createBiteSizedKawaksTombDefinition(kawaksTombDefinition, "catalog-template");
const biteSizedHallOfSurvivors = createBiteSizedHallOfSurvivorsDefinition(hallOfSurvivorsDefinition, "catalog-template");
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
[randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate],
[randomizedBiteSizedAvailableTemplate.id, randomizedBiteSizedAvailableTemplate],
[biteSizedWailingCaverns.id, biteSizedWailingCaverns],
[biteSizedDeadmines.id, biteSizedDeadmines],
[biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle],
[biteSizedForsakenAbbey.id, biteSizedForsakenAbbey],
[biteSizedPaspersShrine.id, biteSizedPaspersShrine],
[biteSizedMysticAltar.id, biteSizedMysticAltar],
[biteSizedGrafuCastle.id, biteSizedGrafuCastle],
[biteSizedBarrenCaves.id, biteSizedBarrenCaves],
[biteSizedHoto.id, biteSizedHoto],
[biteSizedKawaksTomb.id, biteSizedKawaksTomb],
[biteSizedHallOfSurvivors.id, biteSizedHallOfSurvivors],
];
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 {
let definition: DungeonDefinition;
if (id === BITE_SIZED_STOCKADES_DUNGEON_ID) definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed);
else if (isRandomizedBiteSizedDungeonId(id)) definition = randomizedBiteSizedDefinitionForSeed(seed, id);
else if (id === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID) definition = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, seed);
else if (id === BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID) definition = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, seed);
else if (id === BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID) definition = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, seed);
else if (id === BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID) definition = createBiteSizedGrafuCastleDefinition(grafuCastleDefinition, seed);
else if (id === BITE_SIZED_BARREN_CAVES_DUNGEON_ID) definition = createBiteSizedBarrenCavesDefinition(barrenCavesDefinition, seed);
else if (id === BITE_SIZED_HOTO_DUNGEON_ID) definition = createBiteSizedHotoDefinition(hotoDefinition, seed);
else if (id === BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID) definition = createBiteSizedKawaksTombDefinition(kawaksTombDefinition, seed);
else if (id === BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID) definition = createBiteSizedHallOfSurvivorsDefinition(hallOfSurvivorsDefinition, seed);
else if (id === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID) definition = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition, seed);
else if (id === BITE_SIZED_DEADMINES_DUNGEON_ID) definition = createBiteSizedDeadminesDefinition(deadminesDefinition, seed);
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
else return dungeonDefinitionById(id);
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);
}