115 lines
4.5 KiB
TypeScript
115 lines
4.5 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";
|
|
|
|
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/server-backed WC combat; use generated kits only for unmapped fixtures. */
|
|
function enrichAuthoredDefinition(
|
|
authored: DungeonDefinition,
|
|
generated: DungeonDefinition | undefined,
|
|
): DungeonDefinition {
|
|
if (!generated) return authored;
|
|
const generatedByName = new Map(
|
|
Object.values(generated.entities).map((entity) => [normalizedName(entity.name), entity]),
|
|
);
|
|
return {
|
|
...authored,
|
|
entities: Object.fromEntries(
|
|
Object.entries(authored.entities).map(([id, entity]) => {
|
|
const generatedEntity = generatedByName.get(normalizedName(entity.name));
|
|
return [
|
|
id,
|
|
entity.combat
|
|
? entity
|
|
: generatedEntity?.combat
|
|
? { ...entity, combat: generatedEntity.combat }
|
|
: entity,
|
|
];
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
|
|
Object.fromEntries([
|
|
[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),
|
|
]),
|
|
);
|
|
|
|
export type DungeonId = string;
|
|
|
|
export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY));
|
|
|
|
export function dungeonDefinitionById(id: string): DungeonDefinition | null {
|
|
return 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>>)[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);
|
|
}
|