import type { DungeonBossObjective, DungeonDefinition, DungeonEncounterSpawnGroupDefinition, Vector3Tuple, } from "./dungeonTypes"; import type { DungeonEncounterAction, DungeonEncounterDefinition, DungeonEncounterRule, } from "./dungeonEncounterTypes"; import type { DungeonWorldObjectDefinition } from "./dungeonWorldObjects"; import type { PopulationDefinitionMap, PopulationEntityDefinition, StaticMobSpawnDefinition, } from "./mobPopulation"; import type { EnemyAttackDefinition } from "./enemyAttacks"; export interface WowTbcEncounterSourceFile { readonly path: string; readonly sha256: string; readonly anchors: readonly string[]; } export interface WowTbcEncounterContentReview { readonly dungeonId: | "blood-furnace" | "shattered-halls" | "hellfire-ramparts" | "botanica" | "sethekk-halls"; readonly fidelity: "source-backed-adapted"; readonly sourceFiles: readonly WowTbcEncounterSourceFile[]; 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; readonly archetype?: "humanoid" | "plant" | "winged"; readonly attacks?: readonly EnemyAttackDefinition[]; } const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Outland"; const DB_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-world"; const EPOCH_SPELL_DBC = "../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"; const BLOOD_FURNACE_FILES = Object.freeze([ `${SOURCE_ROOT}/HellfireCitadel/BloodFurnace/instance_blood_furnace.cpp`, `${SOURCE_ROOT}/HellfireCitadel/BloodFurnace/boss_broggok.cpp`, `${SOURCE_ROOT}/HellfireCitadel/BloodFurnace/blood_furnace.h`, ]); const SHATTERED_HALLS_FILES = Object.freeze([ `${SOURCE_ROOT}/HellfireCitadel/ShatteredHalls/instance_shattered_halls.cpp`, `${SOURCE_ROOT}/HellfireCitadel/ShatteredHalls/boss_nethekurse.cpp`, `${SOURCE_ROOT}/HellfireCitadel/ShatteredHalls/boss_porung.cpp`, `${SOURCE_ROOT}/HellfireCitadel/ShatteredHalls/boss_warchief_kargath_bladefist.cpp`, `${SOURCE_ROOT}/HellfireCitadel/ShatteredHalls/shattered_halls.h`, ]); const HELLFIRE_RAMPARTS_FILES = Object.freeze([ `${SOURCE_ROOT}/HellfireCitadel/HellfireRamparts/instance_hellfire_ramparts.cpp`, `${SOURCE_ROOT}/HellfireCitadel/HellfireRamparts/boss_vazruden_the_herald.cpp`, `${SOURCE_ROOT}/HellfireCitadel/HellfireRamparts/hellfire_ramparts.h`, ]); const BOTANICA_FILES = Object.freeze([ `${SOURCE_ROOT}/TempestKeep/botanica/instance_the_botanica.cpp`, `${SOURCE_ROOT}/TempestKeep/botanica/boss_high_botanist_freywinn.cpp`, `${SOURCE_ROOT}/TempestKeep/botanica/boss_laj.cpp`, `${SOURCE_ROOT}/TempestKeep/botanica/boss_warp_splinter.cpp`, `${SOURCE_ROOT}/TempestKeep/botanica/the_botanica.h`, ]); const SETHEKK_HALLS_FILES = Object.freeze([ `${SOURCE_ROOT}/Auchindoun/SethekkHalls/instance_sethekk_halls.cpp`, `${SOURCE_ROOT}/Auchindoun/SethekkHalls/boss_darkweaver_syth.cpp`, `${SOURCE_ROOT}/Auchindoun/SethekkHalls/boss_talon_king_ikiss.cpp`, `${SOURCE_ROOT}/Auchindoun/SethekkHalls/boss_anzu.cpp`, `${SOURCE_ROOT}/Auchindoun/SethekkHalls/sethekk_halls.h`, ]); export const WOW_TBC_ENCOUNTER_CONTENT_REVIEWS: readonly WowTbcEncounterContentReview[] = Object.freeze([ { dungeonId: "blood-furnace", fidelity: "source-backed-adapted", sourceFiles: [ { path: BLOOD_FURNACE_FILES[0], sha256: "ec11055881a2cb1a01c299ab9999f91f732ddefe2ed79e3b817734179694e398", anchors: [ "DoorData room/passage bindings (lines 23-31)", "Broggok reset and first-cell activation (lines 110-136)", "four source cell bounds (lines 154-188)", "death-gated cell chain and Broggok release (lines 191-230)", ], }, { path: BLOOD_FURNACE_FILES[1], sha256: "d1c55db2400e0dd5563f5832028dce474ad55ff0fc21471442bee7af3a10750e", anchors: [ "Broggok starts passive and immune (lines 44-50)", "prepare/activate actions (lines 66-91)", "lever starts the encounter (lines 96-110)", ], }, { path: BLOOD_FURNACE_FILES[2], sha256: "3ddeb8493dd32d30f76ca1612ee768d78783842cc3d187c48cc66861b92e0e36", anchors: ["boss, prisoner, lever, and passage ids (lines 29-65)"], }, ], reviewedBehavior: "The exact imported lever opens four exact imported prison cells in source order; each cell's Nascent Fel Orcs must die before Broggok is released, while the Maker and Keli'dan doors retain room/passage semantics.", adaptations: [ "Broggok is removed from the unsafe static population and spawned only after all four finite cell groups clear.", "The source immunity and automatic prisoner aggro are represented by absent actors until each cell activates.", "Reviewed imported doors and the lever keep their exact transforms and bounds but use visible procedural geometry until native client models ship.", "A fifteen-minute per-stage safety timeout prevents missing runtime actors from permanently locking a run.", ], remainingGaps: [ "Broggok's travel animation, poison-cloud summons, lever animation, dialogue, and native door models remain unported.", "The current reducer resets the whole four-cell attempt after a wipe rather than restoring the exact active source cell.", ], }, { dungeonId: "shattered-halls", fidelity: "source-backed-adapted", sourceFiles: [ { path: SHATTERED_HALLS_FILES[0], sha256: "8e1e6178a4c36d5bfd245529625df928a3c1e9178562bf7c03daf29cee725dbc", anchors: [ "Nethekurse passage doors (lines 36-40)", "heroic execution timer start (lines 97-107)", "80/25/15/0-minute prisoner lifecycle (lines 139-166)", "heroic-only area trigger (lines 188-203)", ], }, { path: SHATTERED_HALLS_FILES[1], sha256: "8edd2a23afb4d6db7c72d63b16d86a860c68bd6bee2dc21ece7d09e307092dfd", anchors: [ "four-convert death gate (lines 130-165)", "source intro/aggro cancellation (lines 188-220)", "door/proximity intro hooks (lines 228-274, 329-347)", ], }, { path: SHATTERED_HALLS_FILES[2], sha256: "1d4eae585fc0084c50e5d8b6caf1502643cc2f5739b42e79d668439afdf7341a", anchors: [ "scout proximity starts the gauntlet (lines 94-115)", "45-second repeating zealot summons (lines 127-147)", "archer flame-arrow cycle and empty-player reset (lines 150-239)", ], }, { path: SHATTERED_HALLS_FILES[3], sha256: "6fee37fc2e07515aa887155701c83bb0383ba8a41bb5549e90da4a83e95dd94e", anchors: [ "four exact assassin positions (lines 58-67)", "assassins and portal start on engage (lines 136-150)", "20.6-second repeating portal sequence (lines 225-239)", ], }, { path: SHATTERED_HALLS_FILES[4], sha256: "c4c2e6724b0d7ca9daa659a2f4e64df53e1eae67e913fe901ceb74865d29f850", anchors: ["boss, gauntlet, executioner, and chamber-door ids (lines 27-82)"], }, ], reviewedBehavior: "The four exact Nethekurse-room converts gate a non-static Nethekurse and both chamber passages; Kargath engagement adds the four source-position assassins and one deterministic portal reinforcement cycle.", adaptations: [ "Interacting with the exact entry chamber door replaces the source proximity trigger and shortened roleplay.", "The imported scout, zealots, archers, and normal/heroic Blood Guard or Porung population remains a finite gauntlet pass; repeated 45-second reinforcements and flame-arrow hazards are not fabricated.", "Kargath receives one bounded Heathen/Reaver/Sharpshooter cycle instead of an unbounded 20.6-second portal stream.", "Reviewed chamber doors retain exact source transforms and bounds with temporary procedural collision.", ], remainingGaps: [ "The heroic timed prisoner rescue, faction-dependent prisoners, executioner loot mode, and failure timeline require difficulty guards and protected friendly actors.", "Scout waypoints, repeating gauntlet spawns, flame-arrow ground hazards, Nethekurse dialogue, Kargath blade-dance motion, and repeating portal summons remain incomplete.", ], }, { dungeonId: "hellfire-ramparts", fidelity: "source-backed-adapted", sourceFiles: [ { path: HELLFIRE_RAMPARTS_FILES[0], sha256: "0b70541937db116dfd1d9eba75c0f9b662679edf5f61ca52c88d3198319ebb45", anchors: [ "normal/heroic Fel Iron Chest registration (lines 37-46)", "Vazruden completion unlocks the chest (lines 48-56)", ], }, { path: HELLFIRE_RAMPARTS_FILES[1], sha256: "32fa87c630d4512b62b4f7fb7f22ea82b8e4d93c9cfd5bd17e452e0b6245357c", anchors: [ "exact two-sentry reset summons (lines 69-76)", "both sentries gate the Herald descent (lines 99-111)", "Vazruden and Nazan joint summon (lines 89-96)", "Nazan lands when Vazruden reaches 35 percent (lines 188-225, 293-299)", "Nazan Fireball/Cone schedules and heroic Roar branch (lines 164-223)", "Vazruden four-second Revenge and six-second repeat (lines 270-276)", ], }, { path: HELLFIRE_RAMPARTS_FILES[2], sha256: "5365a041c9bacdb8c1a46f25b8ed90f2a8ff06e1925f7e8a34cd77ee1ea8df93", anchors: ["three encounter, sentry, Herald, Vazruden, Nazan, and chest ids (lines 26-45)"], }, { path: `${DB_ROOT}/creature_template.sql`, sha256: "0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a", anchors: ["Nazan/Vazruden base attack times and script bindings (lines 11736-11737)"], }, { path: EPOCH_SPELL_DBC, sha256: "d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3", anchors: ["records 19130, 30926, 33793, and 39427"], }, ], reviewedBehavior: "The previously absent finale now requires both imported progression bosses, two sentries at their exact normalized source positions, and then a joint Vazruden/Nazan defeat before dungeon completion and the Fel Iron Chest unlock; Vazruden uses the source four/six-second Revenge schedule and Nazan uses the source Fireball/Cone schedules.", adaptations: [ "A procedural Herald interaction replaces automatic proximity aggro so the finale has an explicit reliable start.", "Nazan begins beside Vazruden as a ground-combat actor; flying routes and the 35-percent landing transition are not supported by the current actor runtime.", "The normal Fel Iron Chest is the deterministic cross-mode presentation object because encounter guards cannot currently inspect dungeon difficulty.", "The exact source coordinates are transformed through the package's pinned normalization transform.", "Source flat spell damage is normalized to the runtime's encounter-scaled multipliers while spell ids, schools, targets, and task-scheduler cadence remain pinned.", ], remainingGaps: [ "Herald flight/despawn presentation, Nazan aerial pathing, Fireball's Liquid Fire patch, the 35-percent landing call, native chest model, and chest loot interaction remain unported.", "Nazan's heroic-only Bellowing Roar remains documented rather than being incorrectly cast on normal, because combat loadouts cannot yet branch on dungeon difficulty.", "The final encounter uses finite actors and does not reproduce summon ownership or source evade callbacks.", ], }, { dungeonId: "botanica", fidelity: "source-backed-adapted", sourceFiles: [ { path: BOTANICA_FILES[0], sha256: "58e3f612984d30d765d4ebe98016e530629d7cd5a66499b3ee1c541f16cb378f", anchors: [ "five-boss instance state only (lines 26-43)", "Sarannis Call of the Falcon threat handoff (lines 46-81)", "school-reactive form switching (lines 88-142)", ], }, { path: BOTANICA_FILES[1], sha256: "062c751d365cb5d205c47a5dca93c64fa46cfda7629456e8e58096dfe8aeaa22", anchors: ["30-second Frayer/Tree Form cycle (lines 52-87)", "early resume after all Frayers die (lines 90-118)"], }, { path: BOTANICA_FILES[2], sha256: "b350507ced2b94f6a9178db0102176c98e130cfe5b844ce11f649b62f94cae83", anchors: ["five immunity forms (lines 53-66)", "20-second teleport and lasher/flayer summon cycle (lines 88-120)"], }, { path: BOTANICA_FILES[3], sha256: "a428f3f216e1e91a93339b837c9e1049f4d0eca4fa735312c574fcc388971dc7", anchors: ["six-sapling summon every 40 seconds (lines 40-66)"], }, { path: BOTANICA_FILES[4], sha256: "484bebc3e3211ab624bb933fd0b59bb1e9f6c2891b66d36c318dac8df7705399", anchors: ["five boss and Bloodfalcon ids (lines 27-45)"], }, ], reviewedBehavior: "AzerothCore has no instance-wide door or wave gate here; imported static boss progression remains intact while Freywinn, Laj, and Warp Splinter each receive one source-shaped finite summon cycle.", adaptations: [ "Freywinn summons three Frayer Protectors once, Laj summons one Thorn Lasher and one Thorn Flayer once, and Warp Splinter summons six Saplings once.", "The finite cycles preserve source delay, creature family, and cardinality where explicit, but deliberately do not repeat forever.", "Summons use conservative source-entry combat proxies until their full templates, spells, and client models are packaged.", ], remainingGaps: [ "Freywinn Tree Form/Tranquility immunity, Laj teleports and school immunities, Warp Splinter repeated waves/healing, and Sarannis Bloodfalcon threat redirection need richer boss-state and summon-owner primitives.", "Dialogue, exact summon spell visuals, repeated scheduling, and summon-specific loot/despawn callbacks remain unported.", ], }, { dungeonId: "sethekk-halls", fidelity: "source-backed-adapted", sourceFiles: [ { path: SETHEKK_HALLS_FILES[0], sha256: "223f84da402f20cfb5d99cbdee8b1bdf4cd22bf2f74eed1516cb078b9759250f", anchors: [ "Ikiss passage door and coffer bindings (lines 22-37)", "Anzu spell-event 14797 and Voice summon (lines 40-67)", ], }, { path: SETHEKK_HALLS_FILES[1], sha256: "2d44ec532e3d62d804cf0b4c26304cc9ad12b38f626ef5876e5e02b0b99e5bb8", anchors: ["Syth's four elementals at 90/50/10 percent (lines 43-57)"], }, { path: SETHEKK_HALLS_FILES[2], sha256: "93ecd4af480331bc4026d24431c6db9288a17f9502966795e4318615eec774ca", anchors: [ "Ikiss blink/explosion at 80/50/25 percent (lines 46-70)", "Ikiss death unlocks the coffer (lines 117-124)", ], }, { path: SETHEKK_HALLS_FILES[3], sha256: "b15531207673a2b908e263acb6e5c3360810ffc682cc93f6cadaaad67f01a703", anchors: [ "Anzu's three spirit positions (lines 64-78)", "brood gates at 66/33 percent (lines 82-101)", "Screech/Spell Bomb/Cyclone schedules and target selection (lines 108-129)", "five Broods and banish lifecycle (lines 132-150)", "sixteen-second attackability intro (lines 152-170)", ], }, { path: SETHEKK_HALLS_FILES[4], sha256: "73fdb3c48d6a1763600a1a3293639849f6a61cc465e1c186958c1322e3003f3f", anchors: ["Syth/Anzu/Ikiss order and source object ids (lines 25-54)"], }, { path: `${DB_ROOT}/creature_template.sql`, sha256: "0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a", anchors: ["Anzu base attack time and boss_anzu binding (line 17077)"], }, { path: EPOCH_SPELL_DBC, sha256: "d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3", anchors: ["records 40184, 40303, and 40321"], }, ], reviewedBehavior: "Syth summons four typed elementals at each exact source health threshold, Ikiss unlocks his exact passage/coffer objects, and an explicit optional ritual runs Anzu's sixteen-second intro, two five-Brood waves, and source-timed Screech, Spell Bomb, and Cyclone combat schedule.", adaptations: [ "The Anzu ritual is available as an optional deterministic encounter in both modes because the reducer cannot inspect heroic difficulty, possession of Swift Flight Form, or event 14797.", "Anzu's three friendly spirits are documented but omitted instead of being fabricated as hostile actors.", "Broods do not make Anzu immune; they add finite source-shaped pressure while preserving a completable ordinary combat actor.", "The exact imported Ikiss door and coffer transforms use visible procedural fallbacks pending native client models.", "Paralyzing Screech and Cyclone use the runtime's stun control, while Spell Bomb is a timed magic debuff marker; their exact spell ids, source task cadence, random/area targeting, and DBC schools are retained.", ], remainingGaps: [ "Heroic/item/class gating for Anzu, Voice of the Raven God presentation, Anzu banish immunity, spirit activation by Druid heals, native object models, and coffer loot remain unported.", "Spell Bomb's mana-loss/damage-on-cast retaliation, Cyclone's repeated damage pulse, and the script's three-second global post-cast scheduler delays need richer aura and scheduler primitives.", "Syth summon spell visuals, Ikiss blink positioning/Arcane Explosion shelter play, dialogue, and summon ownership remain incomplete.", ], }, ]); const REVIEW_BY_DUNGEON = new Map( WOW_TBC_ENCOUNTER_CONTENT_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], ] as const); function basicAttack(cooldownMs: number): EnemyAttackDefinition { return Object.freeze({ id: "basic-attack", name: "Basic Attack", delivery: "melee", target: "primary", school: "physical", animation: "attack", range: 4.5, cooldownMs, damageMultiplier: 1, }); } function attack(definition: EnemyAttackDefinition): EnemyAttackDefinition { return Object.freeze(definition); } const VAZRUDEN_ATTACKS = Object.freeze([ basicAttack(1_300), attack({ id: "revenge-19130", name: "Revenge", spellId: 19_130, delivery: "melee", target: "primary", school: "physical", animation: "attack", range: 5, cooldownMs: 6_000, initialCooldownMs: [4_000, 4_000] as const, repeatCooldownMs: [6_000, 6_000] as const, damageMultiplier: 1, }), ] satisfies readonly EnemyAttackDefinition[]); const NAZAN_ATTACKS = Object.freeze([ basicAttack(2_000), attack({ id: "fireball-33793", name: "Fireball", spellId: 33_793, delivery: "projectile", target: "random-party", school: "fire", animation: "cast", range: 100, cooldownMs: 5_000, initialCooldownMs: [5_000, 5_000] as const, repeatCooldownMs: [4_000, 6_000] as const, damageMultiplier: 0.9, }), attack({ id: "cone-of-fire-30926", name: "Cone of Fire", spellId: 30_926, delivery: "area", target: "nearby-party", school: "fire", animation: "cast", range: 10, cooldownMs: 12_000, initialCooldownMs: [5_000, 5_000] as const, repeatCooldownMs: [12_000, 12_000] as const, damageMultiplier: 1.15, telegraph: { shape: "cone", anchor: "source", warningMs: 1_650, length: 10, angleDegrees: 70, }, }), ] satisfies readonly EnemyAttackDefinition[]); const ANZU_ATTACKS = Object.freeze([ basicAttack(1_400), attack({ id: "paralyzing-screech-40184", name: "Paralyzing Screech", spellId: 40_184, delivery: "area", target: "nearby-party", school: "shadow", animation: "cast", range: 100, radius: 100, cooldownMs: 23_000, initialCooldownMs: [14_000, 14_000] as const, repeatCooldownMs: [23_000, 23_000] as const, damageMultiplier: 0, effect: { kind: "control", mechanic: "stun", durationMs: 6_000 }, }), attack({ id: "spell-bomb-40303", name: "Spell Bomb", spellId: 40_303, delivery: "projectile", target: "random-party", school: "physical", animation: "cast", range: 50, cooldownMs: 20_250, initialCooldownMs: [5_000, 5_000] as const, repeatCooldownMs: [16_000, 24_500] as const, damageMultiplier: 0, effect: { kind: "target-aura", aura: "spell-bomb", durationMs: 8_000 }, }), attack({ id: "cyclone-of-feathers-40321", name: "Cyclone of Feathers", spellId: 40_321, delivery: "projectile", target: "random-party", school: "shadow", animation: "cast", range: 45, cooldownMs: 24_500, initialCooldownMs: [8_000, 8_000] as const, repeatCooldownMs: [22_000, 27_000] as const, damageMultiplier: 0, effect: { kind: "control", mechanic: "stun", durationMs: 6_000 }, }), ] satisfies readonly EnemyAttackDefinition[]); function entityIdForEntry(entities: PopulationDefinitionMap, entry: number): string | undefined { return Object.entries(entities).find(([, entity]) => entity.combat?.serverEntry === entry)?.[0]; } function entryForSpawn(definition: DungeonDefinition, spawn: StaticMobSpawnDefinition): number | undefined { return definition.entities[spawn.entityId]?.combat?.serverEntry; } function createAdaptedEntity(spec: AdaptedEntitySpec): PopulationEntityDefinition { const caster = spec.role === "caster"; const attacks: readonly EnemyAttackDefinition[] = spec.attacks ? Object.freeze([...spec.attacks]) : Object.freeze([ { 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 : 1_900, damageMultiplier: caster ? 0.82 : 1, }, ]); const base = { id: `tbc-event-entry-${spec.entry}`, name: spec.name, hasLoot: false, combat: { serverEntry: spec.entry, level: spec.level, healthMultiplier: spec.kind === "boss" ? 4.4 : caster ? 0.92 : 1.08, damageMultiplier: spec.kind === "boss" ? 1.18 : 0.92, moveSpeed: 3.657152, aggroRange: 32, leashRange: 85, ...(spec.attacks ? { mechanicIds: Object.freeze(spec.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => attack.id)) } : {}), attacks, }, visual: { primaryColor: caster ? "#52698c" : "#704c43", accentColor: caster ? "#a6d8ef" : "#d59a79", scale: spec.kind === "boss" ? 1.2 : 1, archetype: spec.archetype ?? "humanoid" as const, }, }; if (spec.kind === "boss") { return Object.freeze({ ...base, kind: "boss" as const, title: spec.title ?? "Dungeon Boss" }); } return Object.freeze({ ...base, kind: "mob" as const }); } function ensureEntities( definition: DungeonDefinition, specs: readonly AdaptedEntitySpec[], ): PopulationDefinitionMap { const entities: Record = { ...definition.entities }; for (const spec of specs) { if (entityIdForEntry(entities, spec.entry)) continue; const entity = createAdaptedEntity(spec); if (entities[entity.id]) throw new Error(`${definition.id} has duplicate adapted entity ${entity.id}`); entities[entity.id] = entity; } return Object.freeze(entities); } function objectiveForEntry(definition: DungeonDefinition, entry: number): DungeonBossObjective { const staticIds = new Set(definition.staticSpawns .filter((spawn) => entryForSpawn(definition, spawn) === entry) .map((spawn) => spawn.id)); const objective = definition.bosses.find((candidate) => staticIds.has(candidate.id)); if (!objective) throw new Error(`${definition.id} is missing boss objective for source entry ${entry}`); 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 sourceReview(review: WowTbcEncounterContentReview): 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 appendValidationWarnings( definition: DungeonDefinition, warnings: readonly string[], ): DungeonDefinition["validation"] { return Object.freeze({ ...definition.validation, warnings: Object.freeze([...new Set([...definition.validation.warnings, ...warnings])]), }); } function assertImportedTransform(definition: DungeonDefinition): void { if (definition.transform.fromWow !== "three(x,y,z)=(-wow.x,wow.z,wow.y)") { throw new Error(`TBC adapter ${definition.id} requires three(x,y,z)=(-wow.x,wow.z,wow.y)`); } } function objectForTemplate(definition: DungeonDefinition, templateId: number): DungeonWorldObjectDefinition { const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId); if (!object) throw new Error(`${definition.id} is missing reviewed gameobject template ${templateId}`); return object; } function reviewedDoor( object: DungeonWorldObjectDefinition, sourceFiles: readonly string[], options: { readonly initialState?: "closed" | "open"; readonly prompt?: string } = {}, ): DungeonWorldObjectDefinition { const interaction = options.prompt ? { prompt: options.prompt, range: 4, signalId: `${object.id}:use`, allowedStates: ["closed"], oneShot: false, instructions: "Use the reviewed source doorway.", } : undefined; return Object.freeze({ ...object, initialState: options.initialState ?? "closed", states: Object.freeze([ { id: "closed", label: "Closed", visible: true, blocksMovement: true, blocksSight: true, interactionEnabled: Boolean(options.prompt) }, { id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false, interactionEnabled: false }, ]), ...(interaction ? { interaction } : {}), visual: Object.freeze({ color: "#687181", emissive: "#151b24" }), source: Object.freeze({ ...object.source, fidelity: "source-backed-adapted" as const, files: sourceFiles, note: "Exact source transform and imported bounds are retained. Visible procedural collision temporarily replaces the unexported native client model.", }), }); } function reviewedLever( object: DungeonWorldObjectDefinition, sourceFiles: readonly string[], ): DungeonWorldObjectDefinition { return Object.freeze({ ...object, initialState: "ready", states: Object.freeze([ { id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true }, { id: "active", label: "Encounter active", visible: true, blocksMovement: false, interactionEnabled: false }, { id: "complete", label: "Complete", visible: true, blocksMovement: false, interactionEnabled: false }, ]), interaction: { prompt: "Pull the cell door lever", range: 4, signalId: `${object.id}:use`, allowedStates: ["ready"], oneShot: false, }, visual: Object.freeze({ color: "#9a7642", emissive: "#2a1d0c" }), source: Object.freeze({ ...object.source, fidelity: "source-backed-adapted" as const, files: sourceFiles, note: "The exact imported lever transform and bounds are retained; a procedural interaction starts the source Broggok cell sequence.", }), }); } function reviewedChest( object: DungeonWorldObjectDefinition, sourceFiles: readonly string[], ): DungeonWorldObjectDefinition { return Object.freeze({ ...object, initialState: "locked", states: Object.freeze([ { id: "locked", label: "Locked", visible: true, blocksMovement: false, interactionEnabled: false }, { id: "unlocked", label: "Unlocked", visible: true, blocksMovement: false, interactionEnabled: false }, ]), visual: Object.freeze({ color: "#9b7436", emissive: "#2b210f" }), source: Object.freeze({ ...object.source, fidelity: "source-backed-adapted" as const, files: sourceFiles, note: "The exact source chest transform and bounds are retained. A visible procedural chest presents locked/unlocked state while native loot interaction remains pending.", }), }); } function replaceObjects( definition: DungeonDefinition, replacements: ReadonlyMap, additions: readonly DungeonWorldObjectDefinition[] = [], ): readonly DungeonWorldObjectDefinition[] { return Object.freeze([ ...definition.serverGameObjects.map((object) => replacements.get(object.id) ?? object), ...additions, ]); } function startObject( id: string, name: string, prompt: string, position: Vector3Tuple, sourceFiles: readonly string[], initialState: "hidden" | "ready" = "ready", ): DungeonWorldObjectDefinition { return Object.freeze({ id, name, kind: "npc", position, halfExtents: [0.9, 1.8, 0.9] as const, initialState, states: Object.freeze([ { id: "hidden", label: "Unavailable", visible: false, blocksMovement: false, interactionEnabled: false }, { id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true }, { id: "active", label: "Encounter active", visible: true, blocksMovement: false, interactionEnabled: false }, { id: "complete", label: "Complete", visible: true, blocksMovement: false, interactionEnabled: false }, ]), interaction: { prompt, range: 4, signalId: `${id}:start`, allowedStates: ["ready"], oneShot: false, }, visual: Object.freeze({ color: "#9a72b5", emissive: "#2d193b" }), source: Object.freeze({ fidelity: "source-backed-adapted" as const, files: sourceFiles, note: "A procedural source-position interaction replaces an unsupported proximity, spell-event, or flying-NPC start.", }), }); } function updateObjective( definition: DungeonDefinition, objective: DungeonBossObjective, ): Pick { const bosses = definition.bosses.map((candidate) => candidate.id === objective.id ? objective : candidate); const byId = new Map(bosses.map((candidate) => [candidate.id, candidate] as const)); return { bosses: Object.freeze(bosses), objectiveOrder: Object.freeze(definition.objectiveOrder.map((candidate) => byId.get(candidate.id) ?? candidate)), }; } function buildBloodFurnace(definition: DungeonDefinition): DungeonDefinition { assertImportedTransform(definition); const review = REVIEW_BY_DUNGEON.get("blood-furnace")!; const broggokObjective = objectiveForEntry(definition, 17_380); const broggokEntityId = entityIdForEntry(definition.entities, 17_380)!; const lever = reviewedLever(objectForTemplate(definition, 181_982), BLOOD_FURNACE_FILES); const doorTemplates = [181_811, 181_812, 181_822, 181_819, 181_823, 181_766, 181_821, 181_818, 181_820, 181_817] as const; const doors = new Map(doorTemplates.map((templateId) => { const initialState = templateId === 181_811 || templateId === 181_822 ? "open" : "closed"; return [templateId, reviewedDoor(objectForTemplate(definition, templateId), BLOOD_FURNACE_FILES, { initialState })] as const; })); const replacements = new Map([ [lever.id, lever], ...[...doors.values()].map((door) => [door.id, door] as const), ]); const prisonerSpawns = definition.staticSpawns.filter((spawn) => ( entryForSpawn(definition, spawn) === 17_398 && spawn.position[0] < -400 && spawn.position[2] >= 75 && spawn.position[2] <= 125 )); const cellCenters = [ doors.get(181_821)!.position, doors.get(181_818)!.position, doors.get(181_820)!.position, doors.get(181_817)!.position, ] as const; const cells = cellCenters.map((center, index) => Object.freeze({ id: `blood-furnace:broggok-cell-${index + 1}`, door: doors.get([181_821, 181_818, 181_820, 181_817][index])!, spawns: Object.freeze(prisonerSpawns.filter((spawn) => { const distances = cellCenters.map((candidate) => Math.hypot( spawn.position[0] - candidate[0], spawn.position[2] - candidate[2], )); return distances.indexOf(Math.min(...distances)) === index; })), })); if (cells.some((cell) => !cell.spawns.length)) throw new Error("Blood Furnace source cell classification produced an empty cell"); const broggokGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({ id: "blood-furnace:broggok", spawns: Object.freeze([spawnAt(broggokObjective.id, broggokEntityId, broggokObjective.position)]), }); const rules: DungeonEncounterRule[] = [ { id: "maker-engaged-close-room", trigger: { kind: "entity-engaged", entityId: objectiveForEntry(definition, 17_381).id }, actions: [{ id: "close-maker-front", kind: "set-world-object-state", objectId: doors.get(181_811)!.id, state: "closed" }], }, { id: "maker-defeated-open-passage", trigger: { kind: "entity-death", entityId: objectiveForEntry(definition, 17_381).id }, actions: [ { id: "reopen-maker-front", kind: "set-world-object-state", objectId: doors.get(181_811)!.id, state: "open", persistence: "run" }, { id: "open-maker-rear", kind: "set-world-object-state", objectId: doors.get(181_812)!.id, state: "open", persistence: "run" }, ], }, { id: "start-broggok-cells", trigger: { kind: "interact", objectId: lever.id }, guards: [ { kind: "phase", phases: ["ready"] }, { kind: "world-object-state", objectId: lever.id, states: ["ready"] }, ], actions: [ { id: "activate-broggok-lever", kind: "set-world-object-state", objectId: lever.id, state: "active" }, { id: "close-broggok-front", kind: "set-world-object-state", objectId: doors.get(181_822)!.id, state: "closed" }, { id: "open-broggok-cell-1", kind: "set-world-object-state", objectId: cells[0].door.id, state: "open" }, { id: "activate-broggok-cell-1", kind: "activate-spawn-group", spawnGroupId: cells[0].id }, { id: "enter-broggok-cell-1", kind: "set-phase", phase: "cell-1-active" }, ], }, { id: "resume-completed-broggok", trigger: { kind: "tick" }, guards: [ { kind: "phase", phases: ["resume"] }, { kind: "flag", flagId: "broggok-complete", equals: true }, ], once: false, priority: 20, actions: [{ id: "resume-broggok-post", kind: "set-phase", phase: "post-broggok" }], }, { id: "resume-broggok-at-lever", trigger: { kind: "tick" }, guards: [ { kind: "phase", phases: ["resume"] }, { kind: "flag", flagId: "broggok-complete", equals: false }, ], once: false, actions: [{ id: "resume-broggok-ready", kind: "set-phase", phase: "ready" }], }, ]; cells.forEach((cell, index) => { const next = cells[index + 1]; const actions: DungeonEncounterAction[] = [ { id: `deactivate-broggok-cell-${index + 1}`, kind: "deactivate-spawn-group", spawnGroupId: cell.id }, ]; if (next) { actions.push( { id: `open-broggok-cell-${index + 2}`, kind: "set-world-object-state", objectId: next.door.id, state: "open" }, { id: `activate-broggok-cell-${index + 2}`, kind: "activate-spawn-group", spawnGroupId: next.id }, { id: `enter-broggok-cell-${index + 2}`, kind: "set-phase", phase: `cell-${index + 2}-active` }, ); } else { actions.push( { id: "open-broggok-rear-for-release", kind: "set-world-object-state", objectId: doors.get(181_819)!.id, state: "open" }, { id: "activate-broggok", kind: "activate-spawn-group", spawnGroupId: broggokGroup.id }, { id: "enter-broggok-active", kind: "set-phase", phase: "broggok-active" }, ); } rules.push({ id: `clear-broggok-cell-${index + 1}`, trigger: { kind: "entities-dead", entityIds: cell.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 }, guards: [{ kind: "phase", phases: [`cell-${index + 1}-active`] }], actions, }); }); rules.push( { id: "broggok-defeated", trigger: { kind: "entities-dead", entityIds: broggokGroup.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 }, guards: [{ kind: "phase", phases: ["broggok-active"] }], actions: [ { id: "deactivate-broggok", kind: "deactivate-spawn-group", spawnGroupId: broggokGroup.id }, { id: "complete-broggok-objective", kind: "complete-objective", objectiveId: broggokObjective.id, persistence: "run" }, { id: "checkpoint-broggok", kind: "set-checkpoint", checkpointId: broggokObjective.id, persistence: "checkpoint" }, { id: "mark-broggok-complete", kind: "set-flag", flagId: "broggok-complete", value: true, persistence: "run" }, { id: "reopen-broggok-front", kind: "set-world-object-state", objectId: doors.get(181_822)!.id, state: "open", persistence: "run" }, { id: "keep-broggok-rear-open", kind: "set-world-object-state", objectId: doors.get(181_819)!.id, state: "open", persistence: "run" }, { id: "complete-broggok-lever", kind: "set-world-object-state", objectId: lever.id, state: "complete", persistence: "run" }, { id: "enter-post-broggok", kind: "set-phase", phase: "post-broggok", persistence: "run" }, ], }, { id: "kelidan-defeated-open-exits", trigger: { kind: "entity-death", entityId: objectiveForEntry(definition, 17_377).id }, actions: [ { id: "open-kelidan-exit-1", kind: "set-world-object-state", objectId: doors.get(181_823)!.id, state: "open", persistence: "run" }, { id: "open-kelidan-exit-2", kind: "set-world-object-state", objectId: doors.get(181_766)!.id, state: "open", persistence: "run" }, ], }, ); const encounter: DungeonEncounterDefinition = Object.freeze({ id: "blood-furnace:tbc-instance", dungeonId: definition.id, initialPhase: "ready", initialState: { flags: { "broggok-complete": false }, worldObjectStates: Object.fromEntries([ [lever.id, "ready"], ...[...doors.values()].map((door) => [door.id, door.initialState] as const), ]), }, resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" }, source: sourceReview(review), rules: Object.freeze(rules), }); const updatedObjective = Object.freeze({ ...broggokObjective, eventOnly: true }); const objectives = updateObjective(definition, updatedObjective); const removedIds = new Set(prisonerSpawns.map((spawn) => spawn.id)); return Object.freeze({ ...definition, ...objectives, staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => ( spawn.id !== broggokObjective.id && !removedIds.has(spawn.id) ))), encounters: Object.freeze([ ...(definition.encounters ?? []).filter((candidate) => candidate.id !== encounter.id), encounter, ]), encounterSpawnGroups: Object.freeze([ ...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("blood-furnace:broggok")), ...cells.map((cell) => Object.freeze({ id: cell.id, spawns: cell.spawns })), broggokGroup, ]), serverGameObjects: replaceObjects(definition, replacements), validation: appendValidationWarnings(definition, [ "Broggok now runs through the four source prison cells and reviewed doors; wipe recovery restarts the finite cell attempt.", ]), }); } function buildShatteredHalls(definition: DungeonDefinition): DungeonDefinition { assertImportedTransform(definition); const review = REVIEW_BY_DUNGEON.get("shattered-halls")!; const nethekurseObjective = objectiveForEntry(definition, 16_807); const nethekurseEntityId = entityIdForEntry(definition.entities, 16_807)!; const entranceDoor = reviewedDoor( objectForTemplate(definition, 182_539), SHATTERED_HALLS_FILES, { initialState: "closed", prompt: "Open Grand Warlock Nethekurse's chamber" }, ); const exitDoor = reviewedDoor(objectForTemplate(definition, 182_540), SHATTERED_HALLS_FILES); const replacements = new Map([ [entranceDoor.id, entranceDoor], [exitDoor.id, exitDoor], ]); const convertSpawns = definition.staticSpawns.filter((spawn) => ( entryForSpawn(definition, spawn) === 17_083 && spawn.position[0] < -140 )); if (convertSpawns.length !== 4) throw new Error(`Shattered Halls expected four Nethekurse converts, got ${convertSpawns.length}`); const convertGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({ id: "shattered-halls:nethekurse-converts", spawns: Object.freeze(convertSpawns), }); const nethekurseGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({ id: "shattered-halls:nethekurse", spawns: Object.freeze([spawnAt(nethekurseObjective.id, nethekurseEntityId, nethekurseObjective.position)]), }); const kargathObjective = objectiveForEntry(definition, 16_808); const sourceAssassinPositions = Object.freeze([ [-172.68164, 2.0834563, -80.65692], [-167.8295, 1.9949634, -86.55783], [-287.0375, 2.0663502, -88.17879], [-292.1491, 1.9973913, -82.25267], ] as const satisfies readonly Vector3Tuple[]); const addEntries = [17_695, 17_695, 17_695, 17_695, 17_420, 16_699, 16_704] as const; const addPositions = [ ...sourceAssassinPositions, ...PACK_OFFSETS.slice(1, 4).map((offset) => [ kargathObjective.position[0] + offset[0], kargathObjective.position[1], kargathObjective.position[2] + offset[1], ] as const), ]; const kargathAdds: DungeonEncounterSpawnGroupDefinition = Object.freeze({ id: "shattered-halls:kargath-reinforcements", spawns: Object.freeze(addEntries.map((entry, index) => spawnAt( `shattered-halls:kargath-add-${index + 1}`, entityIdForEntry(definition.entities, entry)!, addPositions[index], ))), }); const rules: DungeonEncounterRule[] = [ { id: "start-nethekurse-room", trigger: { kind: "interact", objectId: entranceDoor.id }, guards: [{ kind: "phase", phases: ["ready"] }], actions: [ { id: "open-nethekurse-entry", kind: "set-world-object-state", objectId: entranceDoor.id, state: "open" }, { id: "activate-nethekurse-converts", kind: "activate-spawn-group", spawnGroupId: convertGroup.id }, { id: "enter-nethekurse-converts", kind: "set-phase", phase: "nethekurse-converts-active" }, ], }, { id: "nethekurse-converts-defeated", trigger: { kind: "entities-dead", entityIds: convertSpawns.map((spawn) => spawn.id), timeoutMs: 900_000 }, guards: [{ kind: "phase", phases: ["nethekurse-converts-active"] }], actions: [ { id: "deactivate-nethekurse-converts", kind: "deactivate-spawn-group", spawnGroupId: convertGroup.id }, { id: "activate-nethekurse", kind: "activate-spawn-group", spawnGroupId: nethekurseGroup.id }, { id: "enter-nethekurse-active", kind: "set-phase", phase: "nethekurse-active" }, ], }, { id: "nethekurse-defeated", trigger: { kind: "entities-dead", entityIds: [nethekurseObjective.id], timeoutMs: 900_000 }, guards: [{ kind: "phase", phases: ["nethekurse-active"] }], actions: [ { id: "deactivate-nethekurse", kind: "deactivate-spawn-group", spawnGroupId: nethekurseGroup.id }, { id: "complete-nethekurse", kind: "complete-objective", objectiveId: nethekurseObjective.id, persistence: "run" }, { id: "checkpoint-nethekurse", kind: "set-checkpoint", checkpointId: nethekurseObjective.id, persistence: "checkpoint" }, { id: "mark-nethekurse-complete", kind: "set-flag", flagId: "nethekurse-complete", value: true, persistence: "run" }, { id: "keep-nethekurse-entry-open", kind: "set-world-object-state", objectId: entranceDoor.id, state: "open", persistence: "run" }, { id: "open-nethekurse-exit", kind: "set-world-object-state", objectId: exitDoor.id, state: "open", persistence: "run" }, { id: "enter-post-nethekurse", kind: "set-phase", phase: "post-nethekurse", persistence: "run" }, ], }, { id: "resume-shattered-halls-complete", trigger: { kind: "tick" }, guards: [ { kind: "phase", phases: ["resume"] }, { kind: "flag", flagId: "nethekurse-complete", equals: true }, ], once: false, priority: 20, actions: [{ id: "resume-shattered-post-nethekurse", kind: "set-phase", phase: "post-nethekurse" }], }, { id: "resume-shattered-halls-ready", trigger: { kind: "tick" }, guards: [ { kind: "phase", phases: ["resume"] }, { kind: "flag", flagId: "nethekurse-complete", equals: false }, ], once: false, actions: [{ id: "resume-shattered-ready", kind: "set-phase", phase: "ready" }], }, { id: "kargath-engaged-reinforcements", trigger: { kind: "entity-engaged", entityId: kargathObjective.id }, guards: [{ kind: "spawn-group-active", spawnGroupId: kargathAdds.id, equals: false }], actions: [{ id: "activate-kargath-reinforcements", kind: "activate-spawn-group", spawnGroupId: kargathAdds.id }], }, { id: "kargath-defeated-cleanup", trigger: { kind: "entity-death", entityId: kargathObjective.id }, actions: [{ id: "deactivate-kargath-reinforcements", kind: "deactivate-spawn-group", spawnGroupId: kargathAdds.id }], }, ]; const encounter: DungeonEncounterDefinition = Object.freeze({ id: "shattered-halls:tbc-instance", dungeonId: definition.id, initialPhase: "ready", initialState: { flags: { "nethekurse-complete": false }, worldObjectStates: { [entranceDoor.id]: "closed", [exitDoor.id]: "closed" }, }, resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" }, source: sourceReview(review), rules: Object.freeze(rules), }); const updatedObjective = Object.freeze({ ...nethekurseObjective, eventOnly: true }); const objectives = updateObjective(definition, updatedObjective); const removedIds = new Set(convertSpawns.map((spawn) => spawn.id)); return Object.freeze({ ...definition, ...objectives, staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => ( spawn.id !== nethekurseObjective.id && !removedIds.has(spawn.id) ))), encounters: Object.freeze([...(definition.encounters ?? []), encounter]), encounterSpawnGroups: Object.freeze([ ...(definition.encounterSpawnGroups ?? []), convertGroup, nethekurseGroup, kargathAdds, ]), serverGameObjects: replaceObjects(definition, replacements), validation: appendValidationWarnings(definition, [ "Nethekurse now requires his four source converts and reviewed chamber doors; Kargath has a bounded source-shaped add cycle.", "The imported gauntlet remains finite and the heroic timed execution rescue is documented but not executable.", ]), }); } const RAMPARTS_EVENT_CENTER: Vector3Tuple = Object.freeze([103.103805, 145.8902, -159.650452]); const RAMPARTS_SENTRY_POSITIONS = Object.freeze([ [85.761754, 143.8572, -122.995672], [71.908533, 143.6863, -132.016606], ] as const satisfies readonly Vector3Tuple[]); function withoutPrematureRampartsCompletion( encounters: readonly DungeonEncounterDefinition[] | undefined, ): readonly DungeonEncounterDefinition[] { return Object.freeze((encounters ?? []).map((encounter) => { if (encounter.id !== "hellfire-ramparts:boss-progression") return encounter; return Object.freeze({ ...encounter, rules: Object.freeze(encounter.rules.filter((rule) => rule.id !== "all-required-bosses-defeated")), }); })); } function buildHellfireRamparts(definition: DungeonDefinition): DungeonDefinition { assertImportedTransform(definition); const review = REVIEW_BY_DUNGEON.get("hellfire-ramparts")!; const entities = ensureEntities(definition, [ { entry: 17_517, name: "Hellfire Sentry", level: 61, role: "melee" }, { entry: 17_537, name: "Vazruden", level: 62, role: "melee", kind: "boss", title: "The Herald's Rider", attacks: VAZRUDEN_ATTACKS, }, { entry: 17_536, name: "Nazan", level: 62, role: "caster", kind: "boss", title: "Fel Drake", archetype: "winged", attacks: NAZAN_ATTACKS, }, ]); const gargolmar = objectiveForEntry(definition, 17_306); const omor = objectiveForEntry(definition, 17_308); const finaleObjective: DungeonBossObjective = Object.freeze({ id: "hellfire-ramparts:vazruden-and-nazan", name: "Vazruden and Nazan", position: RAMPARTS_EVENT_CENTER, eventOnly: true, }); const start = startObject( "hellfire-ramparts:vazruden-herald", "Vazruden the Herald", "Challenge Vazruden's sentries", RAMPARTS_EVENT_CENTER, HELLFIRE_RAMPARTS_FILES, "hidden", ); const chestSource = objectForTemplate(definition, 185_168); const chest = reviewedChest(chestSource, HELLFIRE_RAMPARTS_FILES); const replacements = new Map([[chest.id, chest]]); const sentries: DungeonEncounterSpawnGroupDefinition = Object.freeze({ id: "hellfire-ramparts:vazruden-sentries", spawns: Object.freeze(RAMPARTS_SENTRY_POSITIONS.map((position, index) => spawnAt( `hellfire-ramparts:vazruden-sentry-${index + 1}`, entityIdForEntry(entities, 17_517)!, position, ))), }); const finale: DungeonEncounterSpawnGroupDefinition = Object.freeze({ id: "hellfire-ramparts:vazruden-and-nazan", spawns: Object.freeze([ spawnAt(finaleObjective.id, entityIdForEntry(entities, 17_537)!, RAMPARTS_EVENT_CENTER, [-3, 0]), spawnAt("hellfire-ramparts:nazan", entityIdForEntry(entities, 17_536)!, RAMPARTS_EVENT_CENTER, [3, 0]), ]), }); const encounter: DungeonEncounterDefinition = Object.freeze({ id: "hellfire-ramparts:tbc-instance", dungeonId: definition.id, initialPhase: "locked", initialState: { flags: { "dungeon-complete": false }, worldObjectStates: { [start.id]: "hidden", [chest.id]: "locked" }, }, resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" }, source: sourceReview(review), rules: Object.freeze([ { id: "record-gargolmar-for-finale", trigger: { kind: "entity-death", entityId: gargolmar.id }, actions: [{ id: "complete-gargolmar-for-finale", kind: "complete-objective", objectiveId: gargolmar.id, persistence: "run" }], }, { id: "record-omor-for-finale", trigger: { kind: "entity-death", entityId: omor.id }, actions: [{ id: "complete-omor-for-finale", kind: "complete-objective", objectiveId: omor.id, persistence: "run" }], }, { id: "unlock-vazruden-finale", trigger: { kind: "tick" }, guards: [ { kind: "phase", phases: ["locked"] }, { kind: "objective-complete", objectiveId: gargolmar.id, equals: true }, { kind: "objective-complete", objectiveId: omor.id, equals: true }, ], actions: [ { id: "ready-vazruden-herald", kind: "set-world-object-state", objectId: start.id, state: "ready", persistence: "run" }, { id: "enter-vazruden-ready", kind: "set-phase", phase: "ready" }, ], }, { id: "start-vazruden-finale", trigger: { kind: "interact", objectId: start.id }, guards: [{ kind: "phase", phases: ["ready"] }], actions: [ { id: "activate-vazruden-herald", kind: "set-world-object-state", objectId: start.id, state: "active" }, { id: "activate-vazruden-sentries", kind: "activate-spawn-group", spawnGroupId: sentries.id }, { id: "enter-vazruden-sentries", kind: "set-phase", phase: "sentries-active" }, ], }, { id: "vazruden-sentries-defeated", trigger: { kind: "entities-dead", entityIds: sentries.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 }, guards: [{ kind: "phase", phases: ["sentries-active"] }], actions: [ { id: "deactivate-vazruden-sentries", kind: "deactivate-spawn-group", spawnGroupId: sentries.id }, { id: "enter-vazruden-descent", kind: "set-phase", phase: "herald-descent" }, ], }, { id: "vazruden-herald-descends", trigger: { kind: "elapsed", afterMs: 3_000 }, guards: [{ kind: "phase", phases: ["herald-descent"] }], actions: [ { id: "activate-vazruden-and-nazan", kind: "activate-spawn-group", spawnGroupId: finale.id }, { id: "enter-vazruden-and-nazan", kind: "set-phase", phase: "bosses-active" }, ], }, { id: "vazruden-and-nazan-defeated", trigger: { kind: "entities-dead", entityIds: finale.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 }, guards: [{ kind: "phase", phases: ["bosses-active"] }], actions: [ { id: "deactivate-vazruden-and-nazan", kind: "deactivate-spawn-group", spawnGroupId: finale.id }, { id: "complete-vazruden-and-nazan", kind: "complete-objective", objectiveId: finaleObjective.id, persistence: "run" }, { id: "checkpoint-vazruden-and-nazan", kind: "set-checkpoint", checkpointId: finaleObjective.id, persistence: "checkpoint" }, { id: "unlock-fel-iron-chest", kind: "set-world-object-state", objectId: chest.id, state: "unlocked", persistence: "run" }, { id: "complete-vazruden-herald", kind: "set-world-object-state", objectId: start.id, state: "complete", persistence: "run" }, { id: "mark-ramparts-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" }, { id: "enter-ramparts-complete", kind: "set-phase", phase: "complete", persistence: "run" }, ], }, { id: "resume-completed-ramparts", trigger: { kind: "tick" }, guards: [ { kind: "phase", phases: ["resume"] }, { kind: "flag", flagId: "dungeon-complete", equals: true }, ], once: false, priority: 30, actions: [{ id: "resume-ramparts-complete", kind: "set-phase", phase: "complete" }], }, { id: "resume-unlocked-ramparts", trigger: { kind: "tick" }, guards: [ { kind: "phase", phases: ["resume"] }, { kind: "flag", flagId: "dungeon-complete", equals: false }, { kind: "objective-complete", objectiveId: gargolmar.id, equals: true }, { kind: "objective-complete", objectiveId: omor.id, equals: true }, ], once: false, priority: 20, actions: [ { id: "restore-vazruden-herald", kind: "set-world-object-state", objectId: start.id, state: "ready" }, { id: "resume-ramparts-ready", kind: "set-phase", phase: "ready" }, ], }, { id: "resume-locked-ramparts", trigger: { kind: "tick" }, guards: [{ kind: "phase", phases: ["resume"] }], once: false, actions: [{ id: "resume-ramparts-locked", kind: "set-phase", phase: "locked" }], }, ] satisfies DungeonEncounterRule[]), }); return Object.freeze({ ...definition, entities, bosses: Object.freeze([...definition.bosses, finaleObjective]), objectiveOrder: Object.freeze([...definition.objectiveOrder, finaleObjective]), encounters: Object.freeze([...withoutPrematureRampartsCompletion(definition.encounters), encounter]), encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), sentries, finale]), serverGameObjects: replaceObjects(definition, replacements, [start]), validation: appendValidationWarnings(definition, [ "The missing source Vazruden/Nazan finale now gates Ramparts completion behind two exact-position sentries; Nazan flight remains adapted to ground combat.", ]), }); } function bossAddRules( prefix: string, bossId: string, group: DungeonEncounterSpawnGroupDefinition, delayMs: number, ): readonly DungeonEncounterRule[] { return Object.freeze([ { id: `${prefix}-engaged`, trigger: { kind: "entity-engaged", entityId: bossId }, guards: [{ kind: "phase", phases: ["ready"] }], actions: [{ id: `${prefix}-wait`, kind: "set-phase", phase: `${prefix}-wait` }], }, { id: `${prefix}-summon`, trigger: { kind: "elapsed", afterMs: delayMs }, guards: [{ kind: "phase", phases: [`${prefix}-wait`] }], actions: [ { id: `${prefix}-activate-adds`, kind: "activate-spawn-group", spawnGroupId: group.id }, { id: `${prefix}-adds-active`, kind: "set-phase", phase: `${prefix}-adds-active` }, ], }, { id: `${prefix}-adds-defeated`, trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 }, guards: [{ kind: "phase", phases: [`${prefix}-adds-active`] }], actions: [ { id: `${prefix}-deactivate-adds`, kind: "deactivate-spawn-group", spawnGroupId: group.id }, { id: `${prefix}-return-ready`, kind: "set-phase", phase: "ready" }, ], }, { id: `${prefix}-boss-death-cleanup`, trigger: { kind: "entity-death", entityId: bossId }, guards: [{ kind: "phase", phases: [`${prefix}-wait`, `${prefix}-adds-active`] }], actions: [ { id: `${prefix}-deactivate-on-boss-death`, kind: "deactivate-spawn-group", spawnGroupId: group.id }, { id: `${prefix}-ready-on-boss-death`, kind: "set-phase", phase: "ready" }, ], }, ]); } function buildBotanica(definition: DungeonDefinition): DungeonDefinition { assertImportedTransform(definition); const review = REVIEW_BY_DUNGEON.get("botanica")!; const entities = ensureEntities(definition, [ { entry: 19_953, name: "Frayer Protector", level: 70, role: "melee", archetype: "plant" }, { entry: 19_919, name: "Thorn Lasher", level: 70, role: "melee", archetype: "plant" }, { entry: 19_920, name: "Thorn Flayer", level: 70, role: "melee", archetype: "plant" }, { entry: 19_949, name: "Sapling", level: 70, role: "melee", archetype: "plant" }, ]); const freywinn = objectiveForEntry(definition, 17_975); const laj = objectiveForEntry(definition, 17_980); const warpSplinter = objectiveForEntry(definition, 17_977); const groups: DungeonEncounterSpawnGroupDefinition[] = [ Object.freeze({ id: "botanica:freywinn-frayers", spawns: Object.freeze(PACK_OFFSETS.slice(0, 3).map((offset, index) => spawnAt( `botanica:freywinn-frayer-${index + 1}`, entityIdForEntry(entities, 19_953)!, freywinn.position, offset, ))), }), Object.freeze({ id: "botanica:laj-thorns", spawns: Object.freeze([19_919, 19_920].map((entry, index) => spawnAt( `botanica:laj-thorn-${index + 1}`, entityIdForEntry(entities, entry)!, laj.position, PACK_OFFSETS[index + 1], ))), }), Object.freeze({ id: "botanica:warp-saplings", spawns: Object.freeze(PACK_OFFSETS.slice(0, 6).map((offset, index) => spawnAt( `botanica:warp-sapling-${index + 1}`, entityIdForEntry(entities, 19_949)!, warpSplinter.position, offset, ))), }), ]; const rules = [ ...bossAddRules("freywinn", freywinn.id, groups[0], 30_000), ...bossAddRules("laj", laj.id, groups[1], 20_000), ...bossAddRules("warp-splinter", warpSplinter.id, groups[2], 40_000), ]; const encounter: DungeonEncounterDefinition = Object.freeze({ id: "botanica:tbc-instance", dungeonId: definition.id, initialPhase: "ready", resetPolicy: { bossWipePhase: "ready", checkpointPhase: "ready" }, source: sourceReview(review), rules: Object.freeze(rules), }); return Object.freeze({ ...definition, entities, encounters: Object.freeze([...(definition.encounters ?? []), encounter]), encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), ...groups]), validation: appendValidationWarnings(definition, [ "Botanica retains source static progression and adds one bounded Freywinn, Laj, and Warp Splinter summon cycle; repeating boss phases remain adapted.", ]), }); } function buildSethekkHalls(definition: DungeonDefinition): DungeonDefinition { assertImportedTransform(definition); const review = REVIEW_BY_DUNGEON.get("sethekk-halls")!; const entities = ensureEntities(definition, [ { entry: 19_203, name: "Syth Fire Elemental", level: 69, role: "caster" }, { entry: 19_204, name: "Syth Frost Elemental", level: 69, role: "caster" }, { entry: 19_205, name: "Syth Arcane Elemental", level: 69, role: "caster" }, { entry: 19_206, name: "Syth Shadow Elemental", level: 69, role: "caster" }, { entry: 23_035, name: "Anzu", level: 72, role: "caster", kind: "boss", title: "The Raven God", archetype: "winged", attacks: ANZU_ATTACKS, }, { entry: 23_132, name: "Brood of Anzu", level: 70, role: "melee", archetype: "winged" }, ]); const syth = objectiveForEntry(definition, 18_472); const ikiss = objectiveForEntry(definition, 18_473); const anzuPosition: Vector3Tuple = Object.freeze([88.02, 75.2, 288.18]); const anzuObjective: DungeonBossObjective = Object.freeze({ id: "sethekk-halls:anzu", name: "Anzu", position: anzuPosition, optional: true, eventOnly: true, }); const ritual = startObject( "sethekk-halls:anzu-ritual", "Essence-Infused Moonstone", "Invoke Anzu", anzuPosition, SETHEKK_HALLS_FILES, ); const ikissDoor = reviewedDoor(objectForTemplate(definition, 183_398), SETHEKK_HALLS_FILES); const coffer = reviewedChest(objectForTemplate(definition, 187_372), SETHEKK_HALLS_FILES); const replacements = new Map([ [ikissDoor.id, ikissDoor], [coffer.id, coffer], ]); const elementalEntries = [19_205, 19_203, 19_204, 19_206] as const; const sythGroups = [90, 50, 10].map((threshold): DungeonEncounterSpawnGroupDefinition => Object.freeze({ id: `sethekk-halls:syth-elementals-${threshold}`, spawns: Object.freeze(elementalEntries.map((entry, index) => spawnAt( `sethekk-halls:syth-${threshold}-elemental-${index + 1}`, entityIdForEntry(entities, entry)!, syth.position, PACK_OFFSETS[index + 1], ))), })); const anzuGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({ id: "sethekk-halls:anzu", spawns: Object.freeze([spawnAt(anzuObjective.id, entityIdForEntry(entities, 23_035)!, anzuPosition)]), }); const broodGroups = [66, 33].map((threshold): DungeonEncounterSpawnGroupDefinition => Object.freeze({ id: `sethekk-halls:anzu-broods-${threshold}`, spawns: Object.freeze(PACK_OFFSETS.slice(0, 5).map((offset, index) => spawnAt( `sethekk-halls:anzu-${threshold}-brood-${index + 1}`, entityIdForEntry(entities, 23_132)!, anzuPosition, offset, ))), })); const rules: DungeonEncounterRule[] = []; [90, 50, 10].forEach((threshold, index) => { const group = sythGroups[index]; rules.push( { id: `syth-summons-at-${threshold}`, trigger: { kind: "entity-health", entityId: syth.id, atOrBelowPercent: threshold }, guards: [{ kind: "spawn-group-active", spawnGroupId: group.id, equals: false }], actions: [{ id: `activate-syth-elementals-${threshold}`, kind: "activate-spawn-group", spawnGroupId: group.id }], }, { id: `syth-elementals-${threshold}-defeated`, trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 }, guards: [{ kind: "spawn-group-active", spawnGroupId: group.id, equals: true }], actions: [{ id: `deactivate-syth-elementals-${threshold}`, kind: "deactivate-spawn-group", spawnGroupId: group.id }], }, ); }); rules.push( { id: "syth-death-cleanup", trigger: { kind: "entity-death", entityId: syth.id }, actions: sythGroups.map((group, index) => ({ id: `deactivate-syth-wave-${index + 1}-on-death`, kind: "deactivate-spawn-group" as const, spawnGroupId: group.id, })), }, { id: "ikiss-defeated-unlocks-passage", trigger: { kind: "entity-death", entityId: ikiss.id }, actions: [ { id: "open-ikiss-passage", kind: "set-world-object-state", objectId: ikissDoor.id, state: "open", persistence: "run" }, { id: "unlock-ikiss-coffer", kind: "set-world-object-state", objectId: coffer.id, state: "unlocked", persistence: "run" }, ], }, { id: "start-anzu-ritual", trigger: { kind: "interact", objectId: ritual.id }, guards: [ { kind: "phase", phases: ["ready"] }, { kind: "flag", flagId: "anzu-complete", equals: false }, ], actions: [ { id: "activate-anzu-ritual", kind: "set-world-object-state", objectId: ritual.id, state: "active" }, { id: "enter-anzu-intro", kind: "set-phase", phase: "anzu-intro" }, ], }, { id: "anzu-intro-complete", trigger: { kind: "elapsed", afterMs: 16_000 }, guards: [{ kind: "phase", phases: ["anzu-intro"] }], actions: [ { id: "activate-anzu", kind: "activate-spawn-group", spawnGroupId: anzuGroup.id }, { id: "enter-anzu-active", kind: "set-phase", phase: "anzu-active" }, ], }, ); [66, 33].forEach((threshold, index) => { const group = broodGroups[index]; rules.push( { id: `anzu-summons-broods-${threshold}`, trigger: { kind: "entity-health", entityId: anzuObjective.id, atOrBelowPercent: threshold }, guards: [ { kind: "phase", phases: ["anzu-active"] }, { kind: "spawn-group-active", spawnGroupId: group.id, equals: false }, ], actions: [{ id: `activate-anzu-broods-${threshold}`, kind: "activate-spawn-group", spawnGroupId: group.id }], }, { id: `anzu-broods-${threshold}-defeated`, trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 }, guards: [{ kind: "spawn-group-active", spawnGroupId: group.id, equals: true }], actions: [{ id: `deactivate-anzu-broods-${threshold}`, kind: "deactivate-spawn-group", spawnGroupId: group.id }], }, ); }); rules.push( { id: "anzu-defeated", trigger: { kind: "entities-dead", entityIds: [anzuObjective.id], timeoutMs: 900_000 }, guards: [{ kind: "phase", phases: ["anzu-active"] }], actions: [ { id: "deactivate-anzu", kind: "deactivate-spawn-group", spawnGroupId: anzuGroup.id }, ...broodGroups.map((group, index) => ({ id: `deactivate-anzu-broods-on-death-${index + 1}`, kind: "deactivate-spawn-group" as const, spawnGroupId: group.id, })), { id: "complete-anzu", kind: "complete-objective", objectiveId: anzuObjective.id, persistence: "run" }, { id: "mark-anzu-complete", kind: "set-flag", flagId: "anzu-complete", value: true, persistence: "run" }, { id: "complete-anzu-ritual", kind: "set-world-object-state", objectId: ritual.id, state: "complete", persistence: "run" }, { id: "return-from-anzu", kind: "set-phase", phase: "ready", persistence: "run" }, ], }, { id: "resume-sethekk-halls", trigger: { kind: "tick" }, guards: [{ kind: "phase", phases: ["resume"] }], once: false, actions: [{ id: "resume-sethekk-ready", kind: "set-phase", phase: "ready" }], }, ); const encounter: DungeonEncounterDefinition = Object.freeze({ id: "sethekk-halls:tbc-instance", dungeonId: definition.id, initialPhase: "ready", initialState: { flags: { "anzu-complete": false }, worldObjectStates: { [ritual.id]: "ready", [ikissDoor.id]: "closed", [coffer.id]: "locked" }, }, resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" }, source: sourceReview(review), rules: Object.freeze(rules), }); const ikissIndex = definition.objectiveOrder.findIndex((objective) => objective.id === ikiss.id); const objectiveOrder = [...definition.objectiveOrder]; objectiveOrder.splice(Math.max(0, ikissIndex), 0, anzuObjective); return Object.freeze({ ...definition, entities, bosses: Object.freeze([...definition.bosses, anzuObjective]), objectiveOrder: Object.freeze(objectiveOrder), encounters: Object.freeze([...(definition.encounters ?? []), encounter]), encounterSpawnGroups: Object.freeze([ ...(definition.encounterSpawnGroups ?? []), ...sythGroups, anzuGroup, ...broodGroups, ]), serverGameObjects: replaceObjects(definition, replacements, [ritual]), validation: appendValidationWarnings(definition, [ "Sethekk Halls now runs Syth's three elemental thresholds, reviewed Ikiss object states, and a finite optional Anzu ritual; heroic/class/item gating remains adapted.", ]), }); } /** * Adds finite, checkpoint-safe source-reviewed TBC orchestration. Apply after * general WoW/progression-object adapters and before Ascension/RuneWaker content. */ export function withWowTbcEncounterContent(definition: DungeonDefinition): DungeonDefinition { if (definition.encounters?.some((encounter) => encounter.id === `${definition.id}:tbc-instance`)) { return definition; } if (definition.id === "blood-furnace") return buildBloodFurnace(definition); if (definition.id === "shattered-halls") return buildShatteredHalls(definition); if (definition.id === "hellfire-ramparts") return buildHellfireRamparts(definition); if (definition.id === "botanica") return buildBotanica(definition); if (definition.id === "sethekk-halls") return buildSethekkHalls(definition); return definition; }