Files
healer-man/src/game/wowWrathEncounterBatchTwoCatalog.ts
T
2026-09-16 14:30:58 -04:00

1948 lines
85 KiB
TypeScript

import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
Vector3Tuple,
} from "./dungeonTypes";
import type {
DungeonEncounterAction,
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "./dungeonEncounterTypes";
import type {
DungeonWorldObjectDefinition,
DungeonWorldObjectInteraction,
DungeonWorldObjectKind,
DungeonWorldObjectStateDefinition,
} from "./dungeonWorldObjects";
import type {
PopulationDefinitionMap,
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "./mobPopulation";
import type { EnemyAttackDefinition } from "./enemyAttacks";
export interface WowWrathEncounterBatchTwoSourceFile {
readonly path: string;
readonly sha256: string;
readonly anchors: readonly string[];
}
export interface WowWrathEncounterBatchTwoReview {
readonly dungeonId: "halls-of-stone" | "pit-of-saron" | "utgarde-pinnacle" | "gundrak";
readonly fidelity: "source-backed-adapted";
readonly sourceFiles: readonly WowWrathEncounterBatchTwoSourceFile[];
readonly reviewedBehavior: string;
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
}
interface AdaptedEntitySpec {
readonly entry: number;
readonly name: string;
readonly level: number;
readonly role: "melee" | "caster";
readonly kind?: "mob" | "boss";
readonly title?: string;
}
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
const HALLS_OF_STONE_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/Ulduar/HallsOfStone/instance_halls_of_stone.cpp`,
`${SOURCE_ROOT}/Northrend/Ulduar/HallsOfStone/brann_bronzebeard.cpp`,
`${SOURCE_ROOT}/Northrend/Ulduar/HallsOfStone/halls_of_stone.h`,
]);
const PIT_OF_SARON_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/FrozenHalls/PitOfSaron/instance_pit_of_saron.cpp`,
`${SOURCE_ROOT}/Northrend/FrozenHalls/PitOfSaron/pit_of_saron.cpp`,
`${SOURCE_ROOT}/Northrend/FrozenHalls/PitOfSaron/pit_of_saron.h`,
`${SOURCE_ROOT}/Northrend/FrozenHalls/PitOfSaron/boss_scourgelord_tyrannus.cpp`,
]);
const UTGARDE_PINNACLE_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/UtgardeKeep/UtgardePinnacle/instance_utgarde_pinnacle.cpp`,
`${SOURCE_ROOT}/Northrend/UtgardeKeep/UtgardePinnacle/boss_svala.cpp`,
`${SOURCE_ROOT}/Northrend/UtgardeKeep/UtgardePinnacle/boss_palehoof.cpp`,
`${SOURCE_ROOT}/Northrend/UtgardeKeep/UtgardePinnacle/boss_skadi.cpp`,
`${SOURCE_ROOT}/Northrend/UtgardeKeep/UtgardePinnacle/utgarde_pinnacle.h`,
]);
const GUNDRAK_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/Gundrak/instance_gundrak.cpp`,
`${SOURCE_ROOT}/Northrend/Gundrak/gundrak.h`,
`${SOURCE_ROOT}/Northrend/Gundrak/boss_eck.cpp`,
]);
const ECK_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({
id: "basic-attack", name: "Basic Attack", delivery: "melee", target: "primary",
school: "physical", animation: "attack", range: 4.5, cooldownMs: 2_000,
initialCooldownMs: [450, 450] as const, repeatCooldownMs: [2_000, 2_000] as const, damageMultiplier: 1,
}),
Object.freeze({
id: "spell-55837", name: "Eck Spring Init", spellId: 55_837, delivery: "area", target: "self",
school: "physical", animation: "cast", castTimeMs: 0, range: 0, cooldownMs: 1,
initialCooldownMs: [450, 450] as const, repeatCooldownMs: [1, 1] as const, maximumCasts: 1, damageMultiplier: 0,
effect: { kind: "aura", aura: "eck-spring-init", durationMs: 1_000 } as const,
}),
Object.freeze({
id: "spell-55813", name: "Eck Bite", spellId: 55_813, delivery: "melee", target: "primary",
school: "physical", animation: "attack", range: 5.5, cooldownMs: 10_000,
initialCooldownMs: [5_000, 5_000] as const, repeatCooldownMs: [8_000, 12_000] as const, damageMultiplier: 1.35,
}),
Object.freeze({
id: "spell-55814", name: "Eck Spit", spellId: 55_814, delivery: "projectile", target: "primary",
school: "nature", animation: "cast", castTimeMs: 0, range: 28, cooldownMs: 10_000,
initialCooldownMs: [10_000, 37_000] as const, repeatCooldownMs: [8_000, 12_000] as const, damageMultiplier: 1.35,
}),
Object.freeze({
id: "spell-55815", name: "Eck Spring", spellId: 55_815, delivery: "projectile", target: "random-party",
school: "physical", animation: "cast", castTimeMs: 750, range: 30, cooldownMs: 17_000,
initialCooldownMs: [10_000, 24_000] as const, repeatCooldownMs: [10_000, 24_000] as const, damageMultiplier: 1.35,
telegraph: { shape: "circle", anchor: "target", warningMs: 750, radius: 3 } as const,
}),
Object.freeze({
id: "spell-55816", name: "Eck Berserk", spellId: 55_816, delivery: "area", target: "self",
school: "physical", animation: "cast", castTimeMs: 0, range: 0, cooldownMs: 90_000,
initialCooldownMs: [90_000, 90_000] as const, repeatCooldownMs: [90_000, 90_000] as const, maximumCasts: 1,
damageMultiplier: 0, effect: { kind: "aura", aura: "enrage", durationMs: 0, magnitude: 1 } as const,
}),
]);
export const WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS: readonly WowWrathEncounterBatchTwoReview[] = Object.freeze([
{
dungeonId: "halls-of-stone",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: HALLS_OF_STONE_FILES[0],
sha256: "253a027fe7e431fd418f97731e6f1ab730ab097e99765b3adedc76812e917e55",
anchors: [
"OnGameObjectCreate Tribunal faces, floor, consoles, and Sjonnir door (lines 101-137)",
"Tribunal completion activates the faces/floor and makes Sjonnir selectable (lines 224-285)",
"Tribunal reset restores face/floor states (lines 287-308)",
],
},
{
path: HALLS_OF_STONE_FILES[1],
sha256: "aa60245d6d8fc23c2b6b3e7f69b34853c4d852875cac276dfb7aab41bc4d0dc7",
anchors: [
"Brann's four source gossip steps (lines 147-205)",
"three Protectors, two Stormcallers, and one Custodian schedules (lines 579-603, 814-820)",
"Tribunal wipe and Brann reset behavior (lines 744-784)",
"Tribunal/Sjonnir waypoint and door handoff (lines 823-899)",
],
},
{
path: HALLS_OF_STONE_FILES[2],
sha256: "527512db7c26edf880ea261cccfb0cc11b10a3e6ac51f2db3edc7d487ab20515",
anchors: ["encounter, Tribunal object, Brann, Sjonnir, and action ids (lines 44-92)"],
},
],
reviewedBehavior: "Krystallus and Maiden remain ordinary fights; Brann's Tribunal defense runs the three source add families, unlocks Sjonnir's exact source door, and Sjonnir completes the instance.",
adaptations: [
"Brann's friendly escort and four gossip stops collapse to one interaction at the exact Tribunal console because protected-friendly waypoint movement is not serializable yet.",
"The source's repeating 310-second overlap is represented by three finite kill-gated waves of three Protectors, two Stormcallers, and one Custodian so the event cannot leak infinite actors or timers.",
"Kaddrak glare, Marnak dark matter, Abedneum searing gaze, face/floor animation, the long lore scene, and chest presentation are not simulated; the reviewed consoles receive procedural visual states only.",
"The exact imported Sjonnir door row is replaced by a visible procedural blocker using its source transform and bounds.",
],
remainingGaps: [
"Protected Brann health/failure, escort waypoints, overlapping timed spawns, face spell hazards, native object models/animations, dialogue, quest credit, achievements, and Tribunal chest loot need dedicated runtime support.",
],
},
{
dungeonId: "pit-of-saron",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: PIT_OF_SARON_FILES[0],
sha256: "fc2d53919e7a3f6f8a8d090720ccd7b4db10cf72147cc1cdc20cb82c56cc5dfc",
anchors: [
"saved seven-step instance progress repositions event actors (lines 103-153)",
"both Garfrost and Ick open the exact ice wall (lines 234-272)",
"Tyrannus completion creates the faction outro leader (lines 274-278)",
],
},
{
path: PIT_OF_SARON_FILES[1],
sha256: "4e38b472092f4f680929bbd9c1d8fa1c456d58c099d748af387bc93bb1b59eca",
anchors: [
"first ambush requires ten kills (lines 622-646)",
"second ambush requires six normal or twelve heroic kills (lines 647-667)",
"exact first and second ambush compositions/positions (lines 695-815)",
"Tyrannus area-trigger prerequisites (lines 1462-1482)",
],
},
{
path: PIT_OF_SARON_FILES[2],
sha256: "dde99a0262f7cb7ddc825583c2056c43f80b468d72a996d2ca6d05f828ad4b00",
anchors: [
"instance progress, actor, ice-wall, and portcullis ids (lines 28-133)",
"ambush and Tyrannus source positions (lines 239-299)",
],
},
{
path: PIT_OF_SARON_FILES[3],
sha256: "a5ba207c04019227e2de7d30da6f63aa68c77682f32a2fe7d3023a321b491958",
anchors: ["Tyrannus/Rimefang vehicle handoff and combat schedules (lines 94-205)"],
},
],
reviewedBehavior: "Garfrost and Ick jointly open the source ice wall; the party clears the source ten-enemy and normal six-enemy ambushes, crosses the tunnel gate, and then releases Tyrannus behind the exact portcullis.",
adaptations: [
"Faction intros, freed-slave scenes, and the friendly leader escort are represented by two interaction markers at source positions.",
"The first ambush keeps its exact ten actors. The second deliberately keeps the normal six-actor composition as this batch's deterministic finite branch; the heroic extra six remain explicitly unclaimed.",
"The icicle tunnel is a bounded transition without falling-icicle damage or the Don't Look Up achievement.",
"Tyrannus fights as a ground actor; Rimefang vehicle riding, aerial movement, Icy Blast, and Mark targeting remain combat-runtime gaps.",
"The exact imported ice wall and portcullis rows are replaced with visible procedural collision boxes and reviewed state changes.",
],
remainingGaps: [
"Friendly escort/path survival, faction variants, heroic gauntlet cardinality, icicle hazards, Tyrannus/Rimefang vehicle behavior, cinematics, dialogue, achievements, and outro remain unported.",
],
},
{
dungeonId: "utgarde-pinnacle",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: UTGARDE_PINNACLE_FILES[0],
sha256: "93efcff64abde10900c30d98e40c7c02e859607c0422ad264cd751bd9dc0503a",
anchors: [
"source doors, mirror, and stasis generator bindings (lines 130-150)",
"Skadi opens his door and releases Ymiron; Ymiron opens the final door (lines 167-205)",
],
},
{
path: UTGARDE_PINNACLE_FILES[1],
sha256: "d44d03de1320bd2150d9c717cd9a3db123e4ce41d3b75acd3220b7ba699833d2",
anchors: [
"Svala transform and ritual ids/positions (lines 39-92)",
"three ritual channelers and 25-second sacrifice window (lines 326-355)",
],
},
{
path: UTGARDE_PINNACLE_FILES[2],
sha256: "76df4d741c8e27b3a0b5d6277faa850e21065d6702dff4483eb9d1032ee5a5be",
anchors: [
"random unique stasis order and normal two/heroic four branch (lines 130-205)",
"orb unfreezes each beast then Gortok (lines 234-283)",
"stasis generator interaction starts the event (lines 791-808)",
],
},
{
path: UTGARDE_PINNACLE_FILES[3],
sha256: "5200950980ec9277a1f2e13eaa1340086c6fe56eb5d6ac31788929005916ea26",
anchors: [
"exact thirteen-actor first wave and positions (lines 91-113, 211-225)",
"flying/ground phase handoff (lines 232-280)",
"Launch Harpoon deals 35 percent Grauf health (lines 490-510)",
],
},
{
path: UTGARDE_PINNACLE_FILES[4],
sha256: "a7a2d62a575784c104f2ee3935c0b4cf1590b1ac4079c1cb6d0701c7db869279",
anchors: ["boss, beast, door, mirror, sphere, Skadi hit, and Grauf ids (lines 27-79)"],
},
],
reviewedBehavior: "Svala's ritual gates her second combat stage; the stasis sphere releases a valid deterministic normal-mode pair before Gortok; Skadi's source first wave feeds three 35-percent harpoon strikes before ground combat; Skadi releases Ymiron.",
adaptations: [
"Svala's long Arthas transformation is a short interaction start, and her repeating sacrifice is one finite three-channeler intermission between two boss actors.",
"The deterministic Worgen then Furbolg stasis order is one valid source normal-mode two-of-four outcome; random selection and the heroic four-beast branch remain unported in this adapter.",
"Skadi's first wave retains all thirteen source actors; two small bounded reinforcement packs replace the source's periodic infinite summon aura.",
"Three procedural harpoon controls model three 35-percent Launch Harpoon hits. Grauf flight, vehicle seating, breath-side paths, harpoon item pickup, and in-place dismount are unavailable.",
"Exact imported mirror, stasis sphere, Skadi door, and Ymiron door rows are replaced by procedural visuals/interactions/collision at their source transforms.",
],
remainingGaps: [
"Arthas/Svala transformation, repeated ritual targeting, heroic four-beast selection, random stasis order, vehicles, Grauf flight/breath lanes, periodic gauntlet spawning, harpoon inventory, achievements, dialogue, and native object models remain unported.",
],
},
{
dungeonId: "gundrak",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: GUNDRAK_FILES[0],
sha256: "033f5f3b516f5bc681131acf02b60bb51b0ea140c68ca2e6b45394b7f875bb82",
anchors: [
"Eck and Gal'darah door relationships (lines 24-31, 107-116)",
"three boss-gated altars and four statues/collision/bridge (lines 67-106)",
"altar activation and all-three-key check (lines 137-193)",
"five-second bridge/statue activation (lines 217-235)",
"heroic Eck summon after Ruins Dweller formation death (lines 195-214)",
],
},
{
path: GUNDRAK_FILES[1],
sha256: "ec0477724b5343879ecfd713060c408796373d3f6fbeaf16dcef84f1393c6356",
anchors: ["boss, altar, statue, bridge, collision, Eck, and Gal'darah door ids (lines 27-73)"],
},
{
path: GUNDRAK_FILES[2],
sha256: "ef6f4736b921c5f654260cb0c61f2590e77270c8e5a4c4f0935db4c5f0478e07",
anchors: [
"Eck Spring initialization and threat handoff (lines 51-72)",
"Bite, Spit, Spring, and 90-second Berserk schedules (lines 75-90)",
],
},
],
reviewedBehavior: "Slad'ran, Moorabi, and the Drakkari Colossus independently enable their exact altars; using all three rotates the exact statues and bridge collision after five seconds, exposing Gal'darah; Moorabi releases the heroic Eck route.",
adaptations: [
"The bridge and statues change discrete procedural states without native rotation animation; the source five-second delay is retained.",
"The optional Eck actor is activated after Moorabi for deterministic recovery rather than after the source Ruins Dweller formation; its actor spawn remains heroic-mask-only, so normal rendering filters it out.",
"Eck keeps the source's independent Bite, Spit, Spring, and 90-second Berserk timings; Spring uses a short target warning because the runtime does not yet execute creature jump motion.",
"Gal'darah's room door closes on engagement and all three source Gal'darah doors open on death; no native door animation is claimed.",
"Exact imported altar, statue, bridge, collision, Eck-door, underwater-gate, and Gal'darah-door rows are replaced by procedural stateful objects at their source transforms.",
],
remainingGaps: [
"Native bridge/statue rotation, Ruins Dweller formation detection, heroic-only interaction visibility, encounter cinematics, achievements, dialogue, and native object models remain unported.",
],
},
]);
const REVIEW_BY_DUNGEON = new Map(
WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => [review.dungeonId, review] as const),
);
const PACK_OFFSETS = Object.freeze([
[0, 0], [4, 0], [-4, 0], [0, 4], [0, -4], [4, 4], [-4, 4], [4, -4], [-4, -4],
[8, 0], [-8, 0], [0, 8], [0, -8],
] as const);
function sourceMapPosition(source: readonly [x: number, y: number, z: number]): Vector3Tuple {
return [source[1], source[2], source[0]];
}
function assertSourceMapTransform(definition: DungeonDefinition): void {
if (definition.provenance.coordinateTransform !== "three(x,y,z)=(wow.y,wow.z,wow.x)") {
throw new Error(
`Wrath batch-two content for ${definition.id} requires three(x,y,z)=(wow.y,wow.z,wow.x), got ${definition.provenance.coordinateTransform}`,
);
}
}
function entityIdForEntry(entities: PopulationDefinitionMap, entry: number): string | undefined {
return Object.entries(entities).find(([, entity]) => entity.combat?.serverEntry === entry)?.[0];
}
function createAdaptedEntity(spec: AdaptedEntitySpec): PopulationEntityDefinition {
const caster = spec.role === "caster";
const base = {
id: `wrath-batch-two-entry-${spec.entry}`,
name: spec.name,
hasLoot: spec.kind === "boss",
combat: {
serverEntry: spec.entry,
level: spec.level,
healthMultiplier: spec.kind === "boss" ? 4.2 : caster ? 0.92 : 1.08,
damageMultiplier: spec.kind === "boss" ? 1.18 : 0.92,
moveSpeed: 3.65,
aggroRange: 32,
leashRange: 90,
attacks: [{
id: "basic-attack",
name: caster ? "Source Spell Proxy" : "Basic Attack",
delivery: caster ? "projectile" as const : "melee" as const,
target: "primary" as const,
school: caster ? "arcane" as const : "physical" as const,
animation: caster ? "cast" as const : "attack" as const,
range: caster ? 28 : 4.5,
cooldownMs: caster ? 3_000 : 2_000,
damageMultiplier: caster ? 0.82 : 1,
}],
},
visual: {
primaryColor: caster ? "#526d91" : "#70505b",
accentColor: caster ? "#a8daf6" : "#dda58f",
scale: spec.kind === "boss" ? 1.16 : 1,
archetype: "humanoid" as const,
},
};
return spec.kind === "boss"
? Object.freeze({ ...base, kind: "boss" as const, title: spec.title ?? "Dungeon Boss" })
: Object.freeze({ ...base, kind: "mob" as const });
}
function ensureEntities(
definition: DungeonDefinition,
specs: readonly AdaptedEntitySpec[],
): PopulationDefinitionMap {
const entities: Record<string, PopulationEntityDefinition> = { ...definition.entities };
for (const spec of specs) {
if (entityIdForEntry(entities, spec.entry)) continue;
const entity = createAdaptedEntity(spec);
entities[entity.id] = entity;
}
return Object.freeze(entities);
}
function entryForSpawn(definition: DungeonDefinition, spawn: StaticMobSpawnDefinition): number | undefined {
return definition.entities[spawn.entityId]?.combat?.serverEntry;
}
function objectiveForEntry(definition: DungeonDefinition, entry: number): DungeonBossObjective {
const spawnIds = new Set(definition.staticSpawns
.filter((spawn) => entryForSpawn(definition, spawn) === entry)
.map((spawn) => spawn.id));
const objective = definition.bosses.find((boss) => spawnIds.has(boss.id));
if (!objective) throw new Error(`${definition.id} is missing boss objective for creature entry ${entry}`);
return objective;
}
function objectiveForName(definition: DungeonDefinition, name: string): DungeonBossObjective {
const objective = definition.bosses.find((boss) => boss.name.toLowerCase() === name.toLowerCase());
if (!objective) throw new Error(`${definition.id} is missing boss objective ${name}`);
return objective;
}
function spawnAt(
id: string,
entityId: string,
center: Vector3Tuple,
offset: readonly [x: number, z: number] = [0, 0],
spawnMask = 3,
): StaticMobSpawnDefinition {
return Object.freeze({
id,
entityId,
position: [center[0] + offset[0], center[1], center[2] + offset[1]] as const,
yaw: 0,
spawnMask,
});
}
function spawnGroup(
id: string,
entries: readonly number[],
entities: PopulationDefinitionMap,
center: Vector3Tuple,
spawnMask = 3,
): DungeonEncounterSpawnGroupDefinition {
return Object.freeze({
id,
spawns: Object.freeze(entries.map((entry, index) => {
const entityId = entityIdForEntry(entities, entry);
if (!entityId) throw new Error(`${id} is missing source creature entry ${entry}`);
return spawnAt(`${id}:actor-${index + 1}`, entityId, center, PACK_OFFSETS[index] ?? [index * 3, 0], spawnMask);
})),
});
}
function exactSpawnGroup(
id: string,
actors: readonly { readonly entry: number; readonly position: Vector3Tuple; readonly spawnMask?: number }[],
entities: PopulationDefinitionMap,
): DungeonEncounterSpawnGroupDefinition {
return Object.freeze({
id,
spawns: Object.freeze(actors.map((actor, index) => {
const entityId = entityIdForEntry(entities, actor.entry);
if (!entityId) throw new Error(`${id} is missing source creature entry ${actor.entry}`);
return Object.freeze({
id: `${id}:actor-${index + 1}`,
entityId,
position: actor.position,
yaw: 0,
spawnMask: actor.spawnMask ?? 3,
});
})),
});
}
function sourceReview(review: WowWrathEncounterBatchTwoReview): DungeonEncounterDefinition["source"] {
return {
fidelity: review.fidelity,
scope: "instance-orchestration",
files: review.sourceFiles.map((file) => `${file.path} (sha256 ${file.sha256})`),
reviewedBehavior: review.reviewedBehavior,
adaptations: review.adaptations,
remainingGaps: review.remainingGaps,
};
}
function withoutBaselineBossProgression(definition: DungeonDefinition): readonly DungeonEncounterDefinition[] {
return (definition.encounters ?? []).filter((encounter) => (
encounter.source?.scope !== "boss-progression"
&& encounter.id !== `${definition.id}:boss-progression`
&& encounter.id !== `${definition.id}:wrath-batch-two-instance`
));
}
function appendValidationWarning(definition: DungeonDefinition, warning: string): DungeonDefinition["validation"] {
return Object.freeze({
...definition.validation,
warnings: Object.freeze([...new Set([...definition.validation.warnings, warning])]),
});
}
function reviewedObject(
original: DungeonWorldObjectDefinition,
id: string,
name: string,
kind: DungeonWorldObjectKind,
initialState: string,
states: readonly DungeonWorldObjectStateDefinition[],
sourceFiles: readonly string[],
interaction?: DungeonWorldObjectInteraction,
): DungeonWorldObjectDefinition {
return Object.freeze({
...original,
id,
name,
kind,
initialState,
states: Object.freeze(states),
...(interaction ? { interaction } : { interaction: undefined }),
visual: { color: "#65758a", emissive: "#111b29" },
source: {
...original.source,
fidelity: "source-backed-adapted" as const,
files: sourceFiles,
note: "Exact imported transform and bounds are retained while visible procedural geometry replaces the unexported native model.",
},
});
}
function importedObjectsForTemplate(
definition: DungeonDefinition,
templateId: number,
): readonly DungeonWorldObjectDefinition[] {
return definition.serverGameObjects.filter((object) => object.source?.templateId === templateId);
}
function oneImportedObject(definition: DungeonDefinition, templateId: number): DungeonWorldObjectDefinition {
const objects = importedObjectsForTemplate(definition, templateId);
if (objects.length !== 1) {
throw new Error(`${definition.id} expected one imported gameobject template ${templateId}, got ${objects.length}`);
}
return objects[0];
}
function replaceImportedObjects(
definition: DungeonDefinition,
replacements: readonly DungeonWorldObjectDefinition[],
authored: readonly DungeonWorldObjectDefinition[] = [],
): readonly DungeonWorldObjectDefinition[] {
const replacedSpawnIds = new Set(replacements
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined));
return Object.freeze([
...definition.serverGameObjects.filter((object) => (
object.source?.spawnId === undefined || !replacedSpawnIds.has(object.source.spawnId)
)),
...replacements,
...authored,
]);
}
const CLOSED_OPEN_STATES: readonly DungeonWorldObjectStateDefinition[] = Object.freeze([
{ id: "closed", label: "Closed", visible: true, blocksMovement: true, blocksSight: true, interactionEnabled: false },
{ id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false, interactionEnabled: false },
]);
const VISUAL_STATES: readonly DungeonWorldObjectStateDefinition[] = Object.freeze([
{ id: "inactive", label: "Inactive", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "active", label: "Active", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "complete", label: "Complete", visible: true, blocksMovement: false, interactionEnabled: false },
]);
const INTERACTION_STATES: readonly DungeonWorldObjectStateDefinition[] = Object.freeze([
{ id: "unavailable", label: "Unavailable", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "used", label: "Used", visible: true, blocksMovement: false, interactionEnabled: false },
]);
function authoredInteraction(
id: string,
name: string,
prompt: string,
position: Vector3Tuple,
sourceFiles: readonly string[],
initialState: "unavailable" | "ready" = "unavailable",
kind: DungeonWorldObjectKind = "npc",
): DungeonWorldObjectDefinition {
return Object.freeze({
id,
name,
kind,
position,
halfExtents: [0.8, 1.5, 0.8] as const,
initialState,
states: INTERACTION_STATES,
interaction: {
prompt,
range: 4,
signalId: `${id}:interact`,
allowedStates: ["ready"],
oneShot: true,
},
visual: { color: "#a78bd4", emissive: "#302044" },
source: {
fidelity: "source-backed-adapted" as const,
files: sourceFiles,
note: "A procedural interaction at a source position replaces an unsupported proximity, gossip, item-use, escort, or vehicle trigger.",
},
});
}
function objectiveActions(prefix: string, objective: DungeonBossObjective): readonly DungeonEncounterAction[] {
return [
{ id: `${prefix}-complete-objective`, kind: "complete-objective", objectiveId: objective.id, persistence: "run" },
{ id: `${prefix}-checkpoint`, kind: "set-checkpoint", checkpointId: objective.id, persistence: "checkpoint" },
];
}
function staticBossRule(prefix: string, objective: DungeonBossObjective): DungeonEncounterRule {
return {
id: `${prefix}-defeated`,
trigger: { kind: "entities-dead", entityIds: [objective.id] },
actions: objectiveActions(prefix, objective),
};
}
function allRequiredCompleteRule(
prefix: string,
objectives: readonly DungeonBossObjective[],
extraActions: readonly DungeonEncounterAction[] = [],
): DungeonEncounterRule {
return {
id: `${prefix}-all-required-complete`,
trigger: { kind: "tick" },
guards: objectives.filter((objective) => !objective.optional).map((objective) => ({
kind: "objective-complete" as const,
objectiveId: objective.id,
equals: true,
})),
actions: [
...extraActions,
{ id: `${prefix}-mark-dungeon-complete`, kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: `${prefix}-enter-complete`, kind: "set-phase", phase: "complete", persistence: "run" },
],
};
}
function eventObjective(definition: DungeonDefinition, objectiveId: string): DungeonBossObjective {
const objective = definition.bosses.find((boss) => boss.id === objectiveId);
if (!objective) throw new Error(`${definition.id} missing objective ${objectiveId}`);
return Object.freeze({ ...objective, eventOnly: true });
}
function replaceObjectives(
objectives: readonly DungeonBossObjective[],
replacements: readonly DungeonBossObjective[],
): readonly DungeonBossObjective[] {
const byId = new Map(replacements.map((objective) => [objective.id, objective] as const));
return Object.freeze(objectives.map((objective) => byId.get(objective.id) ?? objective));
}
function buildHallsOfStone(definition: DungeonDefinition): DungeonDefinition {
assertSourceMapTransform(definition);
const review = REVIEW_BY_DUNGEON.get("halls-of-stone")!;
const krystallus = objectiveForEntry(definition, 27_977);
const maiden = objectiveForEntry(definition, 27_975);
const tribunalBase = objectiveForName(definition, "Tribunal of Ages");
const tribunal = eventObjective(definition, tribunalBase.id);
const sjonnir = objectiveForEntry(definition, 27_978);
const entities = ensureEntities(definition, [
{ entry: 27_983, name: "Dark Rune Protector", level: 77, role: "melee" },
{ entry: 27_984, name: "Dark Rune Stormcaller", level: 77, role: "caster" },
{ entry: 27_985, name: "Iron Golem Custodian", level: 77, role: "melee" },
]);
const protectorGroup = spawnGroup(
"halls-of-stone:tribunal-protectors",
[27_983, 27_983, 27_983],
entities,
sourceMapPosition([943.088, 401.378, 206.078]),
);
const stormcallerGroup = spawnGroup(
"halls-of-stone:tribunal-stormcallers",
[27_984, 27_984],
entities,
sourceMapPosition([964.302, 378.942, 206.078]),
);
const custodianGroup = spawnGroup(
"halls-of-stone:tribunal-custodian",
[27_985],
entities,
sourceMapPosition([964.302, 378.942, 206.078]),
);
const tribunalConsoleSource = oneImportedObject(definition, 193_907);
const tribunalConsole = reviewedObject(
tribunalConsoleSource,
"halls-of-stone:reviewed-tribunal-console",
"Tribunal Console",
"switch",
"ready",
INTERACTION_STATES,
HALLS_OF_STONE_FILES,
{
prompt: "Ask Brann to begin the Tribunal defense",
range: 4,
signalId: "halls-of-stone:tribunal-start",
allowedStates: ["ready"],
oneShot: true,
},
);
const sjonnirConsole = reviewedObject(
oneImportedObject(definition, 193_906),
"halls-of-stone:reviewed-sjonnir-console",
"Sjonnir Console",
"switch",
"inactive",
VISUAL_STATES,
HALLS_OF_STONE_FILES,
);
const sjonnirDoor = reviewedObject(
oneImportedObject(definition, 191_296),
"halls-of-stone:reviewed-sjonnir-door",
"Sjonnir Door",
"door",
"closed",
CLOSED_OPEN_STATES,
HALLS_OF_STONE_FILES,
);
const rules: DungeonEncounterRule[] = [
{
id: "halls-of-stone-resume-complete",
trigger: { kind: "tick" },
once: false,
priority: 100,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "dungeon-complete", equals: true },
],
actions: [{ id: "halls-of-stone-resume-complete-phase", kind: "set-phase", phase: "complete" }],
},
{
id: "halls-of-stone-resume-after-tribunal",
trigger: { kind: "tick" },
once: false,
priority: 50,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: tribunal.id, equals: true },
{ kind: "objective-complete", objectiveId: sjonnir.id, equals: false },
],
actions: [
{ id: "halls-of-stone-resume-console-used", kind: "set-world-object-state", objectId: tribunalConsole.id, state: "used" },
{ id: "halls-of-stone-resume-sjonnir-console", kind: "set-world-object-state", objectId: sjonnirConsole.id, state: "active" },
{ id: "halls-of-stone-resume-open-sjonnir-door", kind: "set-world-object-state", objectId: sjonnirDoor.id, state: "open" },
{ id: "halls-of-stone-resume-sjonnir-ready", kind: "set-phase", phase: "sjonnir-ready" },
],
},
{
id: "halls-of-stone-resume-before-tribunal",
trigger: { kind: "tick" },
once: false,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: tribunal.id, equals: false },
],
actions: [
{ id: "halls-of-stone-resume-console-ready", kind: "set-world-object-state", objectId: tribunalConsole.id, state: "ready" },
{ id: "halls-of-stone-resume-close-sjonnir-door", kind: "set-world-object-state", objectId: sjonnirDoor.id, state: "closed" },
{ id: "halls-of-stone-resume-tribunal-ready", kind: "set-phase", phase: "tribunal-ready" },
],
},
{
id: "halls-of-stone-start-tribunal",
trigger: { kind: "interact", objectId: tribunalConsole.id },
guards: [
{ kind: "phase", phases: ["tribunal-ready"] },
{ kind: "objective-complete", objectiveId: tribunal.id, equals: false },
],
actions: [
{ id: "halls-of-stone-use-tribunal-console", kind: "set-world-object-state", objectId: tribunalConsole.id, state: "used" },
{ id: "halls-of-stone-activate-protectors", kind: "activate-spawn-group", spawnGroupId: protectorGroup.id },
{ id: "halls-of-stone-enter-protectors", kind: "set-phase", phase: "tribunal-protectors-active" },
],
},
{
id: "halls-of-stone-protectors-cleared",
trigger: { kind: "entities-dead", entityIds: protectorGroup.spawns.map((spawn) => spawn.id), timeoutMs: 180_000 },
guards: [{ kind: "phase", phases: ["tribunal-protectors-active"] }],
actions: [
{ id: "halls-of-stone-despawn-protectors", kind: "deactivate-spawn-group", spawnGroupId: protectorGroup.id },
{ id: "halls-of-stone-activate-stormcallers", kind: "activate-spawn-group", spawnGroupId: stormcallerGroup.id },
{ id: "halls-of-stone-enter-stormcallers", kind: "set-phase", phase: "tribunal-stormcallers-active" },
],
},
{
id: "halls-of-stone-stormcallers-cleared",
trigger: { kind: "entities-dead", entityIds: stormcallerGroup.spawns.map((spawn) => spawn.id), timeoutMs: 180_000 },
guards: [{ kind: "phase", phases: ["tribunal-stormcallers-active"] }],
actions: [
{ id: "halls-of-stone-despawn-stormcallers", kind: "deactivate-spawn-group", spawnGroupId: stormcallerGroup.id },
{ id: "halls-of-stone-activate-custodian", kind: "activate-spawn-group", spawnGroupId: custodianGroup.id },
{ id: "halls-of-stone-enter-custodian", kind: "set-phase", phase: "tribunal-custodian-active" },
],
},
{
id: "halls-of-stone-custodian-cleared",
trigger: { kind: "entities-dead", entityIds: custodianGroup.spawns.map((spawn) => spawn.id), timeoutMs: 180_000 },
guards: [{ kind: "phase", phases: ["tribunal-custodian-active"] }],
actions: [
{ id: "halls-of-stone-despawn-custodian", kind: "deactivate-spawn-group", spawnGroupId: custodianGroup.id },
...objectiveActions("halls-of-stone-tribunal", tribunal),
{ id: "halls-of-stone-activate-sjonnir-console", kind: "set-world-object-state", objectId: sjonnirConsole.id, state: "active", persistence: "run" },
{ id: "halls-of-stone-open-sjonnir-door", kind: "set-world-object-state", objectId: sjonnirDoor.id, state: "open", persistence: "run" },
{ id: "halls-of-stone-enter-sjonnir-ready", kind: "set-phase", phase: "sjonnir-ready" },
],
},
staticBossRule("halls-of-stone-krystallus", krystallus),
staticBossRule("halls-of-stone-maiden", maiden),
staticBossRule("halls-of-stone-sjonnir", sjonnir),
allRequiredCompleteRule("halls-of-stone", [krystallus, maiden, tribunal, sjonnir]),
];
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "halls-of-stone:wrath-batch-two-instance",
dungeonId: definition.id,
initialPhase: "tribunal-ready",
initialState: {
flags: { "dungeon-complete": false },
worldObjectStates: {
[tribunalConsole.id]: "ready",
[sjonnirConsole.id]: "inactive",
[sjonnirDoor.id]: "closed",
},
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(review),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== tribunal.id)),
bosses: replaceObjectives(definition.bosses, [tribunal]),
objectiveOrder: replaceObjectives(definition.objectiveOrder, [tribunal]),
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []),
protectorGroup,
stormcallerGroup,
custodianGroup,
]),
serverGameObjects: replaceImportedObjects(definition, [tribunalConsole, sjonnirConsole, sjonnirDoor]),
validation: appendValidationWarning(
definition,
"Halls of Stone now has a source-reviewed finite Tribunal defense and exact-row Sjonnir door gate; Brann escort, hazards, lore RP, and achievements remain documented adaptations.",
),
});
}
function buildPitOfSaron(definition: DungeonDefinition): DungeonDefinition {
assertSourceMapTransform(definition);
const review = REVIEW_BY_DUNGEON.get("pit-of-saron")!;
const garfrost = objectiveForEntry(definition, 36_494);
const ick = objectiveForEntry(definition, 36_476);
const tyrannusBase = objectiveForEntry(definition, 36_658);
const tyrannus = eventObjective(definition, tyrannusBase.id);
const entities = ensureEntities(definition, [
{ entry: 36_892, name: "Ymirjar Deathbringer", level: 80, role: "caster" },
{ entry: 36_840, name: "Ymirjar Wrathbringer", level: 80, role: "melee" },
{ entry: 36_893, name: "Ymirjar Flamebearer", level: 80, role: "caster" },
{ entry: 36_841, name: "Fallen Warrior", level: 80, role: "melee" },
{ entry: 36_842, name: "Wrathbone Coldwraith", level: 80, role: "caster" },
]);
const firstAmbush = exactSpawnGroup(
"pit-of-saron:first-ambush",
[
{ entry: 36_892, position: sourceMapPosition([950.61, 50.91, 567.85]) },
{ entry: 36_892, position: sourceMapPosition([949.05, 61.18, 566.60]) },
{ entry: 36_840, position: sourceMapPosition([919.733, 89.0972, 558.959]) },
{ entry: 36_840, position: sourceMapPosition([911.936, 63.3542, 547.698]) },
{ entry: 36_893, position: sourceMapPosition([909.356, 83.1684, 551.717]) },
{ entry: 36_893, position: sourceMapPosition([920.946, 69.1667, 557.594]) },
{ entry: 36_840, position: sourceMapPosition([879.464, 41.1997, 521.394]) },
{ entry: 36_840, position: sourceMapPosition([885.715, 65.5156, 533.631]) },
{ entry: 36_893, position: sourceMapPosition([876.884, 61.0139, 527.715]) },
{ entry: 36_893, position: sourceMapPosition([889.49, 45.2865, 527.233]) },
],
entities,
);
const secondAmbushPositions = [
[927.11, -72.60, 592.2],
[922.92, -72.64, 592.3],
[930.46, -72.57, 592.1],
[934.52, -72.52, 592.1],
[934.57, -77.66, 592.20],
[927.15, -77.07, 592.20],
] as const;
const secondAmbush = exactSpawnGroup(
"pit-of-saron:second-ambush-normal",
secondAmbushPositions.map((position, index) => ({
entry: index < 4 ? 36_841 : 36_842,
position: sourceMapPosition(position),
})),
entities,
);
const tyrannusEntityId = entityIdForEntry(entities, 36_658);
if (!tyrannusEntityId) throw new Error("pit-of-saron is missing Scourgelord Tyrannus entry 36658");
const tyrannusGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "pit-of-saron:tyrannus",
spawns: Object.freeze([spawnAt("pit-of-saron:tyrannus-actor", tyrannusEntityId, tyrannus.position)]),
});
const iceWall = reviewedObject(
oneImportedObject(definition, 201_885),
"pit-of-saron:reviewed-ice-wall",
"Ice Wall",
"door",
"closed",
CLOSED_OPEN_STATES,
PIT_OF_SARON_FILES,
);
const portcullis = reviewedObject(
oneImportedObject(definition, 201_848),
"pit-of-saron:reviewed-portcullis",
"Tyrannus Portcullis",
"gate",
"open",
CLOSED_OPEN_STATES,
PIT_OF_SARON_FILES,
);
const gauntletStart = authoredInteraction(
"pit-of-saron:gauntlet-start",
"Freed Prisoner Vanguard",
"Advance with the freed prisoners",
sourceMapPosition([923.45, 82.65, 582.44]),
PIT_OF_SARON_FILES,
);
const tunnelStart = authoredInteraction(
"pit-of-saron:tunnel-start",
"Collapsed Tunnel Exit",
"Cross the icicle tunnel and confront Tyrannus",
sourceMapPosition([1009.29, 163.15, 628.157]),
PIT_OF_SARON_FILES,
);
const rules: DungeonEncounterRule[] = [
{
id: "pit-of-saron-resume-complete",
trigger: { kind: "tick" },
once: false,
priority: 100,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "dungeon-complete", equals: true },
],
actions: [{ id: "pit-of-saron-resume-complete-phase", kind: "set-phase", phase: "complete" }],
},
{
id: "pit-of-saron-resume-tyrannus",
trigger: { kind: "tick" },
once: false,
priority: 60,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "gauntlet-complete", equals: true },
{ kind: "objective-complete", objectiveId: tyrannus.id, equals: false },
],
actions: [
{ id: "pit-of-saron-resume-open-ice-wall", kind: "set-world-object-state", objectId: iceWall.id, state: "open" },
{ id: "pit-of-saron-resume-gauntlet-used", kind: "set-world-object-state", objectId: gauntletStart.id, state: "used" },
{ id: "pit-of-saron-resume-tunnel-ready", kind: "set-world-object-state", objectId: tunnelStart.id, state: "ready" },
{ id: "pit-of-saron-resume-enter-tunnel-ready", kind: "set-phase", phase: "tunnel-ready" },
],
},
{
id: "pit-of-saron-resume-gauntlet",
trigger: { kind: "tick" },
once: false,
priority: 40,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: garfrost.id, equals: true },
{ kind: "objective-complete", objectiveId: ick.id, equals: true },
{ kind: "flag", flagId: "gauntlet-complete", equals: false },
],
actions: [
{ id: "pit-of-saron-resume-gauntlet-open-ice", kind: "set-world-object-state", objectId: iceWall.id, state: "open" },
{ id: "pit-of-saron-resume-gauntlet-ready-object", kind: "set-world-object-state", objectId: gauntletStart.id, state: "ready" },
{ id: "pit-of-saron-resume-enter-gauntlet-ready", kind: "set-phase", phase: "gauntlet-ready" },
],
},
{
id: "pit-of-saron-resume-early",
trigger: { kind: "tick" },
once: false,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: garfrost.id, equals: false },
],
actions: [{ id: "pit-of-saron-resume-enter-progression", kind: "set-phase", phase: "progression" }],
},
{
id: "pit-of-saron-resume-before-ick",
trigger: { kind: "tick" },
once: false,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: ick.id, equals: false },
],
actions: [{ id: "pit-of-saron-resume-before-ick-progression", kind: "set-phase", phase: "progression" }],
},
staticBossRule("pit-of-saron-garfrost", garfrost),
staticBossRule("pit-of-saron-ick", ick),
{
id: "pit-of-saron-release-gauntlet",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["progression"] },
{ kind: "objective-complete", objectiveId: garfrost.id, equals: true },
{ kind: "objective-complete", objectiveId: ick.id, equals: true },
],
actions: [
{ id: "pit-of-saron-open-ice-wall", kind: "set-world-object-state", objectId: iceWall.id, state: "open", persistence: "run" },
{ id: "pit-of-saron-ready-gauntlet-start", kind: "set-world-object-state", objectId: gauntletStart.id, state: "ready", persistence: "run" },
{ id: "pit-of-saron-enter-gauntlet-ready", kind: "set-phase", phase: "gauntlet-ready" },
],
},
{
id: "pit-of-saron-start-first-ambush",
trigger: { kind: "interact", objectId: gauntletStart.id },
guards: [{ kind: "phase", phases: ["gauntlet-ready"] }],
actions: [
{ id: "pit-of-saron-use-gauntlet-start", kind: "set-world-object-state", objectId: gauntletStart.id, state: "used" },
{ id: "pit-of-saron-activate-first-ambush", kind: "activate-spawn-group", spawnGroupId: firstAmbush.id },
{ id: "pit-of-saron-enter-first-ambush", kind: "set-phase", phase: "first-ambush-active" },
],
},
{
id: "pit-of-saron-first-ambush-cleared",
trigger: { kind: "entities-dead", entityIds: firstAmbush.spawns.map((spawn) => spawn.id), timeoutMs: 240_000 },
guards: [{ kind: "phase", phases: ["first-ambush-active"] }],
actions: [
{ id: "pit-of-saron-despawn-first-ambush", kind: "deactivate-spawn-group", spawnGroupId: firstAmbush.id },
{ id: "pit-of-saron-activate-second-ambush", kind: "activate-spawn-group", spawnGroupId: secondAmbush.id },
{ id: "pit-of-saron-enter-second-ambush", kind: "set-phase", phase: "second-ambush-active" },
],
},
{
id: "pit-of-saron-second-ambush-cleared",
trigger: { kind: "entities-dead", entityIds: secondAmbush.spawns.map((spawn) => spawn.id), timeoutMs: 240_000 },
guards: [{ kind: "phase", phases: ["second-ambush-active"] }],
actions: [
{ id: "pit-of-saron-despawn-second-ambush", kind: "deactivate-spawn-group", spawnGroupId: secondAmbush.id },
{ id: "pit-of-saron-record-gauntlet", kind: "set-flag", flagId: "gauntlet-complete", value: true, persistence: "run" },
{ id: "pit-of-saron-gauntlet-checkpoint", kind: "set-checkpoint", checkpointId: "pit-of-saron:gauntlet", persistence: "checkpoint" },
{ id: "pit-of-saron-ready-tunnel", kind: "set-world-object-state", objectId: tunnelStart.id, state: "ready", persistence: "run" },
{ id: "pit-of-saron-enter-tunnel-ready", kind: "set-phase", phase: "tunnel-ready" },
],
},
{
id: "pit-of-saron-start-tyrannus",
trigger: { kind: "interact", objectId: tunnelStart.id },
guards: [
{ kind: "phase", phases: ["tunnel-ready"] },
{ kind: "flag", flagId: "gauntlet-complete", equals: true },
],
actions: [
{ id: "pit-of-saron-use-tunnel-start", kind: "set-world-object-state", objectId: tunnelStart.id, state: "used" },
{ id: "pit-of-saron-close-portcullis", kind: "set-world-object-state", objectId: portcullis.id, state: "closed" },
{ id: "pit-of-saron-activate-tyrannus", kind: "activate-spawn-group", spawnGroupId: tyrannusGroup.id },
{ id: "pit-of-saron-enter-tyrannus", kind: "set-phase", phase: "tyrannus-active" },
],
},
{
id: "pit-of-saron-tyrannus-defeated",
trigger: { kind: "entities-dead", entityIds: tyrannusGroup.spawns.map((spawn) => spawn.id), timeoutMs: 300_000 },
guards: [{ kind: "phase", phases: ["tyrannus-active"] }],
actions: [
{ id: "pit-of-saron-despawn-tyrannus", kind: "deactivate-spawn-group", spawnGroupId: tyrannusGroup.id },
...objectiveActions("pit-of-saron-tyrannus", tyrannus),
{ id: "pit-of-saron-open-portcullis", kind: "set-world-object-state", objectId: portcullis.id, state: "open", persistence: "run" },
{ id: "pit-of-saron-enter-post-tyrannus", kind: "set-phase", phase: "post-tyrannus" },
],
},
allRequiredCompleteRule("pit-of-saron", [garfrost, ick, tyrannus]),
];
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "pit-of-saron:wrath-batch-two-instance",
dungeonId: definition.id,
initialPhase: "progression",
initialState: {
flags: { "dungeon-complete": false, "gauntlet-complete": false },
worldObjectStates: {
[iceWall.id]: "closed",
[portcullis.id]: "open",
[gauntletStart.id]: "unavailable",
[tunnelStart.id]: "unavailable",
},
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(review),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== tyrannus.id)),
bosses: replaceObjectives(definition.bosses, [tyrannus]),
objectiveOrder: replaceObjectives(definition.objectiveOrder, [tyrannus]),
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []),
firstAmbush,
secondAmbush,
tyrannusGroup,
]),
serverGameObjects: replaceImportedObjects(definition, [iceWall, portcullis], [gauntletStart, tunnelStart]),
validation: appendValidationWarning(
definition,
"Pit of Saron now gates Tyrannus behind both bosses, the exact source ten-enemy ambush, and the normal six-enemy tunnel wave; escorts, heroic extras, icicles, and Rimefang remain documented adaptations.",
),
});
}
function buildUtgardePinnacle(definition: DungeonDefinition): DungeonDefinition {
assertSourceMapTransform(definition);
const review = REVIEW_BY_DUNGEON.get("utgarde-pinnacle")!;
const svalaBase = objectiveForEntry(definition, 26_668);
const gortokBase = objectiveForEntry(definition, 26_687);
const skadiBase = objectiveForEntry(definition, 26_693);
const ymiron = objectiveForEntry(definition, 26_861);
const svala = eventObjective(definition, svalaBase.id);
const gortok = eventObjective(definition, gortokBase.id);
const skadi = eventObjective(definition, skadiBase.id);
const entities = ensureEntities(definition, [
{ entry: 27_281, name: "Ritual Channeler", level: 80, role: "caster" },
{ entry: 26_683, name: "Frenzied Worgen", level: 80, role: "melee" },
{ entry: 26_684, name: "Ravenous Furbolg", level: 80, role: "caster" },
{ entry: 26_690, name: "Ymirjar Warrior", level: 80, role: "melee" },
{ entry: 26_691, name: "Ymirjar Witch Doctor", level: 80, role: "caster" },
{ entry: 26_692, name: "Ymirjar Harpooner", level: 80, role: "melee" },
]);
const svalaEntityId = entityIdForEntry(entities, 26_668);
const gortokEntityId = entityIdForEntry(entities, 26_687);
const skadiEntityId = entityIdForEntry(entities, 26_693);
if (!svalaEntityId || !gortokEntityId || !skadiEntityId) {
throw new Error("utgarde-pinnacle is missing a required event boss entity");
}
const svalaPhaseOne: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "utgarde-pinnacle:svala-phase-one",
spawns: Object.freeze([spawnAt("utgarde-pinnacle:svala-phase-one-actor", svalaEntityId, svala.position)]),
});
const svalaRitual = exactSpawnGroup(
"utgarde-pinnacle:svala-ritual-channelers",
[
{ entry: 27_281, position: sourceMapPosition([296.42, -355.01, 90.94]) },
{ entry: 27_281, position: sourceMapPosition([302.36, -352.01, 90.54]) },
{ entry: 27_281, position: sourceMapPosition([291.39, -350.89, 90.54]) },
],
entities,
);
const svalaPhaseTwo: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "utgarde-pinnacle:svala-phase-two",
spawns: Object.freeze([spawnAt("utgarde-pinnacle:svala-phase-two-actor", svalaEntityId, svala.position)]),
});
const stasisCenter = sourceMapPosition([275.4, -453, 110]);
const worgenGroup = spawnGroup(
"utgarde-pinnacle:stasis-worgen",
[26_683],
entities,
stasisCenter,
);
const furbolgGroup = spawnGroup(
"utgarde-pinnacle:stasis-furbolg",
[26_684],
entities,
stasisCenter,
3,
);
const gortokGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "utgarde-pinnacle:gortok",
spawns: Object.freeze([spawnAt("utgarde-pinnacle:gortok-actor", gortokEntityId, gortok.position)]),
});
const firstWavePositions = [
[458.532, -516.254, 104.617], [429.424, -517.562, 104.894],
[394.496, -514.614, 104.724], [362.286, -515.877, 104.754],
[333.537, -514.794, 104.478], [457.611, -508.836, 104.401],
[427.403, -510.772, 104.880], [392.511, -507.943, 104.743],
[362.951, -508.412, 104.722], [333.536, -506.081, 104.426],
[478.310, -511.049, 104.724], [482.250, -514.127, 104.723],
[481.388, -507.109, 104.724],
] as const;
const firstWave = exactSpawnGroup(
"utgarde-pinnacle:skadi-first-wave",
firstWavePositions.map((position, index) => ({
entry: index < 10 ? 26_690 : index === 10 ? 26_691 : 26_692,
position: sourceMapPosition(position),
})),
entities,
);
const reinforcementOne = spawnGroup(
"utgarde-pinnacle:skadi-reinforcement-one",
[26_690, 26_691, 26_692],
entities,
sourceMapPosition([477.581, -484.559, 104.822]),
);
const reinforcementTwo = spawnGroup(
"utgarde-pinnacle:skadi-reinforcement-two",
[26_690, 26_690, 26_692],
entities,
sourceMapPosition([477.581, -484.559, 104.822]),
);
const skadiGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "utgarde-pinnacle:skadi-ground",
spawns: Object.freeze([spawnAt("utgarde-pinnacle:skadi-ground-actor", skadiEntityId, skadi.position)]),
});
const mirror = reviewedObject(
oneImportedObject(definition, 191_745),
"utgarde-pinnacle:reviewed-svala-mirror",
"Svala's Mirror",
"ritual",
"inactive",
VISUAL_STATES,
UTGARDE_PINNACLE_FILES,
);
const stasisSource = oneImportedObject(definition, 188_593);
const stasisSphere = reviewedObject(
stasisSource,
"utgarde-pinnacle:reviewed-stasis-sphere",
"Stasis Generator",
"switch",
"unavailable",
INTERACTION_STATES,
UTGARDE_PINNACLE_FILES,
{
prompt: "Activate the stasis generator",
range: 4,
signalId: "utgarde-pinnacle:stasis-start",
allowedStates: ["ready"],
oneShot: true,
},
);
const skadiDoor = reviewedObject(
oneImportedObject(definition, 192_173),
"utgarde-pinnacle:reviewed-skadi-door",
"Skadi Passage Door",
"door",
"closed",
CLOSED_OPEN_STATES,
UTGARDE_PINNACLE_FILES,
);
const ymironDoor = reviewedObject(
oneImportedObject(definition, 192_174),
"utgarde-pinnacle:reviewed-ymiron-door",
"Ymiron Door",
"door",
"closed",
CLOSED_OPEN_STATES,
UTGARDE_PINNACLE_FILES,
);
const svalaStart = authoredInteraction(
"utgarde-pinnacle:svala-start",
"Svala's Ritual",
"Confront Svala",
svala.position,
UTGARDE_PINNACLE_FILES,
"ready",
"ritual",
);
const skadiStart = authoredInteraction(
"utgarde-pinnacle:skadi-start",
"Skadi's Gauntlet",
"Enter Skadi's gauntlet",
sourceMapPosition([477.581, -484.559, 104.822]),
UTGARDE_PINNACLE_FILES,
);
const harpoonPositions = [
sourceMapPosition([341.741, -510.0, 104.670]),
sourceMapPosition([341.741, -516.955, 104.670]),
sourceMapPosition([341.741, -523.5, 104.670]),
];
const harpoons = harpoonPositions.map((position, index) => authoredInteraction(
`utgarde-pinnacle:harpoon-${index + 1}`,
`Harpoon Control ${index + 1}`,
`Launch harpoon ${index + 1} at Grauf`,
position,
UTGARDE_PINNACLE_FILES,
"unavailable",
"switch",
));
const rules: DungeonEncounterRule[] = [
{
id: "utgarde-pinnacle-resume-complete",
trigger: { kind: "tick" },
once: false,
priority: 100,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "dungeon-complete", equals: true },
],
actions: [{ id: "utgarde-pinnacle-resume-complete-phase", kind: "set-phase", phase: "complete" }],
},
{
id: "utgarde-pinnacle-resume-after-skadi",
trigger: { kind: "tick" },
once: false,
priority: 60,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: skadi.id, equals: true },
{ kind: "objective-complete", objectiveId: ymiron.id, equals: false },
],
actions: [
{ id: "utgarde-pinnacle-resume-open-skadi-door", kind: "set-world-object-state", objectId: skadiDoor.id, state: "open" },
{ id: "utgarde-pinnacle-resume-enter-ymiron-ready", kind: "set-phase", phase: "ymiron-ready" },
],
},
{
id: "utgarde-pinnacle-resume-after-gortok",
trigger: { kind: "tick" },
once: false,
priority: 50,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: gortok.id, equals: true },
{ kind: "objective-complete", objectiveId: skadi.id, equals: false },
],
actions: [
{ id: "utgarde-pinnacle-resume-use-stasis", kind: "set-world-object-state", objectId: stasisSphere.id, state: "used" },
{ id: "utgarde-pinnacle-resume-ready-skadi", kind: "set-world-object-state", objectId: skadiStart.id, state: "ready" },
{ id: "utgarde-pinnacle-resume-enter-skadi-ready", kind: "set-phase", phase: "skadi-ready" },
],
},
{
id: "utgarde-pinnacle-resume-after-svala",
trigger: { kind: "tick" },
once: false,
priority: 40,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: svala.id, equals: true },
{ kind: "objective-complete", objectiveId: gortok.id, equals: false },
],
actions: [
{ id: "utgarde-pinnacle-resume-use-svala-start", kind: "set-world-object-state", objectId: svalaStart.id, state: "used" },
{ id: "utgarde-pinnacle-resume-ready-stasis", kind: "set-world-object-state", objectId: stasisSphere.id, state: "ready" },
{ id: "utgarde-pinnacle-resume-enter-gortok-ready", kind: "set-phase", phase: "gortok-ready" },
],
},
{
id: "utgarde-pinnacle-resume-before-svala",
trigger: { kind: "tick" },
once: false,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: svala.id, equals: false },
],
actions: [
{ id: "utgarde-pinnacle-resume-ready-svala", kind: "set-world-object-state", objectId: svalaStart.id, state: "ready" },
{ id: "utgarde-pinnacle-resume-enter-svala-ready", kind: "set-phase", phase: "svala-ready" },
],
},
{
id: "utgarde-pinnacle-start-svala",
trigger: { kind: "interact", objectId: svalaStart.id },
guards: [{ kind: "phase", phases: ["svala-ready"] }],
actions: [
{ id: "utgarde-pinnacle-use-svala-start", kind: "set-world-object-state", objectId: svalaStart.id, state: "used" },
{ id: "utgarde-pinnacle-activate-svala-one", kind: "activate-spawn-group", spawnGroupId: svalaPhaseOne.id },
{ id: "utgarde-pinnacle-activate-svala-mirror", kind: "set-world-object-state", objectId: mirror.id, state: "active" },
{ id: "utgarde-pinnacle-enter-svala-one", kind: "set-phase", phase: "svala-phase-one-active" },
],
},
{
id: "utgarde-pinnacle-svala-ritual",
trigger: { kind: "entity-health", entityId: svalaPhaseOne.spawns[0].id, atOrBelowPercent: 50 },
guards: [{ kind: "phase", phases: ["svala-phase-one-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-svala-one", kind: "deactivate-spawn-group", spawnGroupId: svalaPhaseOne.id },
{ id: "utgarde-pinnacle-activate-svala-channelers", kind: "activate-spawn-group", spawnGroupId: svalaRitual.id },
{ id: "utgarde-pinnacle-enter-svala-ritual", kind: "set-phase", phase: "svala-ritual-active" },
],
},
{
id: "utgarde-pinnacle-svala-ritual-cleared",
trigger: { kind: "entities-dead", entityIds: svalaRitual.spawns.map((spawn) => spawn.id), timeoutMs: 90_000 },
guards: [{ kind: "phase", phases: ["svala-ritual-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-svala-channelers", kind: "deactivate-spawn-group", spawnGroupId: svalaRitual.id },
{ id: "utgarde-pinnacle-activate-svala-two", kind: "activate-spawn-group", spawnGroupId: svalaPhaseTwo.id },
{ id: "utgarde-pinnacle-enter-svala-two", kind: "set-phase", phase: "svala-phase-two-active" },
],
},
{
id: "utgarde-pinnacle-svala-defeated",
trigger: { kind: "entities-dead", entityIds: svalaPhaseTwo.spawns.map((spawn) => spawn.id), timeoutMs: 240_000 },
guards: [{ kind: "phase", phases: ["svala-phase-two-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-svala-two", kind: "deactivate-spawn-group", spawnGroupId: svalaPhaseTwo.id },
...objectiveActions("utgarde-pinnacle-svala", svala),
{ id: "utgarde-pinnacle-complete-svala-mirror", kind: "set-world-object-state", objectId: mirror.id, state: "complete", persistence: "run" },
{ id: "utgarde-pinnacle-ready-stasis", kind: "set-world-object-state", objectId: stasisSphere.id, state: "ready", persistence: "run" },
{ id: "utgarde-pinnacle-enter-gortok-ready", kind: "set-phase", phase: "gortok-ready" },
],
},
{
id: "utgarde-pinnacle-start-stasis",
trigger: { kind: "interact", objectId: stasisSphere.id },
guards: [{ kind: "phase", phases: ["gortok-ready"] }],
actions: [
{ id: "utgarde-pinnacle-use-stasis", kind: "set-world-object-state", objectId: stasisSphere.id, state: "used" },
{ id: "utgarde-pinnacle-activate-worgen", kind: "activate-spawn-group", spawnGroupId: worgenGroup.id },
{ id: "utgarde-pinnacle-enter-worgen", kind: "set-phase", phase: "stasis-worgen-active" },
],
},
{
id: "utgarde-pinnacle-worgen-defeated",
trigger: { kind: "entities-dead", entityIds: worgenGroup.spawns.map((spawn) => spawn.id), timeoutMs: 180_000 },
guards: [{ kind: "phase", phases: ["stasis-worgen-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-worgen", kind: "deactivate-spawn-group", spawnGroupId: worgenGroup.id },
{ id: "utgarde-pinnacle-activate-furbolg", kind: "activate-spawn-group", spawnGroupId: furbolgGroup.id },
{ id: "utgarde-pinnacle-enter-furbolg", kind: "set-phase", phase: "stasis-furbolg-active" },
],
},
{
id: "utgarde-pinnacle-furbolg-defeated",
trigger: { kind: "entities-dead", entityIds: furbolgGroup.spawns.map((spawn) => spawn.id), timeoutMs: 180_000 },
guards: [{ kind: "phase", phases: ["stasis-furbolg-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-furbolg", kind: "deactivate-spawn-group", spawnGroupId: furbolgGroup.id },
{ id: "utgarde-pinnacle-activate-gortok", kind: "activate-spawn-group", spawnGroupId: gortokGroup.id },
{ id: "utgarde-pinnacle-enter-gortok", kind: "set-phase", phase: "gortok-active" },
],
},
{
id: "utgarde-pinnacle-gortok-defeated",
trigger: { kind: "entities-dead", entityIds: gortokGroup.spawns.map((spawn) => spawn.id), timeoutMs: 300_000 },
guards: [{ kind: "phase", phases: ["gortok-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-gortok", kind: "deactivate-spawn-group", spawnGroupId: gortokGroup.id },
...objectiveActions("utgarde-pinnacle-gortok", gortok),
{ id: "utgarde-pinnacle-ready-skadi", kind: "set-world-object-state", objectId: skadiStart.id, state: "ready", persistence: "run" },
{ id: "utgarde-pinnacle-enter-skadi-ready", kind: "set-phase", phase: "skadi-ready" },
],
},
{
id: "utgarde-pinnacle-start-skadi",
trigger: { kind: "interact", objectId: skadiStart.id },
guards: [{ kind: "phase", phases: ["skadi-ready"] }],
actions: [
{ id: "utgarde-pinnacle-use-skadi-start", kind: "set-world-object-state", objectId: skadiStart.id, state: "used" },
{ id: "utgarde-pinnacle-activate-first-wave", kind: "activate-spawn-group", spawnGroupId: firstWave.id },
{ id: "utgarde-pinnacle-enter-first-wave", kind: "set-phase", phase: "skadi-first-wave-active" },
],
},
{
id: "utgarde-pinnacle-first-wave-cleared",
trigger: { kind: "entities-dead", entityIds: firstWave.spawns.map((spawn) => spawn.id), timeoutMs: 300_000 },
guards: [{ kind: "phase", phases: ["skadi-first-wave-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-first-wave", kind: "deactivate-spawn-group", spawnGroupId: firstWave.id },
{ id: "utgarde-pinnacle-ready-harpoon-one", kind: "set-world-object-state", objectId: harpoons[0].id, state: "ready" },
{ id: "utgarde-pinnacle-enter-harpoon-one", kind: "set-phase", phase: "harpoon-one-ready" },
],
},
{
id: "utgarde-pinnacle-fire-harpoon-one",
trigger: { kind: "interact", objectId: harpoons[0].id },
guards: [{ kind: "phase", phases: ["harpoon-one-ready"] }],
actions: [
{ id: "utgarde-pinnacle-use-harpoon-one", kind: "set-world-object-state", objectId: harpoons[0].id, state: "used" },
{ id: "utgarde-pinnacle-set-harpoon-count-one", kind: "set-counter", counterId: "harpoon-hits", value: 1 },
{ id: "utgarde-pinnacle-activate-reinforcement-one", kind: "activate-spawn-group", spawnGroupId: reinforcementOne.id },
{ id: "utgarde-pinnacle-enter-reinforcement-one", kind: "set-phase", phase: "skadi-reinforcement-one-active" },
],
},
{
id: "utgarde-pinnacle-reinforcement-one-cleared",
trigger: { kind: "entities-dead", entityIds: reinforcementOne.spawns.map((spawn) => spawn.id), timeoutMs: 180_000 },
guards: [{ kind: "phase", phases: ["skadi-reinforcement-one-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-reinforcement-one", kind: "deactivate-spawn-group", spawnGroupId: reinforcementOne.id },
{ id: "utgarde-pinnacle-ready-harpoon-two", kind: "set-world-object-state", objectId: harpoons[1].id, state: "ready" },
{ id: "utgarde-pinnacle-enter-harpoon-two", kind: "set-phase", phase: "harpoon-two-ready" },
],
},
{
id: "utgarde-pinnacle-fire-harpoon-two",
trigger: { kind: "interact", objectId: harpoons[1].id },
guards: [{ kind: "phase", phases: ["harpoon-two-ready"] }],
actions: [
{ id: "utgarde-pinnacle-use-harpoon-two", kind: "set-world-object-state", objectId: harpoons[1].id, state: "used" },
{ id: "utgarde-pinnacle-set-harpoon-count-two", kind: "set-counter", counterId: "harpoon-hits", value: 2 },
{ id: "utgarde-pinnacle-activate-reinforcement-two", kind: "activate-spawn-group", spawnGroupId: reinforcementTwo.id },
{ id: "utgarde-pinnacle-enter-reinforcement-two", kind: "set-phase", phase: "skadi-reinforcement-two-active" },
],
},
{
id: "utgarde-pinnacle-reinforcement-two-cleared",
trigger: { kind: "entities-dead", entityIds: reinforcementTwo.spawns.map((spawn) => spawn.id), timeoutMs: 180_000 },
guards: [{ kind: "phase", phases: ["skadi-reinforcement-two-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-reinforcement-two", kind: "deactivate-spawn-group", spawnGroupId: reinforcementTwo.id },
{ id: "utgarde-pinnacle-ready-harpoon-three", kind: "set-world-object-state", objectId: harpoons[2].id, state: "ready" },
{ id: "utgarde-pinnacle-enter-harpoon-three", kind: "set-phase", phase: "harpoon-three-ready" },
],
},
{
id: "utgarde-pinnacle-fire-harpoon-three",
trigger: { kind: "interact", objectId: harpoons[2].id },
guards: [{ kind: "phase", phases: ["harpoon-three-ready"] }],
actions: [
{ id: "utgarde-pinnacle-use-harpoon-three", kind: "set-world-object-state", objectId: harpoons[2].id, state: "used" },
{ id: "utgarde-pinnacle-set-harpoon-count-three", kind: "set-counter", counterId: "harpoon-hits", value: 3 },
{ id: "utgarde-pinnacle-activate-skadi-ground", kind: "activate-spawn-group", spawnGroupId: skadiGroup.id },
{ id: "utgarde-pinnacle-enter-skadi-ground", kind: "set-phase", phase: "skadi-ground-active" },
],
},
{
id: "utgarde-pinnacle-skadi-defeated",
trigger: { kind: "entities-dead", entityIds: skadiGroup.spawns.map((spawn) => spawn.id), timeoutMs: 300_000 },
guards: [{ kind: "phase", phases: ["skadi-ground-active"] }],
actions: [
{ id: "utgarde-pinnacle-despawn-skadi", kind: "deactivate-spawn-group", spawnGroupId: skadiGroup.id },
...objectiveActions("utgarde-pinnacle-skadi", skadi),
{ id: "utgarde-pinnacle-open-skadi-door", kind: "set-world-object-state", objectId: skadiDoor.id, state: "open", persistence: "run" },
{ id: "utgarde-pinnacle-enter-ymiron-ready", kind: "set-phase", phase: "ymiron-ready" },
],
},
{
id: "utgarde-pinnacle-ymiron-defeated",
trigger: { kind: "entities-dead", entityIds: [ymiron.id] },
actions: [
...objectiveActions("utgarde-pinnacle-ymiron", ymiron),
{ id: "utgarde-pinnacle-open-ymiron-door", kind: "set-world-object-state", objectId: ymironDoor.id, state: "open", persistence: "run" },
],
},
allRequiredCompleteRule("utgarde-pinnacle", [svala, gortok, skadi, ymiron]),
];
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "utgarde-pinnacle:wrath-batch-two-instance",
dungeonId: definition.id,
initialPhase: "svala-ready",
initialState: {
flags: { "dungeon-complete": false },
counters: { "harpoon-hits": 0 },
worldObjectStates: {
[mirror.id]: "inactive",
[stasisSphere.id]: "unavailable",
[skadiDoor.id]: "closed",
[ymironDoor.id]: "closed",
[svalaStart.id]: "ready",
[skadiStart.id]: "unavailable",
[harpoons[0].id]: "unavailable",
[harpoons[1].id]: "unavailable",
[harpoons[2].id]: "unavailable",
},
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(review),
rules: Object.freeze(rules),
});
const eventObjectives = [svala, gortok, skadi];
const eventObjectiveIds = new Set(eventObjectives.map((objective) => objective.id));
return Object.freeze({
...definition,
entities,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => !eventObjectiveIds.has(spawn.id))),
bosses: replaceObjectives(definition.bosses, eventObjectives),
objectiveOrder: replaceObjectives(definition.objectiveOrder, eventObjectives),
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []),
svalaPhaseOne,
svalaRitual,
svalaPhaseTwo,
worgenGroup,
furbolgGroup,
gortokGroup,
firstWave,
reinforcementOne,
reinforcementTwo,
skadiGroup,
]),
serverGameObjects: replaceImportedObjects(
definition,
[mirror, stasisSphere, skadiDoor, ymironDoor],
[svalaStart, skadiStart, ...harpoons],
),
validation: appendValidationWarning(
definition,
"Utgarde Pinnacle now source-gates Svala, Gortok, and Skadi through finite ritual, stasis, gauntlet, and harpoon stages; heroic stasis, Grauf vehicle flight, and cinematics remain explicit adaptations.",
),
});
}
function buildGundrak(definition: DungeonDefinition): DungeonDefinition {
assertSourceMapTransform(definition);
const review = REVIEW_BY_DUNGEON.get("gundrak")!;
const sladRan = objectiveForEntry(definition, 29_304);
const moorabi = objectiveForEntry(definition, 29_305);
const colossus = objectiveForEntry(definition, 29_573);
const galDarah = objectiveForEntry(definition, 29_306);
const eckBase = objectiveForEntry(definition, 29_932);
const eck = eventObjective(definition, eckBase.id);
const baseEntities = ensureEntities(definition, []);
const eckEntityId = entityIdForEntry(baseEntities, 29_932);
if (!eckEntityId) throw new Error("gundrak is missing Eck the Ferocious entry 29932");
const sourceEck = baseEntities[eckEntityId];
if (!sourceEck.combat) throw new Error("gundrak Eck the Ferocious is missing combat data");
if (!sourceEck.visual.model) throw new Error("gundrak Eck the Ferocious is missing its source model");
const entities = Object.freeze({
...baseEntities,
[eckEntityId]: Object.freeze({
...sourceEck,
visual: Object.freeze({
...sourceEck.visual,
model: Object.freeze({
...sourceEck.visual.model,
// The skinned Oracle bind pose extends far below its animated root.
// Treating that minimum as the floor lifted Eck out of the camera.
groundOffset: 0,
labelHeight: 7,
}),
}),
combat: Object.freeze({
...sourceEck.combat,
mechanicIds: Object.freeze(ECK_ATTACKS.slice(1).map((attack) => attack.id)),
attacks: ECK_ATTACKS,
}),
}),
});
const eckGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "gundrak:eck",
spawns: Object.freeze([spawnAt("gundrak:eck-actor", eckEntityId, eck.position, [0, 0], 2)]),
});
const altarSpecs = [
{ templateId: 192_518, id: "slad-ran", name: "Altar of Slad'ran", objective: sladRan, statueTemplateId: 192_564 },
{ templateId: 192_519, id: "moorabi", name: "Altar of Moorabi", objective: moorabi, statueTemplateId: 192_565 },
{ templateId: 192_520, id: "drakkari", name: "Altar of the Drakkari Colossus", objective: colossus, statueTemplateId: 192_567 },
] as const;
const altars = altarSpecs.map((spec) => reviewedObject(
oneImportedObject(definition, spec.templateId),
`gundrak:reviewed-altar-${spec.id}`,
spec.name,
"switch",
"unavailable",
INTERACTION_STATES,
GUNDRAK_FILES,
{
prompt: `Activate ${spec.name}`,
range: 4,
signalId: `gundrak:altar-${spec.id}`,
allowedStates: ["ready"],
oneShot: true,
},
));
const statues = altarSpecs.map((spec) => reviewedObject(
oneImportedObject(definition, spec.statueTemplateId),
`gundrak:reviewed-statue-${spec.id}`,
`${spec.name} Statue`,
"ritual",
"inactive",
VISUAL_STATES,
GUNDRAK_FILES,
));
const galDarahStatue = reviewedObject(
oneImportedObject(definition, 192_566),
"gundrak:reviewed-statue-gal-darah",
"Statue of Gal'darah",
"ritual",
"inactive",
VISUAL_STATES,
GUNDRAK_FILES,
);
const bridge = reviewedObject(
oneImportedObject(definition, 193_188),
"gundrak:reviewed-bridge",
"Gundrak Bridge",
"bridge",
"closed",
CLOSED_OPEN_STATES,
GUNDRAK_FILES,
);
const bridgeCollision = reviewedObject(
oneImportedObject(definition, 192_633),
"gundrak:reviewed-bridge-collision",
"Gundrak Bridge Collision",
"gate",
"closed",
CLOSED_OPEN_STATES,
GUNDRAK_FILES,
);
const eckDoor = reviewedObject(
oneImportedObject(definition, 192_632),
"gundrak:reviewed-eck-door",
"Eck Passage Door",
"door",
"closed",
CLOSED_OPEN_STATES,
GUNDRAK_FILES,
);
const eckUnderwaterGate = reviewedObject(
oneImportedObject(definition, 192_569),
"gundrak:reviewed-eck-underwater-gate",
"Eck Underwater Gate",
"gate",
"closed",
CLOSED_OPEN_STATES,
GUNDRAK_FILES,
);
const galDoorTemplates = [192_568, 193_208, 193_209] as const;
const galDoors = galDoorTemplates.map((templateId, index) => reviewedObject(
oneImportedObject(definition, templateId),
`gundrak:reviewed-gal-darah-door-${index + 1}`,
`Gal'darah Door ${index + 1}`,
"door",
index === 0 ? "open" : "closed",
CLOSED_OPEN_STATES,
GUNDRAK_FILES,
));
const rules: DungeonEncounterRule[] = [
{
id: "gundrak-resume-complete",
trigger: { kind: "tick" },
once: false,
priority: 100,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "dungeon-complete", equals: true },
],
actions: [{ id: "gundrak-resume-complete-phase", kind: "set-phase", phase: "complete" }],
},
{
id: "gundrak-resume-eck",
trigger: { kind: "tick" },
once: false,
priority: 80,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: moorabi.id, equals: true },
{ kind: "objective-complete", objectiveId: eck.id, equals: false },
],
actions: [
{ id: "gundrak-resume-open-eck-door", kind: "set-world-object-state", objectId: eckDoor.id, state: "open" },
{ id: "gundrak-resume-activate-eck", kind: "activate-spawn-group", spawnGroupId: eckGroup.id },
],
},
{
id: "gundrak-resume-bridge-open",
trigger: { kind: "tick" },
once: false,
priority: 60,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "bridge-open", equals: true },
],
actions: [
{ id: "gundrak-resume-open-bridge", kind: "set-world-object-state", objectId: bridge.id, state: "open" },
{ id: "gundrak-resume-open-bridge-collision", kind: "set-world-object-state", objectId: bridgeCollision.id, state: "open" },
{ id: "gundrak-resume-enter-gal-ready", kind: "set-phase", phase: "gal-darah-ready" },
],
},
{
id: "gundrak-resume-bridge-delay",
trigger: { kind: "tick" },
once: false,
priority: 50,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "bridge-open", equals: false },
{ kind: "counter", counterId: "keys-in", comparison: "at-least", value: 3 },
],
actions: [{ id: "gundrak-resume-enter-bridge-delay", kind: "set-phase", phase: "bridge-activating" }],
},
{
id: "gundrak-resume-altars",
trigger: { kind: "tick" },
once: false,
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "counter", counterId: "keys-in", comparison: "at-most", value: 2 },
],
actions: [{ id: "gundrak-resume-enter-altars", kind: "set-phase", phase: "altar-progression" }],
},
{
id: "gundrak-slad-ran-defeated",
trigger: { kind: "entities-dead", entityIds: [sladRan.id] },
actions: [
...objectiveActions("gundrak-slad-ran", sladRan),
{ id: "gundrak-ready-slad-ran-altar", kind: "set-world-object-state", objectId: altars[0].id, state: "ready", persistence: "run" },
],
},
{
id: "gundrak-moorabi-defeated",
trigger: { kind: "entities-dead", entityIds: [moorabi.id] },
actions: [
...objectiveActions("gundrak-moorabi", moorabi),
{ id: "gundrak-ready-moorabi-altar", kind: "set-world-object-state", objectId: altars[1].id, state: "ready", persistence: "run" },
{ id: "gundrak-open-eck-door", kind: "set-world-object-state", objectId: eckDoor.id, state: "open", persistence: "run" },
{ id: "gundrak-activate-eck", kind: "activate-spawn-group", spawnGroupId: eckGroup.id },
],
},
{
id: "gundrak-colossus-defeated",
trigger: { kind: "entities-dead", entityIds: [colossus.id] },
actions: [
...objectiveActions("gundrak-colossus", colossus),
{ id: "gundrak-ready-drakkari-altar", kind: "set-world-object-state", objectId: altars[2].id, state: "ready", persistence: "run" },
],
},
...altars.map((altar, index): DungeonEncounterRule => ({
id: `gundrak-use-altar-${altarSpecs[index].id}`,
trigger: { kind: "interact", objectId: altar.id },
guards: [
{ kind: "phase", phases: ["altar-progression"] },
{ kind: "objective-complete", objectiveId: altarSpecs[index].objective.id, equals: true },
{ kind: "world-object-state", objectId: altar.id, states: ["ready"] },
],
actions: [
{ id: `gundrak-mark-altar-${altarSpecs[index].id}-used`, kind: "set-world-object-state", objectId: altar.id, state: "used", persistence: "run" },
{ id: `gundrak-activate-statue-${altarSpecs[index].id}`, kind: "set-world-object-state", objectId: statues[index].id, state: "active", persistence: "run" },
{ id: `gundrak-increment-key-${altarSpecs[index].id}`, kind: "increment-counter", counterId: "keys-in", persistence: "run" },
],
})),
{
id: "gundrak-all-altars-used",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["altar-progression"] },
{ kind: "counter", counterId: "keys-in", comparison: "at-least", value: 3 },
{ kind: "flag", flagId: "bridge-open", equals: false },
],
actions: [{ id: "gundrak-enter-bridge-delay", kind: "set-phase", phase: "bridge-activating" }],
},
{
id: "gundrak-activate-bridge",
trigger: { kind: "elapsed", afterMs: 5_000 },
guards: [{ kind: "phase", phases: ["bridge-activating"] }],
actions: [
...statues.map((statue, index) => ({
id: `gundrak-complete-statue-${index + 1}`,
kind: "set-world-object-state" as const,
objectId: statue.id,
state: "complete",
persistence: "run" as const,
})),
{ id: "gundrak-activate-gal-darah-statue", kind: "set-world-object-state", objectId: galDarahStatue.id, state: "complete", persistence: "run" },
{ id: "gundrak-open-bridge", kind: "set-world-object-state", objectId: bridge.id, state: "open", persistence: "run" },
{ id: "gundrak-open-bridge-collision", kind: "set-world-object-state", objectId: bridgeCollision.id, state: "open", persistence: "run" },
{ id: "gundrak-record-bridge-open", kind: "set-flag", flagId: "bridge-open", value: true, persistence: "run" },
{ id: "gundrak-bridge-checkpoint", kind: "set-checkpoint", checkpointId: "gundrak:bridge", persistence: "checkpoint" },
{ id: "gundrak-enter-gal-darah-ready", kind: "set-phase", phase: "gal-darah-ready" },
],
},
{
id: "gundrak-eck-defeated",
trigger: {
kind: "entities-dead",
entityIds: eckGroup.spawns.map((spawn) => spawn.id),
timeoutMs: 300_000,
},
guards: [{ kind: "spawn-group-active", spawnGroupId: eckGroup.id, equals: true }],
actions: [
{ id: "gundrak-despawn-eck", kind: "deactivate-spawn-group", spawnGroupId: eckGroup.id },
...objectiveActions("gundrak-eck", eck),
{ id: "gundrak-open-eck-underwater-gate", kind: "set-world-object-state", objectId: eckUnderwaterGate.id, state: "open", persistence: "run" },
],
},
{
id: "gundrak-gal-darah-engaged",
trigger: { kind: "entity-engaged", entityId: galDarah.id },
guards: [{ kind: "flag", flagId: "bridge-open", equals: true }],
actions: [{ id: "gundrak-close-gal-darah-room", kind: "set-world-object-state", objectId: galDoors[0].id, state: "closed" }],
},
{
id: "gundrak-gal-darah-defeated",
trigger: { kind: "entities-dead", entityIds: [galDarah.id] },
actions: [
...objectiveActions("gundrak-gal-darah", galDarah),
...galDoors.map((door, index) => ({
id: `gundrak-open-gal-darah-door-${index + 1}`,
kind: "set-world-object-state" as const,
objectId: door.id,
state: "open",
persistence: "run" as const,
})),
],
},
{
id: "gundrak-all-required-complete",
trigger: { kind: "tick" },
guards: [
...[sladRan, moorabi, colossus, galDarah, eck]
.filter((objective) => !objective.optional)
.map((objective) => ({
kind: "objective-complete" as const,
objectiveId: objective.id,
equals: true,
})),
{ kind: "flag", flagId: "bridge-open", equals: true },
],
actions: [
{ id: "gundrak-mark-dungeon-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "gundrak-enter-complete", kind: "set-phase", phase: "complete", persistence: "run" },
],
},
];
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "gundrak:wrath-batch-two-instance",
dungeonId: definition.id,
initialPhase: "altar-progression",
initialState: {
flags: { "dungeon-complete": false, "bridge-open": false },
counters: { "keys-in": 0 },
worldObjectStates: {
[altars[0].id]: "unavailable",
[altars[1].id]: "unavailable",
[altars[2].id]: "unavailable",
[statues[0].id]: "inactive",
[statues[1].id]: "inactive",
[statues[2].id]: "inactive",
[galDarahStatue.id]: "inactive",
[bridge.id]: "closed",
[bridgeCollision.id]: "closed",
[eckDoor.id]: "closed",
[eckUnderwaterGate.id]: "closed",
[galDoors[0].id]: "open",
[galDoors[1].id]: "closed",
[galDoors[2].id]: "closed",
},
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(review),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== eck.id)),
bosses: replaceObjectives(definition.bosses, [eck]),
objectiveOrder: replaceObjectives(definition.objectiveOrder, [eck]),
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), eckGroup]),
serverGameObjects: replaceImportedObjects(
definition,
[
...altars,
...statues,
galDarahStatue,
bridge,
bridgeCollision,
eckDoor,
eckUnderwaterGate,
...galDoors,
],
),
validation: appendValidationWarning(
definition,
"Gundrak now has source-reviewed boss-gated altars, five-second bridge activation, Gal'darah doors, and heroic-mask Eck spawning; native rotations and the Ruins Dweller formation trigger remain explicit adaptations.",
),
});
}
export function withWowWrathEncounterBatchTwoContent(definition: DungeonDefinition): DungeonDefinition {
if (definition.encounters?.some((encounter) => encounter.id === `${definition.id}:wrath-batch-two-instance`)) {
return definition;
}
if (definition.id === "halls-of-stone") return buildHallsOfStone(definition);
if (definition.id === "pit-of-saron") return buildPitOfSaron(definition);
if (definition.id === "utgarde-pinnacle") return buildUtgardePinnacle(definition);
if (definition.id === "gundrak") return buildGundrak(definition);
return definition;
}