forked from phenom/healer-man
415 lines
20 KiB
TypeScript
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);
|
|
}
|