import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry"; import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories"; import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage"; import { BITE_SIZED_STOCKADES_DUNGEON_ID, isRandomizedBiteSizedDungeonId, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, } from "./dungeonContentSource"; const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[]; const DUNGEON_CATEGORY_IDS = ["wow", "rom", "bite-sized"] as const; export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number]; export interface DungeonCategory { readonly id: DungeonCategoryId; readonly label: string; readonly fullName: string; } export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([ ...SOURCE_DUNGEON_CATEGORY_IDS.map((id) => { const category = contentCategoryById(id); return { id, label: category.label, fullName: category.name }; }), { id: "bite-sized", label: "Bite-Sized", fullName: "Bite-Sized Dungeons" }, ]); export interface DungeonCatalogEntry { readonly id: DungeonId; readonly category: DungeonCategoryId; readonly title: string; readonly mapId: number; readonly entranceName: string; readonly location: string; readonly theme: string; readonly summary: string; readonly available: boolean; readonly releaseStage: DungeonReleaseStage; readonly availabilityReason?: string; } /** Release readiness first, then alphabetical title within each release stage. */ export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze( DUNGEON_DEFINITIONS.map((definition) => ({ id: definition.id as DungeonId, category: definition.id === BITE_SIZED_STOCKADES_DUNGEON_ID || definition.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID ? "bite-sized" : definition.expansion === "runewaker" ? "rom" : "wow", title: definition.title, mapId: definition.mapId, entranceName: definition.entrance.name, location: definition.presentation.location, theme: definition.presentation.theme, summary: definition.presentation.summary, available: dungeonCanEnter(definition.id), releaseStage: dungeonReleaseStage(definition.id), availabilityReason: "availabilityReason" in definition ? definition.availabilityReason : undefined, })).sort(compareDungeonReleaseOrder), ); export type { DungeonId } from "./dungeonRegistry"; export function dungeonById(id: string): DungeonCatalogEntry | null { return DUNGEON_CATALOG.find((dungeon) => dungeon.id === id) ?? null; } export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] { return DUNGEON_CATALOG.filter((dungeon) => ( dungeon.category === category && !isRandomizedBiteSizedDungeonId(dungeon.id) )); } /** The separately launched randomized mode, including every eligible location added later. */ export function randomizedBiteSizedDungeons(): readonly DungeonCatalogEntry[] { return DUNGEON_CATALOG.filter((dungeon) => ( dungeon.category === "bite-sized" && isRandomizedBiteSizedDungeonId(dungeon.id) )); } function randomAvailableDungeon( dungeons: readonly DungeonCatalogEntry[], currentDungeonId: string | undefined, random: () => number, eligible: (dungeon: DungeonCatalogEntry) => boolean, ): DungeonCatalogEntry | null { const available = dungeons.filter((dungeon) => dungeon.available && eligible(dungeon)); const candidates = available.length > 1 ? available.filter((dungeon) => dungeon.id !== currentDungeonId) : available; if (candidates.length === 0) return null; const roll = Math.max(0, Math.min(0.9999999999999999, random())); return candidates[Math.floor(roll * candidates.length)] ?? candidates[0]; } /** Selects from enterable category content and avoids an immediate repeat when alternatives exist. */ export function randomDungeonByCategory( category: DungeonCategoryId, currentDungeonId?: string, random: () => number = Math.random, eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true, ): DungeonCatalogEntry | null { return randomAvailableDungeon(dungeonsByCategory(category), currentDungeonId, random, eligible); } /** Selects an enterable full-length WoW or RoM dungeon, excluding both Bite-Sized modes. */ export function randomRegularDungeon( currentDungeonId?: string, random: () => number = Math.random, eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true, ): DungeonCatalogEntry | null { return randomAvailableDungeon( DUNGEON_CATALOG.filter((dungeon) => dungeon.category !== "bite-sized"), currentDungeonId, random, eligible, ); } /** Selects the next location without crossing from randomized mode into authored Bite-Sized mode. */ export function randomRandomizedBiteSizedDungeon( currentDungeonId?: string, random: () => number = Math.random, eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true, ): DungeonCatalogEntry | null { return randomAvailableDungeon(randomizedBiteSizedDungeons(), currentDungeonId, random, eligible); }