Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 17:51:56 -04:00
1 parent 9c5fb4c614
commit bd073fb00f
113 files changed
+4902 -273

No files matched your search

+485
View File
@@ -0,0 +1,485 @@
import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
Vector3Tuple,
} from "../dungeonTypes";
import type {
DungeonEncounterAction,
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "../dungeonEncounterTypes";
import type {
MobPackDefinition,
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "../mobPopulation";
import type { DungeonWorldObjectDefinition } from "../dungeonWorldObjects";
import {
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../dungeonContentSource";
import {
WAILING_NARALEX_ENCOUNTER,
WAILING_NARALEX_SPAWN_GROUPS,
WAILING_NARALEX_START_POSITION,
WAILING_NARALEX_ESCORT_WAYPOINTS,
} from "../wailingCavernsEncounter";
const WAILING_ROUTE_WIDTH = 36;
const DEADMINES_ROUTE_WIDTH = 34;
const SKADI_ROUTE_WIDTH = 32;
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function distanceToSegment(point: Vector3Tuple, start: Vector3Tuple, end: Vector3Tuple): number {
const direction = end.map((value, axis) => value - start[axis]) as unknown as Vector3Tuple;
const offset = point.map((value, axis) => value - start[axis]) as unknown as Vector3Tuple;
const lengthSquared = direction.reduce((total, value) => total + value * value, 0);
const projection = lengthSquared === 0
? 0
: Math.max(0, Math.min(1, offset.reduce((total, value, axis) => total + value * direction[axis], 0) / lengthSquared));
return Math.hypot(...point.map((value, axis) => value - start[axis] - direction[axis] * projection));
}
function distanceToRoute(point: Vector3Tuple, route: readonly Vector3Tuple[]): number {
if (route.length < 2) return Number.POSITIVE_INFINITY;
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[],
roamingPacks: readonly MobPackDefinition[],
groups: readonly DungeonEncounterSpawnGroupDefinition[],
additionalEntityIds: readonly string[] = [],
): Readonly<Record<string, PopulationEntityDefinition>> {
const usedIds = new Set([
...staticSpawns.map((spawn) => spawn.entityId),
...roamingPacks.flatMap((pack) => pack.members.map((member) => member.entityId)),
...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId)),
...additionalEntityIds,
]);
const entities: Record<string, PopulationEntityDefinition> = {};
for (const entityId of usedIds) {
const entity = source.entities[entityId];
if (!entity) throw new Error(`Bite-sized route references missing entity: ${entityId}`);
entities[entityId] = entity;
}
return entities;
}
function bossByName(source: DungeonDefinition, name: string): {
readonly objective: DungeonBossObjective;
readonly spawn: StaticMobSpawnDefinition;
readonly entity: PopulationEntityDefinition;
} {
const normalized = normalizedName(name);
const objective = source.objectiveOrder.find((candidate) => normalizedName(candidate.name) === normalized);
const spawn = objective && source.staticSpawns.find((candidate) => candidate.id === objective.id);
const entity = spawn && source.entities[spawn.entityId];
if (!objective || !spawn || !entity || entity.kind !== "boss") {
throw new Error(`${source.id} is missing its ${name} boss source.`);
}
return { objective, spawn, entity };
}
function routeArea(id: string, name: string, center: Vector3Tuple, radius = 36) {
return { id, name, center, radius };
}
function runCompletionActions(prefix: string, objectiveId: string): readonly DungeonEncounterAction[] {
return [
{ id: `${prefix}-complete-objective`, kind: "complete-objective", objectiveId, persistence: "run" },
{ id: `${prefix}-checkpoint`, kind: "set-checkpoint", checkpointId: objectiveId, persistence: "checkpoint" },
{ id: `${prefix}-mark-dungeon-complete`, kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: `${prefix}-complete-phase`, kind: "set-phase", phase: "complete", persistence: "run" },
];
}
function appendPrototypeWarning(source: DungeonDefinition, note: string): DungeonDefinition["validation"] {
return {
...source.validation,
warnings: [...source.validation.warnings, note],
};
}
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon.");
const sourceFinalBoss = bossByName(source, "Mutanus the Devourer");
const route = [WAILING_NARALEX_START_POSITION, ...WAILING_NARALEX_ESCORT_WAYPOINTS];
const staticSpawns = routeSpawns(source, route, WAILING_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, WAILING_ROUTE_WIDTH);
const mutanusSpawn: StaticMobSpawnDefinition = {
...sourceFinalBoss.spawn,
position: sourceFinalBoss.objective.position,
};
const mutanusGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:mutanus`;
const mutanusGroup: DungeonEncounterSpawnGroupDefinition = {
id: mutanusGroupId,
spawns: [mutanusSpawn],
};
const encounterId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-escort`;
const encounter: DungeonEncounterDefinition = {
...WAILING_NARALEX_ENCOUNTER,
id: encounterId,
dungeonId: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
initialPhase: "ready",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "ready", checkpointPhase: "ready" },
source: {
...WAILING_NARALEX_ENCOUNTER.source,
reviewedBehavior: "A standalone run begins at the source escort start, follows the Naralex waypoint route, defends the Disciple through the source waves, and finishes with Mutanus.",
adaptations: [
...WAILING_NARALEX_ENCOUNTER.source.adaptations,
"The four Fanglords are skipped; the bite-sized run begins at the actual Naralex escort start.",
"Mutanus is activated by the encounter runtime as the final event spawn in this derived definition.",
],
},
rules: WAILING_NARALEX_ENCOUNTER.rules
.filter((rule) => rule.id !== "fanglords-defeated" && rule.id !== "restore-ready-after-checkpoint")
.map((rule) => {
if (rule.id === "start-escort") {
return {
...rule,
trigger: { kind: "signal", signalId: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-start` },
};
}
if (rule.id === "nightmare-wave-cleared") {
return {
...rule,
actions: [
...rule.actions,
{ id: "activate-mutanus", kind: "activate-spawn-group", spawnGroupId: mutanusGroupId },
],
};
}
return rule;
}),
};
const encounterGroups = [
...WAILING_NARALEX_SPAWN_GROUPS,
mutanusGroup,
];
const finalObjective: DungeonBossObjective = {
...sourceFinalBoss.objective,
eventOnly: true,
};
return {
...source,
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
title: "Wailing Caverns: Naralex’s Rescue",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Disciple of Naralex’s Camp",
footPosition: WAILING_NARALEX_START_POSITION,
forward: [-0.433, 0, 0.902],
yaw: -0.448,
},
presentation: {
...source.presentation,
location: "Wailing Caverns · Naralex’s Route",
theme: "Bite-Sized Dungeon",
summary: "Follow the winding escort route, defend Naralex’s Disciple through the awakening waves, and defeat Mutanus.",
loadingMessage: "Opening the path to the Dreamer’s Rock...",
unchartedAreaName: "Naralex’s Route",
},
areas: [
routeArea("entrance", "Disciple of Naralex’s Camp", WAILING_NARALEX_START_POSITION, 26),
routeArea("naralex-route", "The Winding Caverns", WAILING_NARALEX_ESCORT_WAYPOINTS[12], 64),
routeArea("mutanus", "Mutanus’s Chamber", sourceFinalBoss.objective.position, 42),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, encounterGroups),
staticSpawns,
roamingPacks,
bosses: [finalObjective],
objectiveOrder: [finalObjective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects,
validation: appendPrototypeWarning(
source,
"Naralex’s route corridor and bite-sized objective list are Healer Man-authored; the source cave environment, escort waypoints, wave rosters, and Mutanus combat data are shared with Wailing Caverns.",
),
};
}
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon.");
const captain = bossByName(source, "Captain Greenskin");
const finalBoss = bossByName(source, "Edwin VanCleef");
const route: readonly Vector3Tuple[] = [
[-785, 17, -28],
[-797.2147, 20.4973, -22.8489],
[-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 vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`;
const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = {
id: vanCleefGroupId,
spawns: [vanCleefSpawn],
};
const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`;
const encounter: DungeonEncounterDefinition = {
id: encounterId,
dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID,
initialPhase: "greenskin",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "greenskin", checkpointPhase: "greenskin" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"],
reviewedBehavior: "Start at the ship ramp, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his 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.",
],
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.",
],
},
rules: [
{
id: "ship-greenskin-defeated",
trigger: { kind: "entities-dead", entityIds: [captain.objective.id] },
guards: [{ kind: "phase", phases: ["greenskin"] }],
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-vancleef-defeated",
trigger: { kind: "entities-dead", entityIds: [vanCleefSpawn.id] },
guards: [{ kind: "phase", phases: ["van-cleef"] }],
actions: runCompletionActions("ship-complete-vancleef", finalBoss.objective.id),
},
],
};
const encounterGroups = [vanCleefGroup];
const objectives: readonly DungeonBossObjective[] = [
captain.objective,
{ ...finalBoss.objective, eventOnly: true },
];
const startPosition: Vector3Tuple = [-785, 17, -28];
return {
...source,
id: BITE_SIZED_DEADMINES_DUNGEON_ID,
title: "The Deadmines: Take the Ship",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Mr. Smite’s Ship Ramp",
footPosition: startPosition,
forward: [-0.5, 0, -0.866],
yaw: -2.618,
},
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.",
loadingMessage: "Boarding the Defias ship...",
unchartedAreaName: "Defias Ship",
},
areas: [
routeArea("entrance", "Mr. Smite’s Ship Ramp", startPosition, 25),
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 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,
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects.filter((object) => (
distanceToRoute(object.position, route) <= DEADMINES_ROUTE_WIDTH * 1.7
)),
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.",
),
};
}
const SKADI_GROUP_IDS = new Set([
"utgarde-pinnacle:skadi-first-wave",
"utgarde-pinnacle:skadi-reinforcement-one",
"utgarde-pinnacle:skadi-reinforcement-two",
"utgarde-pinnacle:skadi-ground",
]);
const SKADI_RULE_IDS = new Set([
"utgarde-pinnacle-start-skadi",
"utgarde-pinnacle-first-wave-cleared",
"utgarde-pinnacle-fire-harpoon-one",
"utgarde-pinnacle-reinforcement-one-cleared",
"utgarde-pinnacle-fire-harpoon-two",
"utgarde-pinnacle-reinforcement-two-cleared",
"utgarde-pinnacle-fire-harpoon-three",
"utgarde-pinnacle-skadi-defeated",
]);
function skadiSliceRule(rule: DungeonEncounterRule, objectiveId: string): DungeonEncounterRule {
if (rule.id !== "utgarde-pinnacle-skadi-defeated") return rule;
return {
...rule,
trigger: { kind: "entities-dead", entityIds: [objectiveId], timeoutMs: 300_000 },
actions: [
...rule.actions.filter((action) => (
action.kind === "deactivate-spawn-group"
|| action.kind === "complete-objective"
|| action.kind === "set-checkpoint"
)),
{ id: "utgarde-pinnacle-skadi-slice-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "utgarde-pinnacle-skadi-slice-finish", kind: "set-phase", phase: "complete", persistence: "run" },
],
};
}
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition): 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 sourceEncounter = source.encounters?.find((encounter) => (
encounter.rules.some((rule) => rule.id === "utgarde-pinnacle-start-skadi")
));
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
const skadiStart = source.serverGameObjects.find((object) => object.id === "utgarde-pinnacle:skadi-start");
const harpoons = [1, 2, 3].map((index) => source.serverGameObjects.find((object) => (
object.id === `utgarde-pinnacle:harpoon-${index}`
)));
if (!skadi || !sourceEncounter || !sourceSkadiGroup || !skadiStart || harpoons.some((object) => !object)) {
throw new Error("Utgarde Pinnacle is missing the reviewed Skadi gauntlet content.");
}
const route: readonly Vector3Tuple[] = [skadiStart.position, skadi.position];
const staticSpawns = routeSpawns(source, route, SKADI_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, SKADI_ROUTE_WIDTH);
const skadiSpawn: StaticMobSpawnDefinition = {
...sourceSkadiGroup.spawns[0],
id: skadi.id,
position: skadi.position,
};
const skadiGroup: DungeonEncounterSpawnGroupDefinition = {
...sourceSkadiGroup,
spawns: [skadiSpawn],
};
const groups = source.encounterSpawnGroups!.filter((group) => SKADI_GROUP_IDS.has(group.id))
.map((group) => group.id === skadiGroup.id ? skadiGroup : group);
if (!groups.some((group) => group.id === skadiGroup.id)) groups.push(skadiGroup);
const encounter: DungeonEncounterDefinition = {
...sourceEncounter,
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-gauntlet`,
dungeonId: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
initialPhase: "skadi-ready",
initialState: {
flags: { "dungeon-complete": false },
counters: { "harpoon-hits": 0 },
worldObjectStates: {
[skadiStart.id]: "ready",
...Object.fromEntries(harpoons.map((object) => [object!.id, "unavailable"])),
},
},
resetPolicy: { bossWipePhase: "skadi-ready", checkpointPhase: "skadi-ready" },
source: {
...sourceEncounter.source!,
reviewedBehavior: "Begin at Skadi’s gauntlet, clear the source first wave, fire three harpoons through the bounded reinforcement packs, then defeat Skadi.",
adaptations: [
...sourceEncounter.source!.adaptations,
"The Svala and Gortok stages are skipped; the source Skadi event begins ready at its gauntlet trigger.",
"Skadi’s event actor uses the source boss objective ID so party targeting, loot, and completion stay aligned.",
],
},
rules: sourceEncounter.rules.filter((rule) => SKADI_RULE_IDS.has(rule.id))
.map((rule) => skadiSliceRule(rule, skadi.id)),
};
const objective: DungeonBossObjective = { ...skadi, eventOnly: true };
const routeNames = new Map<string, DungeonWorldObjectDefinition>([
[skadiStart.id, skadiStart],
...harpoons.map((object) => [object!.id, object!] as const),
]);
return {
...source,
id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
title: "Utgarde Pinnacle: Skadi’s Gauntlet",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Eagle’s Eye Gauntlet",
footPosition: skadiStart.position,
forward: [-0.167, 0, -0.986],
yaw: -2.974,
},
presentation: {
...source.presentation,
location: "Utgarde Pinnacle · Eagle’s Eye",
theme: "Bite-Sized Dungeon",
summary: "Enter Skadi’s gauntlet, push through the moving waves, fire the harpoons, and finish the fight on the ground.",
loadingMessage: "Preparing Skadi’s gauntlet...",
unchartedAreaName: "Eagle’s Eye",
},
areas: [
routeArea("entrance", "Eagle’s Eye Gauntlet", skadiStart.position, 30),
routeArea("skadi", "Skadi’s Arena", skadi.position, 34),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, groups),
staticSpawns,
roamingPacks,
bosses: [objective],
objectiveOrder: [objective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: groups,
serverGameObjects: [...routeNames.values()],
validation: appendPrototypeWarning(
source,
"The standalone Skadi route is Healer Man-authored; source gauntlet waves, harpoon controls, Skadi combat, and the Utgarde Pinnacle environment are reused. Grauf’s flying vehicle phase remains adapted to ground-based harpoon interactions.",
),
};
}