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

+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) };
}