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>; 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> = 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 { 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>)[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); }