Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 18:57:44 -04:00
1 parent bd073fb00f
commit 83eac6d476
15 files changed
+438 -272

No files matched your search

+5 -3
View File
@@ -1,5 +1,6 @@
import type { DungeonAssetChunk, DungeonDefinition } from "./dungeonTypes";
import type { ManastormStageAssetPackage } from "./manastormStageAssets";
import { dungeonContentSourceId } from "./dungeonContentSource";
import { dungeonModules } from "@dungeonAssetModules";
function findAsset(dungeonId: string, chunk: DungeonAssetChunk | undefined): string | null {
@@ -19,10 +20,11 @@ export interface ResolvedDungeonAssets {
}
export function resolveDungeonAssets(definition: DungeonDefinition): ResolvedDungeonAssets {
const sourceId = dungeonContentSourceId(definition.id);
return {
visual: definition.assets.visual.flatMap((chunk) => findAsset(definition.id, chunk) ?? []),
collision: definition.assets.collision.flatMap((chunk) => findAsset(definition.id, chunk) ?? []),
navigation: findAsset(definition.id, definition.assets.navigation),
visual: definition.assets.visual.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
collision: definition.assets.collision.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
navigation: findAsset(sourceId, definition.assets.navigation),
};
}
+113 -172
View File
@@ -7,27 +7,17 @@ import type {
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;
@@ -133,114 +123,104 @@ function appendPrototypeWarning(source: DungeonDefinition, note: string): Dungeo
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 verdan = bossByName(source, "Verdan the Everliving");
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[] = [
{ id: "verdan-room-druid", entityId: "druid-of-the-fang", position: [78, -31.01, 16], yaw: Math.PI },
{ id: "verdan-room-guardian", entityId: "deviate-guardian", position: [90, -31, 16], yaw: Math.PI },
{ id: "verdan-room-ravager", entityId: "deviate-ravager", position: [86, -31.05, 24], yaw: Math.PI },
];
const finalObjective: DungeonBossObjective = {
...sourceFinalBoss.objective,
eventOnly: true,
const verdanGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan`;
const verdanGroup: DungeonEncounterSpawnGroupDefinition = {
id: verdanGroupId,
spawns: [verdan.spawn],
};
const encounter: DungeonEncounterDefinition = {
id: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan-room`,
dungeonId: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
initialPhase: "room-mobs",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "room-mobs", checkpointPhase: "room-mobs" },
source: {
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.",
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.",
],
remainingGaps: ["The room encounter still needs an in-game combat and completion pass."],
},
rules: [
{
id: "verdan-room-cleared",
trigger: { kind: "entities-dead", entityIds: staticSpawns.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: ["room-mobs"] }],
actions: [
{ id: "verdan-room-activate-boss", kind: "activate-spawn-group", spawnGroupId: verdanGroupId },
{ id: "verdan-room-enter-boss-phase", kind: "set-phase", phase: "verdan" },
],
},
{
id: "verdan-room-boss-defeated",
trigger: { kind: "entities-dead", entityIds: [verdan.spawn.id] },
guards: [{ kind: "phase", phases: ["verdan"] }],
actions: runCompletionActions("verdan-room", verdan.objective.id),
},
],
};
const finalObjective: DungeonBossObjective = { ...verdan.objective, eventOnly: true };
return {
...source,
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
title: "Wailing Caverns: Naralex’s Rescue",
title: "Wailing Caverns: Verdan’s Lair",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Disciple of Naralex’s Camp",
footPosition: WAILING_NARALEX_START_POSITION,
forward: [-0.433, 0, 0.902],
yaw: -0.448,
name: "Crag of the Everliving",
footPosition: startPosition,
forward: [0, 0, 1],
yaw: 0,
},
presentation: {
...source.presentation,
location: "Wailing Caverns · Naralex’s Route",
location: "Wailing Caverns · Verdan’s Chamber",
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",
summary: "Clear the creatures in Verdan’s chamber, then defeat Verdan the Everliving.",
loadingMessage: "Entering Verdan’s chamber...",
unchartedAreaName: "Crag of the Everliving",
},
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),
routeArea("verdan", "Crag of the Everliving", verdan.objective.position, 46),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, encounterGroups),
entities: entitiesFor(source, staticSpawns, [], [verdanGroup]),
staticSpawns,
roamingPacks,
roamingPacks: [],
bosses: [finalObjective],
objectiveOrder: [finalObjective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects,
encounterSpawnGroups: [verdanGroup],
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.",
"Verdan’s room fight is Healer Man-authored; the cave environment, creature assets, and Verdan 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 smite = bossByName(source, "Mr. Smite");
const captain = bossByName(source, "Captain Greenskin");
const finalBoss = bossByName(source, "Edwin VanCleef");
const startPosition = smite.spawn.position;
const route: readonly Vector3Tuple[] = [
[-785, 17, -28],
[-797.2147, 20.4973, -22.8489],
startPosition,
[-807, 30, -42],
captain.objective.position,
finalBoss.objective.position,
@@ -267,7 +247,7 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
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.",
reviewedBehavior: "Start on Mr. Smite's floor-snapped deck position, 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.",
@@ -303,8 +283,6 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
captain.objective,
{ ...finalBoss.objective, eventOnly: true },
];
const startPosition: Vector3Tuple = [-785, 17, -28];
return {
...source,
id: BITE_SIZED_DEADMINES_DUNGEON_ID,
@@ -312,7 +290,7 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Mr. Smite’s Ship Ramp",
name: "Mr. Smite’s Deck",
footPosition: startPosition,
forward: [-0.5, 0, -0.866],
yaw: -2.618,
@@ -326,7 +304,7 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
unchartedAreaName: "Defias Ship",
},
areas: [
routeArea("entrance", "Mr. Smite’s Ship Ramp", startPosition, 25),
routeArea("entrance", "Mr. Smite’s Deck", startPosition, 25),
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32),
routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28),
],
@@ -348,101 +326,64 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
};
}
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 firstWave = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-first-wave");
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.");
if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[0] || !skadiStart) {
throw new Error("Utgarde Pinnacle is missing the Skadi room content.");
}
const route: readonly Vector3Tuple[] = [skadiStart.position, skadi.position];
const bossPosition = firstWave.spawns[0].position;
const route: readonly Vector3Tuple[] = [skadiStart.position, bossPosition];
const staticSpawns = routeSpawns(source, route, SKADI_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, SKADI_ROUTE_WIDTH);
if (staticSpawns.length === 0) throw new Error("Skadi's approach has no room mobs.");
const skadiSpawn: StaticMobSpawnDefinition = {
...sourceSkadiGroup.spawns[0],
id: skadi.id,
position: skadi.position,
position: bossPosition,
};
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`,
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-room`,
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" },
initialPhase: "approach-mobs",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "approach-mobs", checkpointPhase: "approach-mobs" },
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.",
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/wowWrathEncounterBatchTwoCatalog.ts"],
reviewedBehavior: "Clear the three imported mobs near Eagle's Eye, then confront Skadi on the same walkable approach.",
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.",
"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 near the entrance.",
],
remainingGaps: ["The shortened encounter still needs an in-game combat and completion pass."],
},
rules: sourceEncounter.rules.filter((rule) => SKADI_RULE_IDS.has(rule.id))
.map((rule) => skadiSliceRule(rule, skadi.id)),
rules: [
{
id: "skadi-approach-cleared",
trigger: { kind: "entities-dead", entityIds: staticSpawns.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: ["approach-mobs"] }],
actions: [
{ id: "skadi-approach-activate-boss", kind: "activate-spawn-group", spawnGroupId: skadiGroup.id },
{ id: "skadi-approach-enter-boss-phase", kind: "set-phase", phase: "skadi" },
],
},
{
id: "skadi-room-boss-defeated",
trigger: { kind: "entities-dead", entityIds: [skadiSpawn.id] },
guards: [{ kind: "phase", phases: ["skadi"] }],
actions: runCompletionActions("skadi-room", 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),
]);
const objective: DungeonBossObjective = { ...skadi, position: bossPosition, eventOnly: true };
return {
...source,
@@ -460,26 +401,26 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
...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...",
summary: "Defeat the three guards on Eagle’s Eye, then fight Skadi on the approach.",
loadingMessage: "Entering 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),
routeArea("skadi", "Skadi’s Arena", bossPosition, 34),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, groups),
entities: entitiesFor(source, staticSpawns, [], [skadiGroup]),
staticSpawns,
roamingPacks,
roamingPacks: [],
bosses: [objective],
objectiveOrder: [objective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: groups,
serverGameObjects: [...routeNames.values()],
encounterSpawnGroups: [skadiGroup],
serverGameObjects: [],
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.",
"The short Skadi approach is Healer Man-authored; the imported guards, source boss combat, and Utgarde Pinnacle environment are reused.",
),
};
}
@@ -324,12 +324,12 @@
},
{
"id": "bite-sized-wailing-caverns",
"title": "Wailing Caverns: Naralex’s Rescue",
"finalBossId": "mutanus-spawn",
"title": "Wailing Caverns: Verdan’s Lair",
"finalBossId": "verdan-spawn",
"bosses": [
{
"id": "mutanus-spawn",
"name": "Mutanus the Devourer"
"id": "verdan-spawn",
"name": "Verdan the Everliving"
}
],
"difficulties": [
@@ -337,9 +337,9 @@
"id": "normal",
"name": "normal",
"bossIds": [
"mutanus-spawn"
"verdan-spawn"
],
"finalBossId": "mutanus-spawn"
"finalBossId": "verdan-spawn"
}
],
"defaultDifficultyId": "normal"
+2 -2
View File
@@ -48,8 +48,8 @@ interface WailingReferenceShape {
}
const REFERENCE = (referenceData as unknown as WailingReferenceShape).wailingCaverns;
export const WAILING_NARALEX_START_POSITION = REFERENCE.disciple.position;
export const WAILING_NARALEX_ESCORT_WAYPOINTS = Object.freeze(
const WAILING_NARALEX_START_POSITION = REFERENCE.disciple.position;
const WAILING_NARALEX_ESCORT_WAYPOINTS = Object.freeze(
REFERENCE.disciple.escortWaypoints.map((waypoint) => waypoint.position),
);
const FANG_LORD_RUNTIME_IDS = Object.freeze([