forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
9c5fb4c614
commit
bd073fb00f
113 files changed
+4902
-273
No files matched your search
@@ -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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user