Files
healer-man/src/game/dungeonCatalog.ts
T
2026-09-23 16:14:19 -04:00

128 lines
5.1 KiB
TypeScript

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<DungeonCatalogEntry>((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);
}