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>; 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>)[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> = 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(); const sessionDungeonDefinitions = new Map(); 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>)[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); }