1639 lines
70 KiB
TypeScript
1639 lines
70 KiB
TypeScript
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<string, PopulationEntityDefinition> = { ...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<string, DungeonWorldObjectDefinition>,
|
|
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<DungeonDefinition, "bosses" | "objectiveOrder"> {
|
|
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<number, DungeonWorldObjectDefinition>(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<string, DungeonWorldObjectDefinition>([
|
|
[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<string, DungeonWorldObjectDefinition>([
|
|
[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<string, DungeonWorldObjectDefinition>([[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<string, DungeonWorldObjectDefinition>([
|
|
[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;
|
|
}
|