Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 22:02:18 -04:00
1 parent 8a98797fcf
commit 11934a3979
44 files changed
+3183 -329

No files matched your search

+2 -2
View File
@@ -10,7 +10,7 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore";
import { useCompanionPartyPreferences } from "./companionPartyPreferences";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
import { isRandomizedBiteSizedDungeonId } from "./dungeonContentSource";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
@@ -46,7 +46,7 @@ export function PartyBridge() {
? onlineGroup.members
: [];
const randomizedReadyOnly = onlineActivity?.type === "dungeon"
&& onlineActivity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID;
&& isRandomizedBiteSizedDungeonId(onlineActivity.contentId);
const companionOptions = {
readinessPolicy,
readinessPolicyRevision: onlineActivity?.companionReadinessRevision ?? readinessPolicyRevision,
+5
View File
@@ -47,6 +47,11 @@ export function bossCardByKey(key: string): BossCardDefinition | null {
}
export function bossCardForSource(sourceId: string, dungeonId: string, name?: string): BossCardDefinition | null {
const randomizedCategory = /^randomized:(wow|rom):boss:/.exec(sourceId)?.[1];
if (randomizedCategory && name) {
const key = `${randomizedCategory}:${name.trim().toLowerCase().replace(/\s+/g, " ")}`;
return cardsByKey.get(key) ?? null;
}
return BOSS_CARD_CATALOG.find((card) => card.sources.some(
(source) => source.dungeonId === dungeonId && source.entityId === sourceId,
)) ?? BOSS_CARD_CATALOG.find((card) => card.sources.some((source) => source.entityId === sourceId))
+2 -1
View File
@@ -20,7 +20,8 @@ export function dungeonBossCards(
): readonly DungeonBossCard[] {
const seen = new Set<string>();
return dungeonBossesForDifficulty(dungeon, difficultyId).flatMap((boss) => {
const category = dungeon.expansion === "runewaker" ? "rom" : "wow";
const category = /^randomized:(wow|rom):boss:/.exec(boss.id)?.[1]
?? (dungeon.expansion === "runewaker" ? "rom" : "wow");
const normalizedName = boss.name.trim().toLowerCase().replace(/\s+/g, " ");
const fallbackKey = `${category}:${normalizedName}`;
const sourceCard = bossCardForSource(boss.id, dungeon.id, boss.name);
+5 -4
View File
@@ -17,7 +17,7 @@ import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated.
describe("dungeon catalog", () => {
it("lists the complete campaign and bite-sized variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(92);
expect(DUNGEON_CATALOG).toHaveLength(93);
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
id: "stormwind-stockade",
releaseStage: "beta",
@@ -79,9 +79,9 @@ describe("dungeon catalog", () => {
it("keeps randomized replay in the separate randomized Bite-Sized location pool", () => {
expect(randomizedBiteSizedDungeons().map((dungeon) => dungeon.id))
.toEqual(["randomized-bite-sized-stormwind-stockade"]);
.toEqual(["randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade-available"]);
expect(randomRandomizedBiteSizedDungeon("randomized-bite-sized-stormwind-stockade", () => 0)?.id)
.toBe("randomized-bite-sized-stormwind-stockade");
.toBe("randomized-bite-sized-stormwind-stockade-available");
});
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
@@ -111,6 +111,7 @@ describe("dungeon catalog", () => {
"bite-sized-mystic-altar-golem",
"bite-sized-paspers-shrine-hafiz",
"randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"stormwind-stockade",
"bite-sized-stormwind-stockade",
"bite-sized-deadmines-ship",
@@ -142,7 +143,7 @@ describe("dungeon catalog", () => {
.toEqual(["bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
"bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz", "bite-sized-stormwind-stockade",
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns",
"randomized-bite-sized-stormwind-stockade", "forsaken-abbey",
"randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade-available", "forsaken-abbey",
...Object.keys(availability.enabled)].sort());
});
});
+3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentPackId,
dungeonContentSourceId,
@@ -13,6 +14,8 @@ describe("derived dungeon content sources", () => {
expect(dungeonContentPackId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade");
expect(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("activity:randomized-bite-sized");
expect(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID)).toBe("activity:randomized-bite-sized");
expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines");
});
});
+15 -2
View File
@@ -1,6 +1,7 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade-available";
export const BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID = "bite-sized-wailing-caverns";
export const BITE_SIZED_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship";
export const BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID = "bite-sized-utgarde-pinnacle-skadi";
@@ -12,6 +13,7 @@ export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-s
const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
@@ -32,6 +34,7 @@ export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID]: "wailing-caverns",
[BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines",
[BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle",
@@ -42,7 +45,17 @@ const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.free
export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
return dungeonId === BITE_SIZED_STOCKADES_DUNGEON_ID
|| dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID;
|| isRandomizedBiteSizedDungeonId(dungeonId);
}
export function isDerivedRandomBossDungeonId(dungeonId: string): boolean {
return isDerivedStockadesDungeonId(dungeonId)
|| dungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
|| dungeonId === BITE_SIZED_DEADMINES_DUNGEON_ID
|| dungeonId === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID
|| dungeonId === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID
|| dungeonId === BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID
|| dungeonId === BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID;
}
/** Derived dungeon variants share the installed assets of their source dungeon. */
@@ -51,7 +64,7 @@ export function dungeonContentSourceId(dungeonId: string): string {
}
export function dungeonContentPackId(dungeonId: string): string {
if (dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
if (isRandomizedBiteSizedDungeonId(dungeonId)) {
return RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID;
}
return `dungeon:${dungeonContentSourceId(dungeonId)}`;
+29 -14
View File
@@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";
import { CLASSES } from "../app/characterCatalog";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import { dungeonBossesForDifficulty, finalDungeonBoss } from "./dungeonRunStore";
import { isDerivedStockadesDungeonId, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
import { dungeonContentSourceId, isDerivedRandomBossDungeonId, isDerivedStockadesDungeonId,
STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades";
import { AVAILABLE_RANDOMIZED_CREATURE_CATALOG, BITE_SIZED_RANDOMIZED_CREATURE_CATALOG } from "./dungeonRegistry";
import { RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
const stockades = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === STORMWIND_STOCKADE_DUNGEON_ID)!;
const biteSizedBosses = BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => (
@@ -14,28 +17,40 @@ const biteSizedBosses = BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) =>
describe("dungeon leaderboard catalog", () => {
it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => {
const catalog = {
dungeons: DUNGEON_DEFINITIONS.map((dungeon) => ({ id: dungeon.id, title: dungeon.title,
finalBossId: isDerivedStockadesDungeonId(dungeon.id)
? biteSizedBosses[0].id
dungeons: DUNGEON_DEFINITIONS.map((dungeon) => {
const randomizedCandidates = dungeon.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
? AVAILABLE_RANDOMIZED_CREATURE_CATALOG
: dungeon.id === RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID
? BITE_SIZED_RANDOMIZED_CREATURE_CATALOG : null;
const candidates = randomizedCandidates
? randomizedCandidates.filter((candidate) => candidate.kind === "boss").map((candidate) => ({
id: `randomized:${candidate.key}`, name: candidate.name,
}))
: isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses
: isDerivedRandomBossDungeonId(dungeon.id)
? DUNGEON_DEFINITIONS.find((source) => source.id === dungeonContentSourceId(dungeon.id))!.bosses
: dungeon.bosses;
return { id: dungeon.id, title: dungeon.title,
finalBossId: isDerivedRandomBossDungeonId(dungeon.id)
? candidates[0].id
: finalDungeonBoss(dungeon)?.id ?? "",
bosses: (isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses : dungeon.bosses)
.map((boss) => ({ id: boss.id, name: boss.name })),
bosses: candidates.map((boss) => ({ id: boss.id, name: boss.name })),
difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty,
bossIds: isDerivedStockadesDungeonId(dungeon.id)
? biteSizedBosses.map((boss) => boss.id)
bossIds: isDerivedRandomBossDungeonId(dungeon.id)
? candidates.map((boss) => boss.id)
: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
finalBossId: isDerivedStockadesDungeonId(dungeon.id)
? biteSizedBosses[0].id
finalBossId: isDerivedRandomBossDungeonId(dungeon.id)
? candidates[0].id
: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "",
...(isDerivedStockadesDungeonId(dungeon.id)
? { finalBossIds: biteSizedBosses.map((boss) => boss.id) }
...(isDerivedRandomBossDungeonId(dungeon.id)
? { finalBossIds: candidates.map((boss) => boss.id) }
: {}),
})),
defaultDifficultyId: dungeon.defaultDifficultyId,
})).sort((a, b) => a.id.localeCompare(b.id)),
}; }).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
};
expect(catalog.dungeons).toHaveLength(92);
expect(catalog.dungeons).toHaveLength(93);
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
+15
View File
@@ -0,0 +1,15 @@
import generated from "./generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonLeaderboardDifficultyRule } from "./dungeonLeaderboardRules";
export interface DungeonLeaderboardCatalogEntry {
readonly id: string;
readonly title: string;
readonly defaultDifficultyId: string;
readonly bosses: readonly { readonly id: string; readonly name: string }[];
readonly difficulties: readonly (DungeonLeaderboardDifficultyRule & {
readonly id: string;
readonly name: string;
})[];
}
export const dungeonLeaderboardCatalog = generated as { readonly dungeons: readonly DungeonLeaderboardCatalogEntry[] };
+5
View File
@@ -8,6 +8,11 @@ export function leaderboardFinalBossIds(rule: DungeonLeaderboardDifficultyRule):
return rule.finalBossIds?.length ? rule.finalBossIds : [rule.finalBossId];
}
export function leaderboardRunBossId(rule: DungeonLeaderboardDifficultyRule,
killedBossIds: readonly string[]): string | null {
return leaderboardFinalBossIds(rule).find((id) => killedBossIds.includes(id)) ?? null;
}
export function isLeaderboardFullClear(
rule: DungeonLeaderboardDifficultyRule,
killedBossIds: readonly string[],
+56 -14
View File
@@ -23,9 +23,17 @@ import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCat
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,
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 {
@@ -224,31 +232,61 @@ export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
);
export function randomizedBiteSizedDefinitionForSeed(seed: string): DungeonDefinition {
const AVAILABLE_BITE_SIZED_SOURCE_IDS = new Set([
"stormwind-stockade", "wailing-caverns", "deadmines", "utgarde-pinnacle",
"forsaken-abbey", "paspers-shrine", "mystic-altar",
]);
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,
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
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 biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition);
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition);
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition);
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"];
if (!forsakenAbbeyDefinition || !paspersShrineDefinition || !mysticAltarDefinition) {
throw new Error("The three RoM source dungeons are required for their bite-sized variants.");
}
const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition);
const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition);
const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition);
const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, "catalog-template");
const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, "catalog-template");
const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, "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],
@@ -287,12 +325,16 @@ export function clearBossTestingDungeons(): void {
/** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */
export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null {
if (id !== BITE_SIZED_STOCKADES_DUNGEON_ID && id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
return dungeonDefinitionById(id);
}
const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID
? createBiteSizedStockadesDefinition(stockadesDefinition, seed)
: randomizedBiteSizedDefinitionForSeed(seed);
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_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;
}
+24 -1
View File
@@ -4,7 +4,7 @@ import { NodeIO } from "@gltf-transform/core";
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
import { describe, expect, it } from "vitest";
import { dungeonContentPackId, dungeonContentSourceId } from "../dungeonContentSource";
import { DUNGEON_REGISTRY } from "../dungeonRegistry";
import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry";
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
import { buildPartyNavigationGraph, findNearestPartyNavigationSurface, findPartyNavigationPath } from "../partyNavigation";
@@ -15,6 +15,27 @@ const sliceIds = [
] as const;
describe("RoM bite-sized finales", () => {
it.each(sliceIds)("selects every %s source boss from stable run seeds at the same finale anchor", (id) => {
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
const anchor = DUNGEON_REGISTRY[id].bosses[0].position;
const selected = new Set<string>();
for (let index = 0; index < 500; index += 1) {
const definition = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
const again = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
const boss = definition.bosses[0];
selected.add(boss.id);
expect(again.bosses[0].id).toBe(boss.id);
expect(boss.position).toEqual(anchor);
expect(boss.optional).toBe(false);
expect(definition.encounterSpawnGroups![0].spawns[0].position).toEqual(anchor);
for (const difficulty of definition.difficultyVariants) {
expect((definition.encounterSpawnGroups![0].spawns[0].spawnMask! & difficulty.spawnMask)).not.toBe(0);
}
expect(source.bosses.map((candidate) => candidate.id)).toContain(boss.id);
}
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
});
it.each(sliceIds)("gates %s behind its retained mobs and completes on its boss", (id) => {
const slice = DUNGEON_REGISTRY[id];
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
@@ -45,6 +66,8 @@ describe("RoM bite-sized finales", () => {
expect(completed.phase).toBe("complete");
// The complete source remains available with its original population and objectives.
expect(source.bosses.length).toBeGreaterThan(1);
expect(slice.presentation.ambientLight.intensity).toBeGreaterThan(source.presentation.ambientLight.intensity);
expect(slice.presentation.hemisphereLight.intensity).toBeGreaterThan(source.presentation.hemisphereLight.intensity);
});
it.each(sliceIds)("keeps the %s start and boss reachable on shipped navigation", async (id) => {
+35 -25
View File
@@ -10,6 +10,7 @@ import {
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
} from "../dungeonContentSource";
import { seededBossIndex } from "./seededBossSelection";
interface RoMSlice {
readonly sourceId: string;
@@ -19,7 +20,7 @@ interface RoMSlice {
readonly entranceName: string;
readonly start: Vector3Tuple;
readonly forward: Vector3Tuple;
readonly bossName: string;
readonly finaleBossName: string;
readonly mobSpawnIds: readonly string[];
readonly summary: string;
readonly adaptation: string;
@@ -29,65 +30,71 @@ const ROM_SLICES: readonly RoMSlice[] = [
{
sourceId: "forsaken-abbey",
id: BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
title: "Forsaken Abbey: Ancalon's Approach",
title: "Forsaken Abbey: Final Passage",
location: "Forsaken Abbey · Ancalon's Passage",
entranceName: "Ancalon's Passage",
// The omitted source bat occupied this floor-projected point before the last packs.
start: [330.022888, -19.847076, -34.424903],
forward: [1, 0, 0],
bossName: "Demon Witch Ancalon",
finaleBossName: "Demon Witch Ancalon",
mobSpawnIds: [
"rw-fa-spawn-7855", "rw-fa-spawn-7856",
"rw-fa-spawn-12074", "rw-fa-spawn-12081",
"rw-fa-spawn-12071", "rw-fa-spawn-12072",
],
summary: "Clear the shadows and followers in the final passage, then defeat Demon Witch Ancalon.",
summary: "Clear the shadows and followers in the final passage, then defeat one surprise Abbey boss.",
adaptation: "The earlier keepers, side rooms, patrol script, and deferred keeper doors are outside this short run.",
},
{
sourceId: "paspers-shrine",
id: BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
title: "Paspers' Shrine: Hafiz's Hall",
title: "Paspers' Shrine: Final Hall",
location: "Paspers' Shrine · Hafiz's Hall",
entranceName: "Hafiz's Hall",
// The omitted source axe soldier occupied this projected point before the final corridor.
start: [95.618457, 0.348065, -285.473267],
forward: [1, 0, 0],
bossName: "Blackhorn Hafiz",
finaleBossName: "Blackhorn Hafiz",
mobSpawnIds: [
"rw-paspers-shrine-spawn-5943704", "rw-paspers-shrine-spawn-5943709",
"rw-paspers-shrine-spawn-5943711", "rw-paspers-shrine-spawn-5943712",
"rw-paspers-shrine-spawn-5943715", "rw-paspers-shrine-spawn-5943716",
],
summary: "Defeat the Blackhorn guards along the last corridor, then confront Blackhorn Hafiz.",
summary: "Defeat the Blackhorn guards along the last corridor, then confront one surprise Shrine boss.",
adaptation: "The other Blackhorn bosses and noncombat source interactions are outside this short run.",
},
{
sourceId: "mystic-altar",
id: BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
title: "Mystic Altar: Golem's Antechamber",
title: "Mystic Altar: Final Antechamber",
location: "Mystic Altar · Golem's Antechamber",
entranceName: "Golem's Antechamber",
start: [-392.475647, -17.665024, 329],
forward: [-1, 0, 0],
bossName: "Cursed lron Golem",
finaleBossName: "Cursed lron Golem",
mobSpawnIds: [
"rw-mystic-altar-spawn-13463", "rw-mystic-altar-spawn-13465",
"rw-mystic-altar-spawn-13244", "rw-mystic-altar-spawn-13245",
"rw-mystic-altar-spawn-13248", "rw-mystic-altar-spawn-13332",
],
summary: "Clear the guards, beetles, and Morcus beasts before the Cursed Iron Golem.",
summary: "Clear the guards, beetles, and Morcus beasts before one surprise Altar boss.",
adaptation: "Dorlos, the earlier altar route, and the remaining beetle swarm are outside this short run.",
},
];
function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefinition {
function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string): DungeonDefinition {
if (source.id !== slice.sourceId) throw new Error(`${slice.id} requires ${slice.sourceId}.`);
const boss = source.objectiveOrder.find((objective) => objective.name === slice.bossName);
const bossSpawn = boss && source.staticSpawns.find((spawn) => spawn.id === boss.id);
if (!boss || !bossSpawn || source.entities[bossSpawn.entityId]?.kind !== "boss") {
throw new Error(`${slice.sourceId} is missing ${slice.bossName}.`);
const finale = source.objectiveOrder.find((objective) => objective.name === slice.finaleBossName);
if (!finale || source.bosses.length === 0) {
throw new Error(`${slice.sourceId} is missing ${slice.finaleBossName} or its boss list.`);
}
const boss = source.bosses[seededBossIndex(seed, source.bosses.length)];
const sourceBossSpawn = source.staticSpawns.find((spawn) => spawn.id === boss.id);
if (!sourceBossSpawn || source.entities[sourceBossSpawn.entityId]?.kind !== "boss") {
throw new Error(`${slice.sourceId} is missing source boss ${boss.name}.`);
}
const bossSpawn = { ...sourceBossSpawn, position: finale.position,
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
const mobSpawns = slice.mobSpawnIds.map((id) => {
const spawn = source.staticSpawns.find((candidate) => candidate.id === id);
if (!spawn || source.entities[spawn.entityId]?.kind === "boss" || !source.entities[spawn.entityId]) {
@@ -110,7 +117,7 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedRoMDungeons.ts"],
reviewedBehavior: slice.summary,
adaptations: [slice.adaptation, "The boss appears after the retained source mobs are defeated."],
adaptations: [slice.adaptation, "One source boss is selected from the run id and appears in the finale room after the retained source mobs are defeated."],
remainingGaps: ["The start, party route, combat, boss activation, and completion need an in-game pass."],
},
rules: [
@@ -136,12 +143,12 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
},
],
};
const objective: DungeonBossObjective = { ...boss, eventOnly: true };
const objective: DungeonBossObjective = { ...boss, position: finale.position, optional: false, eventOnly: true };
const entityIds = new Set([...mobSpawns, bossSpawn].map((spawn) => spawn.entityId));
const entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id)));
const areas = [
{ id: "entrance", name: slice.entranceName, center: slice.start, radius: 22 },
{ id: "boss-room", name: slice.bossName, center: boss.position, radius: 32 },
{ id: "boss-room", name: `${boss.name}'s Room`, center: finale.position, radius: 32 },
];
return {
@@ -153,7 +160,10 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
yaw: Math.atan2(slice.forward[0], slice.forward[2]) },
presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
unchartedAreaName: slice.entranceName },
unchartedAreaName: slice.entranceName,
ambientLight: { color: "#e2dccc", intensity: 0.9 },
hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: 1.2 },
directionalLight: { ...source.presentation.directionalLight, color: "#fff0d9", intensity: 1.35 } },
areas,
entities,
staticSpawns: mobSpawns,
@@ -169,14 +179,14 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
};
}
export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[0]);
export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[0], seed);
}
export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[1]);
export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[1], seed);
}
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[2]);
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[2], seed);
}
+2 -10
View File
@@ -5,6 +5,7 @@ import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
} from "../dungeonContentSource";
import { seededBossIndex } from "./seededBossSelection";
const BOSS_ROOM_POSITION = [-159.582, -25.6062, 1.2531] as const;
const CENTRAL_CORRIDOR_HALF_WIDTH = 15;
@@ -22,17 +23,8 @@ export const BITE_SIZED_STOCKADES_BOSS_CANDIDATES: readonly BiteSizedStockadesBo
{ sourceSpawnId: "creature-79116", name: "Dextren Ward" },
]);
function seedHash(seed: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function biteSizedStockadesBossIndex(seed: string): number {
return seedHash(seed) % BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length;
return seededBossIndex(seed, BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length);
}
function branchWall(id: string, name: string, z: number): DungeonWorldObjectDefinition {
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { dungeonContentSourceId } from "../dungeonContentSource";
import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry";
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
import { finalDungeonBoss } from "../dungeonRunStore";
const ids = ["bite-sized-wailing-caverns", "bite-sized-deadmines-ship",
"bite-sized-utgarde-pinnacle-skadi"] as const;
describe("WoW bite-sized seeded bosses", () => {
it.each(ids)("selects every %s source boss at a stable finale anchor", (id) => {
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
const anchor = DUNGEON_REGISTRY[id].bosses[0].position;
const selected = new Set<string>();
for (let index = 0; index < 500; index += 1) {
const seed = `wow-slice-${index}`;
const definition = prepareDungeonSessionDefinition(id, seed)!;
const boss = definition.bosses[0];
const spawn = definition.encounterSpawnGroups![0].spawns[0];
selected.add(boss.id);
expect(prepareDungeonSessionDefinition(id, seed)!.bosses[0].id).toBe(boss.id);
expect(boss.position).toEqual(anchor);
expect(boss.eventOnly).toBe(true);
expect(boss.optional).toBe(false);
expect(spawn.id).toBe(boss.id);
expect(spawn.position).toEqual(anchor);
expect(definition.entities[spawn.entityId].kind).toBe("boss");
expect(finalDungeonBoss(definition)?.id).toBe(boss.id);
expect(definition.staticSpawns.every((entry) => definition.entities[entry.entityId].kind !== "boss")).toBe(true);
}
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
});
it.each(ids)("gates the %s boss on retained mobs and completes on its death", (id) => {
const definition = prepareDungeonSessionDefinition(id, "encounter-run")!;
const encounter = definition.encounters![0];
const mobs = definition.staticSpawns.map((spawn) => spawn.id);
expect(mobs.length).toBeGreaterThan(0);
let snapshot = createDungeonEncounterSnapshot(encounter);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 100,
facts: { registeredEntityIds: mobs, deadEntityIds: mobs.slice(0, -1) } }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([]);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 200,
facts: { registeredEntityIds: mobs, deadEntityIds: mobs } }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([definition.encounterSpawnGroups![0].id]);
const bossId = definition.bosses[0].id;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 300,
facts: { registeredEntityIds: [bossId], deadEntityIds: [bossId] } }).snapshot;
expect(snapshot.completedObjectiveIds).toContain(bossId);
expect(snapshot.flags["dungeon-complete"]).toBe(true);
});
});
+65 -92
View File
@@ -18,6 +18,7 @@ import {
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../dungeonContentSource";
import { finaleBossSpawn, seededSourceBoss } from "./sourceBossCandidates";
const DEADMINES_ROUTE_WIDTH = 34;
function normalizedName(value: string): string {
@@ -39,30 +40,6 @@ function distanceToRoute(point: Vector3Tuple, route: readonly Vector3Tuple[]): n
return Math.min(...route.slice(1).map((end, index) => distanceToSegment(point, route[index], end)));
}
function routeSpawns(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly StaticMobSpawnDefinition[] {
return source.staticSpawns.filter((spawn) => (
source.entities[spawn.entityId]?.kind !== "boss"
&& distanceToRoute(spawn.position, route) <= width
));
}
function routeRoamingPacks(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly MobPackDefinition[] {
return source.roamingPacks.flatMap((pack) => {
const members = pack.members.filter((member) => source.entities[member.entityId]?.kind !== "boss");
return members.length > 0 && pack.waypoints.every((point) => distanceToRoute(point, route) <= width * 1.7)
? [{ ...pack, members }]
: [];
});
}
function entitiesFor(
source: DungeonDefinition,
staticSpawns: readonly StaticMobSpawnDefinition[],
@@ -120,9 +97,11 @@ function appendPrototypeWarning(source: DungeonDefinition, note: string): Dungeo
};
}
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition): DungeonDefinition {
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon.");
const verdan = bossByName(source, "Verdan the Everliving");
const selected = seededSourceBoss(source, seed);
const bossSpawn = finaleBossSpawn(source, selected, verdan.objective.position);
const startPosition: Vector3Tuple = [82, -30.92, 8];
// These points sit on the shipped Wailing Caverns navigation surface in Verdan's chamber.
const staticSpawns: readonly StaticMobSpawnDefinition[] = [
@@ -133,7 +112,7 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
const verdanGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan`;
const verdanGroup: DungeonEncounterSpawnGroupDefinition = {
id: verdanGroupId,
spawns: [verdan.spawn],
spawns: [bossSpawn],
};
const encounter: DungeonEncounterDefinition = {
id: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan-room`,
@@ -145,11 +124,11 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/dungeons/wailingCaverns.ts"],
reviewedBehavior: "Clear three mobs in Verdan's chamber, then defeat Verdan the Everliving.",
reviewedBehavior: "Clear three mobs in Verdan's chamber, then defeat one source Wailing Caverns boss selected by the run ID.",
adaptations: [
"The Fanglords, Naralex escort, and Mutanus event are outside this short run.",
"The room mobs use source Wailing Caverns creatures at floor-checked chamber positions.",
"Verdan is held until the room mobs are defeated.",
"The selected boss is held at Verdan's finale anchor until the room mobs are defeated.",
],
remainingGaps: ["The room encounter still needs an in-game combat and completion pass."],
},
@@ -165,18 +144,19 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
},
{
id: "verdan-room-boss-defeated",
trigger: { kind: "entities-dead", entityIds: [verdan.spawn.id] },
trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
guards: [{ kind: "phase", phases: ["verdan"] }],
actions: runCompletionActions("verdan-room", verdan.objective.id),
actions: runCompletionActions("verdan-room", selected.objective.id),
},
],
};
const finalObjective: DungeonBossObjective = { ...verdan.objective, eventOnly: true };
const finalObjective: DungeonBossObjective = { ...selected.objective, position: verdan.objective.position,
optional: false, eventOnly: true };
return {
...source,
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
title: "Wailing Caverns: Verdan’s Lair",
title: "Wailing Caverns: Everliving Chamber",
lfgDungeonIds: [],
entrance: {
...source.entrance,
@@ -189,7 +169,7 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
...source.presentation,
location: "Wailing Caverns · Verdan’s Chamber",
theme: "Bite-Sized Dungeon",
summary: "Clear the creatures in Verdan’s chamber, then defeat Verdan the Everliving.",
summary: "Clear the chamber creatures, then defeat one surprise Wailing Caverns boss.",
loadingMessage: "Entering Verdan’s chamber...",
unchartedAreaName: "Crag of the Everliving",
},
@@ -212,76 +192,72 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
};
}
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): DungeonDefinition {
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon.");
const smite = bossByName(source, "Mr. Smite");
const captain = bossByName(source, "Captain Greenskin");
const finalBoss = bossByName(source, "Edwin VanCleef");
const startPosition = smite.spawn.position;
const selected = seededSourceBoss(source, seed);
const startPosition = captain.spawn.position;
const route: readonly Vector3Tuple[] = [
startPosition,
[-807, 30, -42],
captain.objective.position,
finalBoss.objective.position,
];
const routeTrash = routeSpawns(source, route, DEADMINES_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, DEADMINES_ROUTE_WIDTH);
const vanCleefSpawn: StaticMobSpawnDefinition = {
...finalBoss.spawn,
position: finalBoss.objective.position,
};
const routeTrash = ["creature-79334", "creature-79335", "creature-79369", "creature-79370"].map((id) => {
const spawn = source.staticSpawns.find((entry) => entry.id === id);
if (!spawn || source.entities[spawn.entityId]?.kind !== "mob") {
throw new Error(`The Deadmines final deck is missing source mob ${id}.`);
}
return spawn;
});
const bossSpawn = finaleBossSpawn(source, selected, finalBoss.objective.position);
const vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`;
const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = {
id: vanCleefGroupId,
spawns: [vanCleefSpawn],
spawns: [bossSpawn],
};
const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`;
const encounter: DungeonEncounterDefinition = {
id: encounterId,
dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID,
initialPhase: "greenskin",
initialPhase: "ship-mobs",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "greenskin", checkpointPhase: "greenskin" },
resetPolicy: { bossWipePhase: "ship-mobs", checkpointPhase: "ship-mobs" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"],
reviewedBehavior: "Start on Mr. Smite's floor-snapped deck position, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his cabin.",
reviewedBehavior: "Start on Greenskin's deck, clear two Defias guard pairs, then confront one seeded Deadmines boss in VanCleef's cabin.",
adaptations: [
"The mine, foundry, Cookie branch, and other Deadmines bosses are omitted from this run.",
"VanCleef is held in an encounter spawn group until Greenskin is defeated.",
"Source boss IDs are retained for objective records and loot lookup.",
"The mine, foundry, lower ship decks, and Cookie branch are omitted from this run.",
"One boss from the complete Deadmines boss list appears in the cabin after the retained crew die.",
"The selected source boss ID is retained for objective records and loot lookup.",
],
remainingGaps: [
"The ship route boundary and the optional Cookie branch need live traversal review; this first pass filters population by a spatial corridor.",
"Original boss-specific scripted actions beyond their imported combat profiles are not included in this two-boss slice.",
"The final-deck approach and party traversal still need a live combat pass.",
"Original boss-specific scripted actions beyond their imported combat profiles are not included in this short slice.",
],
},
rules: [
{
id: "ship-greenskin-defeated",
trigger: { kind: "entities-dead", entityIds: [captain.objective.id] },
guards: [{ kind: "phase", phases: ["greenskin"] }],
id: "ship-crew-defeated",
trigger: { kind: "entities-dead", entityIds: routeTrash.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: ["ship-mobs"] }],
actions: [
{ id: "ship-complete-greenskin", kind: "complete-objective", objectiveId: captain.objective.id, persistence: "run" },
{ id: "ship-checkpoint-greenskin", kind: "set-checkpoint", checkpointId: captain.objective.id, persistence: "checkpoint" },
{ id: "ship-activate-vancleef", kind: "activate-spawn-group", spawnGroupId: vanCleefGroupId },
{ id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "van-cleef" },
{ id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "boss" },
],
},
{
id: "ship-vancleef-defeated",
trigger: { kind: "entities-dead", entityIds: [vanCleefSpawn.id] },
guards: [{ kind: "phase", phases: ["van-cleef"] }],
actions: runCompletionActions("ship-complete-vancleef", finalBoss.objective.id),
trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
guards: [{ kind: "phase", phases: ["boss"] }],
actions: runCompletionActions("ship-complete-vancleef", selected.objective.id),
},
],
};
const encounterGroups = [vanCleefGroup];
const objectives: readonly DungeonBossObjective[] = [
captain.objective,
{ ...finalBoss.objective, eventOnly: true },
];
const objective: DungeonBossObjective = { ...selected.objective, position: finalBoss.objective.position,
optional: false, eventOnly: true };
return {
...source,
id: BITE_SIZED_DEADMINES_DUNGEON_ID,
@@ -289,29 +265,28 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Mr. Smite’s Deck",
name: "Greenskin’s Deck",
footPosition: startPosition,
forward: [-0.5, 0, -0.866],
yaw: -2.618,
forward: [0, 0, -1],
yaw: Math.PI,
},
presentation: {
...source.presentation,
location: "The Deadmines · The Defias Ship",
theme: "Bite-Sized Dungeon",
summary: "Board the Defias ship, push through the crew, defeat Captain Greenskin, and finish VanCleef in the cabin.",
summary: "Clear two Defias guard pairs on the final deck, then face one surprise Deadmines boss in the cabin.",
loadingMessage: "Boarding the Defias ship...",
unchartedAreaName: "Defias Ship",
},
areas: [
routeArea("entrance", "Mr. Smite’s Deck", startPosition, 25),
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32),
routeArea("entrance", "Greenskin’s Deck", startPosition, 32),
routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28),
],
entities: entitiesFor(source, routeTrash, roamingPacks, encounterGroups, [captain.spawn.entityId]),
staticSpawns: [...routeTrash, captain.spawn],
roamingPacks,
bosses: objectives,
objectiveOrder: objectives,
entities: entitiesFor(source, routeTrash, [], encounterGroups),
staticSpawns: routeTrash,
roamingPacks: [],
bosses: [objective],
objectiveOrder: [objective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
@@ -320,12 +295,12 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
)),
validation: appendPrototypeWarning(
source,
"The Deadmines ship route and two-boss progression are Healer Man-authored; source ship geometry, crew, Greenskin, and VanCleef assets are shared with The Deadmines.",
"The Deadmines ship route and one-boss progression are Healer Man-authored; source ship geometry, crew, and boss assets are shared with The Deadmines.",
),
};
}
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition): DungeonDefinition {
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
if (source.id !== "utgarde-pinnacle") throw new Error("The Skadi slice requires Utgarde Pinnacle.");
const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless");
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
@@ -333,6 +308,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[7]) {
throw new Error("Utgarde Pinnacle is missing the Skadi room content.");
}
const selected = seededSourceBoss(source, seed);
// The source start object is across a collision gap from the Skadi corridor.
const startPosition: Vector3Tuple = [-511, 104.8, 370];
const bossPosition = firstWave.spawns[0].position;
@@ -341,14 +317,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
firstWave.spawns[6],
firstWave.spawns[5],
];
const skadiSpawn: StaticMobSpawnDefinition = {
...sourceSkadiGroup.spawns[0],
id: skadi.id,
position: bossPosition,
};
const bossSpawn = finaleBossSpawn(source, selected, bossPosition);
const skadiGroup: DungeonEncounterSpawnGroupDefinition = {
...sourceSkadiGroup,
spawns: [skadiSpawn],
spawns: [bossSpawn],
};
const encounter: DungeonEncounterDefinition = {
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-room`,
@@ -360,10 +332,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/wowWrathEncounterBatchTwoCatalog.ts"],
reviewedBehavior: "Clear three source wave guards along the walkable Skadi corridor, then confront Skadi at its end.",
reviewedBehavior: "Clear three source wave guards along the walkable Skadi corridor, then confront one seeded Utgarde Pinnacle boss at its end.",
adaptations: [
"The start interaction, flying phase, harpoons, and long wave sequence are omitted from this short run.",
"Skadi uses the source boss combat data and objective ID at a floor-snapped source wave position at the corridor's end.",
"The selected source boss uses its combat data and objective ID at a floor-snapped source wave position at the corridor's end.",
],
remainingGaps: ["The shortened encounter still needs an in-game combat and completion pass."],
},
@@ -379,18 +351,19 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
},
{
id: "skadi-room-boss-defeated",
trigger: { kind: "entities-dead", entityIds: [skadiSpawn.id] },
trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
guards: [{ kind: "phase", phases: ["skadi"] }],
actions: runCompletionActions("skadi-room", skadi.id),
actions: runCompletionActions("skadi-room", selected.objective.id),
},
],
};
const objective: DungeonBossObjective = { ...skadi, position: bossPosition, eventOnly: true };
const objective: DungeonBossObjective = { ...selected.objective, position: bossPosition,
optional: false, eventOnly: true };
return {
...source,
id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
title: "Utgarde Pinnacle: Skadi’s Gauntlet",
title: "Utgarde Pinnacle: Eagle’s Eye Gauntlet",
lfgDungeonIds: [],
entrance: {
...source.entrance,
@@ -403,7 +376,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
...source.presentation,
location: "Utgarde Pinnacle · Eagle’s Eye",
theme: "Bite-Sized Dungeon",
summary: "Defeat the three guards on Eagle’s Eye, then fight Skadi on the approach.",
summary: "Defeat the three guards on Eagle’s Eye, then fight one surprise Utgarde Pinnacle boss.",
loadingMessage: "Entering Skadi’s gauntlet...",
unchartedAreaName: "Eagle’s Eye",
},
@@ -2,6 +2,7 @@ import type { DungeonDefinition } from "../dungeonTypes";
import type { PopulationEntityDefinition } from "../mobPopulation";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./biteSizedStockades";
import { seededBossIndex } from "./seededBossSelection";
export type RandomizedCreatureKind = "mob" | "boss";
@@ -114,11 +115,7 @@ function dealMobs(
}
function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntityDefinition {
// Boss source ids remain intact so their existing loot table and collection
// card can still resolve. Trash ids are namespaced away from the location.
const id = candidate.kind === "boss"
? candidate.entityId
: `randomized:${candidate.key}`;
const id = `randomized:${candidate.key}`;
const combat = candidate.definition.combat ? {
...candidate.definition.combat,
// The identity and attacks are randomized, while durability and pressure
@@ -130,58 +127,59 @@ function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntity
}
/**
* Keeps the reviewed Stockades geometry and navigation, then deals a different
* cross-dungeon creature to every corridor slot and rolls the boss separately.
* Keeps the reviewed Stockades geometry and navigation, then deals creatures
* and a boss from the selected cross-dungeon pool.
*/
export function createRandomizedBiteSizedStockadesDefinition(
source: DungeonDefinition,
seed: string,
catalog: readonly RandomizedBiteSizedCreature[],
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
): DungeonDefinition {
const base = createBiteSizedStockadesDefinition(source, `${seed}:objective-slot`);
const bossSpawnId = base.bosses[0]?.id;
if (!bossSpawnId) throw new Error("Randomized Bite-Sized Stockades has no final boss slot.");
const bossSpawn = base.staticSpawns.find((spawn) => spawn.id === bossSpawnId);
if (!bossSpawn) throw new Error("Randomized Bite-Sized Stockades has no final boss slot.");
const trashSpawns = base.staticSpawns.filter((spawn) => spawn.id !== bossSpawnId);
const mobPool = catalog.filter((candidate) => candidate.kind === "mob");
if (mobPool.length < trashSpawns.length) {
throw new Error(`Randomized Bite-Sized Stockades needs ${trashSpawns.length} distinct mob candidates.`);
}
const mobCandidates = dealMobs(source, trashSpawns, mobPool, `${seed}:trash`);
const originalBossSpawn = source.staticSpawns.find((spawn) => spawn.id === bossSpawnId);
const originalBoss = originalBossSpawn ? source.entities[originalBossSpawn.entityId] : undefined;
const originalBossKey = originalBoss ? creatureKey(source, originalBoss) : null;
const bossCandidate = seededShuffle(
catalog.filter((candidate) => candidate.kind === "boss"),
`${seed}:boss`,
).find((candidate) => candidate.key !== originalBossKey);
if (!bossCandidate) throw new Error("Randomized Bite-Sized Stockades has no boss candidates.");
const bossPool = catalog.filter((candidate) => candidate.kind === "boss");
if (!bossPool.length) throw new Error("Randomized Bite-Sized Stockades needs a boss candidate.");
const bossCandidate = bossPool[seededBossIndex(`${seed}:boss`, bossPool.length)];
const selectedBoss = { ...runtimeEntity(bossCandidate), id: bossCandidate.entityId };
const selectedBossId = `randomized:${bossCandidate.key}`;
const selectedMobs = new Map(trashSpawns.map((spawn, index) => [spawn.id, mobCandidates[index]]));
const bossEntity = runtimeEntity(bossCandidate);
const randomizedSpawns = base.staticSpawns.map((spawn) => {
const candidate = selectedMobs.get(spawn.id);
return { ...spawn, entityId: candidate ? runtimeEntity(candidate).id : bossEntity.id };
return candidate ? { ...spawn, entityId: runtimeEntity(candidate).id }
: spawn.id === bossSpawnId ? { ...spawn, id: selectedBossId, entityId: selectedBoss.id } : spawn;
});
const selectedCandidates = [...selectedMobs.values(), bossCandidate];
const entities = Object.fromEntries(selectedCandidates.map((candidate) => {
const randomizedEntities = Object.fromEntries([...selectedMobs.values()].map((candidate) => {
const entity = runtimeEntity(candidate);
return [entity.id, entity];
})) as Readonly<Record<string, PopulationEntityDefinition>>;
const boss = { ...base.bosses[0], name: bossCandidate.name };
const entities = { ...randomizedEntities, [selectedBoss.id]: selectedBoss };
const boss = { ...base.bosses[0], id: selectedBossId, name: selectedBoss.name };
return {
...base,
id: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
title: "Randomized Bite-Sized: Cellblock Shuffle",
id: dungeonId,
title: dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
? "Randomized Bite-Sized: All Available"
: "Randomized Bite-Sized: Bite-Sized Sources",
presentation: {
...base.presentation,
location: "Stormwind Stockade · Shifting Cellblock",
theme: "Randomized Bite-Sized",
summary: "Every enemy slot is dealt a distinct cross-dungeon creature, followed by an independently randomized boss.",
summary: "Every corridor slot and the final boss are selected from the chosen cross-dungeon creature pool.",
loadingMessage: "Choosing a location and shuffling every creature slot...",
},
areas: base.areas.map((area) => area.id === "bite-sized-boss-room"
? { ...area, name: `${bossCandidate.name}'s Cell` }
? { ...area, name: `${boss.name}'s Cell` }
: area),
entities,
staticSpawns: randomizedSpawns,
@@ -189,16 +187,26 @@ export function createRandomizedBiteSizedStockadesDefinition(
objectiveOrder: [boss],
encounters: (base.encounters ?? []).map((encounter) => ({
...encounter,
id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
id: `${dungeonId}:boss-progression`,
dungeonId,
rules: encounter.rules.map((rule) => ({
...rule,
trigger: rule.trigger.kind === "entities-dead"
? { ...rule.trigger, entityIds: [selectedBossId] } : rule.trigger,
guards: rule.guards?.map((guard) => guard.kind === "objective-complete"
? { ...guard, objectiveId: selectedBossId } : guard),
actions: rule.actions.map((action) => action.kind === "complete-objective"
? { ...action, objectiveId: selectedBossId }
: action.kind === "set-checkpoint" ? { ...action, checkpointId: selectedBossId } : action),
})),
source: encounter.source ? {
...encounter.source,
files: ["src/game/dungeons/randomizedBiteSizedStockades.ts"],
reviewedBehavior: "Every corridor slot receives a distinct seeded creature and the final boss uses an independent seeded roll.",
reviewedBehavior: "Every corridor slot and the final boss receive seeded creatures from the selected cross-dungeon pool.",
adaptations: [
...encounter.source.adaptations,
"The trash shuffle deals without replacement from the complete model-backed cross-dungeon mob catalog, so one run cannot repeat a mob identity.",
"The final boss is rolled independently from the complete model-backed cross-dungeon boss catalog while retaining the location's stable completion objective.",
"The final boss uses a namespaced creature key as its objective and leaderboard identity, with source combat and visuals.",
"Randomized combat multipliers are normalized before party-level scaling so source-dungeon tuning cannot overwhelm a short leveling run.",
],
} : encounter.source,
+9
View File
@@ -0,0 +1,9 @@
/** A run id selects one stable boss for saves and every member of a shared party. */
export function seededBossIndex(seed: string, candidateCount: number): number {
let hash = 0x811c9dc5;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) % candidateCount;
}
+49
View File
@@ -0,0 +1,49 @@
import type { DungeonBossObjective, DungeonDefinition } from "../dungeonTypes";
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "../mobPopulation";
import { seededBossIndex } from "./seededBossSelection";
export interface SourceBossCandidate {
readonly objective: DungeonBossObjective;
readonly spawn: StaticMobSpawnDefinition;
readonly entity: PopulationEntityDefinition;
}
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
/** Resolves ordinary, event, and roaming source bosses into a reusable one-boss spawn. */
export function sourceBossCandidates(source: DungeonDefinition): readonly SourceBossCandidate[] {
return source.bosses.map((objective) => {
const namedBoss = (entityId: string) => {
const entity = source.entities[entityId];
return entity?.kind === "boss" && normalizedName(entity.name) === normalizedName(objective.name);
};
const staticSpawn = source.staticSpawns.find((spawn) => spawn.id === objective.id
&& source.entities[spawn.entityId]?.kind === "boss");
const groupSpawn = source.encounterSpawnGroups?.flatMap((group) => group.spawns)
.filter((spawn) => namedBoss(spawn.entityId)).at(-1);
const roamingMember = source.roamingPacks.flatMap((pack) => pack.members)
.find((member) => namedBoss(member.entityId));
const spawn = staticSpawn ?? groupSpawn ?? (roamingMember ? {
id: objective.id, entityId: roamingMember.entityId, position: objective.position, yaw: 0,
} : undefined);
const entity = spawn && source.entities[spawn.entityId];
if (!spawn || !entity || entity.kind !== "boss") {
throw new Error(`${source.id} is missing a usable source spawn for ${objective.name}.`);
}
return { objective, spawn, entity };
});
}
export function seededSourceBoss(source: DungeonDefinition, seed: string): SourceBossCandidate {
const candidates = sourceBossCandidates(source);
if (!candidates.length) throw new Error(`${source.id} has no source bosses to select.`);
return candidates[seededBossIndex(seed, candidates.length)];
}
export function finaleBossSpawn(source: DungeonDefinition, candidate: SourceBossCandidate,
position: DungeonBossObjective["position"]): StaticMobSpawnDefinition {
return { ...candidate.spawn, id: candidate.objective.id, position,
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
}
File diff suppressed because it is too large. Load diff
+37 -11
View File
@@ -1,13 +1,20 @@
import { describe, expect, it } from "vitest";
import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
} from "./dungeonContentSource";
import {
DUNGEON_REGISTRY,
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
AVAILABLE_RANDOMIZED_CREATURE_CATALOG,
BITE_SIZED_RANDOMIZED_CREATURE_CATALOG,
dungeonDefinitionById,
randomizedBiteSizedDefinitionForSeed,
dungeonCanEnter,
} from "./dungeonRegistry";
import { dungeonLeaderboardCatalog } from "./dungeonLeaderboardCatalog";
import { seededBossIndex } from "./dungeons/seededBossSelection";
import { activateDungeonSession } from "./dungeonSession";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades";
@@ -82,13 +89,12 @@ describe("Randomized Bite-Sized Stockades", () => {
}
});
it("uses independent deterministic trash and boss rolls across run ids", () => {
it("uses deterministic cross-dungeon trash and bosses across run ids", () => {
const first = create("shared-run");
const second = create("shared-run");
expect(second.staticSpawns).toEqual(first.staticSpawns);
expect(second.bosses).toEqual(first.bosses);
const bossEntityIds = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.entityId));
const observedBossEntityIds = new Set<string>();
const observedTrashLineups = new Set<string>();
for (let index = 0; index < 2_000; index += 1) {
@@ -101,28 +107,48 @@ describe("Randomized Bite-Sized Stockades", () => {
.map((spawn) => spawn.entityId)
.join("|"));
}
expect([...observedBossEntityIds].every((entityId) => bossEntityIds.has(entityId))).toBe(true);
expect(observedBossEntityIds.size).toBeGreaterThan(100);
expect([...observedBossEntityIds].every((entityId) => catalog.some((candidate) => candidate.kind === "boss" && candidate.entityId === entityId))).toBe(true);
expect(observedBossEntityIds.size).toBeGreaterThan(50);
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns);
});
it("keeps the randomized boss on the location's stable completion objective", () => {
it("uses the selected cross-dungeon boss for combat and completion", () => {
const randomized = create("random-boss-objective");
const boss = randomized.bosses[0];
const spawn = randomized.staticSpawns.find((candidate) => candidate.id === boss.id)!;
const entity = randomized.entities[spawn.entityId];
const base = createBiteSizedStockadesDefinition(source, "random-boss-objective:objective-slot");
const originalSpawn = base.staticSpawns.find((candidate) => candidate.id === boss.id)!;
const originalBoss = base.entities[originalSpawn.entityId];
const candidate = catalog.find((entry) => `randomized:${entry.key}` === boss.id)!;
expect(entity.kind).toBe("boss");
expect(boss.name).toBe(entity.name);
expect(catalog.some((candidate) => candidate.kind === "boss" && candidate.entityId === spawn.entityId)).toBe(true);
expect(entity.combat?.serverEntry).not.toBe(originalBoss.combat?.serverEntry);
expect(candidate.kind).toBe("boss");
expect(entity.combat?.serverEntry).toBe(candidate.definition.combat?.serverEntry);
expect(randomized.encounters?.[0].rules[0].trigger).toEqual({ kind: "entities-dead", entityIds: [boss.id] });
expect(entity.combat).toMatchObject({ healthMultiplier: 1, damageMultiplier: 1 });
expect(entity.combat?.healthMultiplier).toBe(1);
});
it("keeps both selectable pools available and excludes unavailable source dungeons", () => {
expect(BITE_SIZED_RANDOMIZED_CREATURE_CATALOG.length).toBeGreaterThan(12);
expect(AVAILABLE_RANDOMIZED_CREATURE_CATALOG.length).toBeGreaterThan(BITE_SIZED_RANDOMIZED_CREATURE_CATALOG.length);
const allKeys = new Set(AVAILABLE_RANDOMIZED_CREATURE_CATALOG.map((entry) => entry.key));
expect(BITE_SIZED_RANDOMIZED_CREATURE_CATALOG.every((entry) => allKeys.has(entry.key))).toBe(true);
expect(AVAILABLE_RANDOMIZED_CREATURE_CATALOG.every((entry) => dungeonCanEnter(entry.sourceDungeonId))).toBe(true);
for (const [dungeonId, pool] of [
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, AVAILABLE_RANDOMIZED_CREATURE_CATALOG],
[RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID, BITE_SIZED_RANDOMIZED_CREATURE_CATALOG],
] as const) {
const definition = randomizedBiteSizedDefinitionForSeed("shared-pool-seed", dungeonId);
expect(definition.id).toBe(dungeonId);
expect(definition.staticSpawns.every((spawn) => pool.some((entry) => (
entry.kind === "boss" ? entry.entityId === spawn.entityId : `randomized:${entry.key}` === spawn.entityId
)))).toBe(true);
expect(definition.bosses[0].id).toMatch(/^randomized:(wow|rom):boss:/);
const board = dungeonLeaderboardCatalog.dungeons.find((entry) => entry.id === dungeonId)!;
const finalBossIds = board.difficulties[0].finalBossIds!;
expect(definition.bosses[0].id).toBe(finalBossIds[seededBossIndex("shared-pool-seed:boss", finalBossIds.length)]);
}
});
});