forked from phenom/healer-man
1948 lines
85 KiB
TypeScript
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;
|
|
}
|