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