Files
healer-man/src/game/wowClassicEncounterBatchThreeCatalog.ts
T
2026-09-09 11:21:33 -04:00

1873 lines
87 KiB
TypeScript

import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
Vector3Tuple,
} from "./dungeonTypes";
import type {
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "./dungeonEncounterTypes";
import type {
DungeonWorldObjectDefinition,
DungeonWorldObjectStateDefinition,
} from "./dungeonWorldObjects";
import type {
PopulationDefinitionMap,
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "./mobPopulation";
import type { EnemyAttackDefinition } from "./enemyAttacks";
export interface WowClassicBatchThreeSourceFile {
readonly path: string;
readonly sha256: string;
readonly anchors: readonly string[];
}
export interface WowClassicEncounterBatchThreeReview {
readonly dungeonId: string;
readonly fidelity: "source-backed-adapted";
readonly sourceFiles: readonly WowClassicBatchThreeSourceFile[];
readonly reviewedBehavior: string;
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
}
interface AdaptedMobSpec {
readonly entry: number;
readonly name: string;
readonly level: number;
readonly role: "melee" | "caster";
readonly boss?: boolean;
readonly attacks?: readonly EnemyAttackDefinition[];
}
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
const WORLD_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-world";
const WORLD_SMART_SCRIPTS = `${WORLD_ROOT}/smart_scripts.sql`;
const WORLD_GAMEOBJECTS = `${WORLD_ROOT}/gameobject.sql`;
const WORLD_CREATURES = `${WORLD_ROOT}/creature.sql`;
const WORLD_SUMMON_GROUPS = `${WORLD_ROOT}/creature_summon_groups.sql`;
const WORLD_SMART_SCRIPTS_SHA256 = "622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2";
const WORLD_GAMEOBJECTS_SHA256 = "3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42";
const WORLD_CREATURES_SHA256 = "f54ddfe7ee00080b4b0bc970a809d6f947f6fb2627dbcd67e92b402fb34cb701";
const WORLD_SUMMON_GROUPS_SHA256 = "f9130f5df57a62c4037dbd21b004537ffc6a7651cf9efe64785998d8fbe0828f";
const GNOMEREGAN_INSTANCE = `${SOURCE_ROOT}/EasternKingdoms/Gnomeregan/instance_gnomeregan.cpp`;
const GNOMEREGAN_HEADER = `${SOURCE_ROOT}/EasternKingdoms/Gnomeregan/gnomeregan.h`;
const DIRE_MAUL_INSTANCE = `${SOURCE_ROOT}/Kalimdor/DireMaul/instance_dire_maul.cpp`;
const DIRE_MAUL_HEADER = `${SOURCE_ROOT}/Kalimdor/DireMaul/dire_maul.h`;
const BRS_ROOT = `${SOURCE_ROOT}/EasternKingdoms/BlackrockMountain/BlackrockSpire`;
const BRS_INSTANCE = `${BRS_ROOT}/instance_blackrock_spire.cpp`;
const BRS_HEADER = `${BRS_ROOT}/blackrock_spire.h`;
const BRS_EMBERSEER = `${BRS_ROOT}/boss_pyroguard_emberseer.cpp`;
const BRS_REND = `${BRS_ROOT}/boss_rend_blackhand.cpp`;
const BRS_GYTH = `${BRS_ROOT}/boss_gyth.cpp`;
const BRS_UROK = `${BRS_ROOT}/boss_urok_doomhowl.cpp`;
export const WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS: readonly WowClassicEncounterBatchThreeReview[] = Object.freeze([
{
dungeonId: "gnomeregan",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: GNOMEREGAN_INSTANCE,
sha256: "33ff0dd41ec4509aa44c04d74e2c56d7f8a4b603bd8a1b0e304b8528af4acb80",
anchors: [
"completed Grubbis event despawns Blastmaster Emi (lines 43-54)",
"four reviewed doors are explicitly saveable (lines 57-68)",
"Grubbis completion is serialized (lines 70-91)",
],
},
{
path: GNOMEREGAN_HEADER,
sha256: "13eb073a20701bd1a1b2b2cfeb9bb4f973b0a8f39b469211eec619cb397e4860",
anchors: [
"Grubbis encounter data id (lines 32-37)",
"two cave-ins plus workshop/final door entries (lines 39-45)",
"Emi source entry (lines 47-50)",
],
},
{
path: WORLD_SMART_SCRIPTS,
sha256: WORLD_SMART_SCRIPTS_SHA256,
anchors: [
"Grubbis death completes instance data (line 11000)",
"Emi's four summon groups and duplicate group-three call (lines 11422-11460)",
"second then first cave-in open/reset transitions (lines 11440-11476)",
],
},
{
path: WORLD_SUMMON_GROUPS,
sha256: WORLD_SUMMON_GROUPS_SHA256,
anchors: [
"Emi group four contains Chomper and Grubbis (lines 408-409)",
"group three has five ambushers and is invoked twice (lines 410-414)",
"groups two and one contain four and ten ambushers (lines 415-428)",
],
},
{
path: WORLD_GAMEOBJECTS,
sha256: WORLD_GAMEOBJECTS_SHA256,
anchors: [
"first cave-in spawn 6447 (line 3009)",
"final chamber and workshop spawns 6910/12003 (lines 3171-5689)",
"second cave-in spawn 17633 (line 9643)",
],
},
],
reviewedBehavior: "Blastmaster Emi runs four source summon groups (ten ambushers, four ambushers, the same five-ambusher group twice, then Chomper and Grubbis), blows the second cave-in before the late waves, opens the first cave-in at the finish, and permanently records Grubbis. The workshop and final-chamber doors are ordinary saveable doors.",
adaptations: [
"Emi is a stationary interaction proxy; source waypoint movement, dialogue, emotes, explosives, and temporary charge objects are represented by deterministic source-delay phases.",
"The duplicate third summon-group call is retained as two independent five-ambusher groups with source coordinates and unique runtime ids.",
"Workshop and final-chamber doors are directly usable and persist their open state because the reviewed instance controller only enables database saving and supplies no boss/key gate.",
],
remainingGaps: [
"Emi's fifteen-point escort movement, dialogue audio/text, facing/emotes, explosive spell visuals, temporary charge gameobjects, and attack-start command remain unsupported.",
"Summoned actors use procedural bodies and combat proxies. Grubbis remains source-faithful melee-only because the pinned entry 7361 SmartAI has aggro dialogue and death completion but no combat spell; native Chomper abilities, timed corpse despawns, loot, and gossip presentation remain unported.",
"Live traversal, door-model animation/audio, escort NPC damage/failure semantics, and end-to-end multiplayer playtest evidence remain outstanding.",
],
},
{
dungeonId: "dire-maul",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: DIRE_MAUL_INSTANCE,
sha256: "b075f32650aaa884f2d047c7ef2f47c0196ab6efe4682ffca6193dd62663a86b",
anchors: [
"five saved wing/pylon counters initialize (lines 27-40)",
"Immol'thar protection and five-pylon release (lines 42-76 and 108-133)",
"tribute loot mask loses one tier per north-wing boss (lines 78-99)",
"east/west/pylon/north state serialization (lines 136-165)",
],
},
{
path: DIRE_MAUL_HEADER,
sha256: "955701c7b6bb765a8ce3c0d6b8b6e3e0f8f236de1a5e19dc502ca1a2b9103eb2",
anchors: [
"five wing and pylon data ids (lines 27-34)",
"Immol'thar and Highborne Summoner entries (lines 45-49)",
"forcefield and all-pylons-off mask 31 (lines 65-73)",
],
},
{
path: WORLD_SMART_SCRIPTS,
sha256: WORLD_SMART_SCRIPTS_SHA256,
anchors: [
"Zevrim, Ironbark, Alzzin wall, and east-wing signals (lines 13933-13959)",
"five tribute-penalty death signals (lines 15243-15318)",
"Mizzle creates the tribute at its exact source position (line 15364)",
"courtyard/inner door and five crystal-generator signals (lines 38777-38903)",
],
},
{
path: WORLD_GAMEOBJECTS,
sha256: WORLD_GAMEOBJECTS_SHA256,
anchors: [
"five pylons and forcefield spawns 29742-29747 (lines 19567-19572)",
"north courtyard and inner door spawns 29750/29756 (lines 19575-19581)",
"conservatory door and crumble wall spawns 268275/268276 (lines 87417-87418)",
],
},
{
path: WORLD_CREATURES,
sha256: WORLD_CREATURES_SHA256,
anchors: [
"Immol'thar, Prince, King, and five tribute NPC source rows (lines 146912-146972)",
"Zevrim source row 248429 (line 147315)",
"Alzzin and Old Ironbark source rows 248561/248564 (lines 147447-147450)",
],
},
],
reviewedBehavior: "Five persistent crystal-generator bits release Immol'thar and open his forcefield; Zevrim frees Old Ironbark to open the conservatory and Alzzin breaks the crumble wall below half health; north-wing doors save progress and each slain tribute NPC removes one of six tribute loot tiers.",
adaptations: [
"Crystal generators are explicit one-shot interactions because the source SmartAI's five-second autonomous update cannot observe the unsupported surrounding crystal-channeler state.",
"Old Ironbark is a stationary seven-second interaction proxy for his movement path; exact source doors and exact Alzzin database position are retained.",
"The six tribute loot masks are exposed as a visible tiered chest after King Gordok dies; exact item tables and Mizzle gossip are not representable by the current runtime.",
"The five tribute NPC objectives are optional and removed only from the generic completion guard, allowing a real no-kill tribute route while preserving their boss records and checkpoints.",
],
remainingGaps: [
"Pylon channeler deaths, generator animation/audio, Highborne Summoner activation, Immol'thar's native forcefield visual, and exact combat spells remain unported.",
"Ironbark's path, speech, redeemed model swap, Alzzin shapeshifts/add summons, and native destructible-wall presentation remain adapted.",
"North-wing disguise, keys, traps, guard gossip buffs, Kromcrush bypass, Cho'Rush surrender, Mizzle dialogue, exact tribute items, quests, and loot ownership are unsupported.",
"Wing-specific completion/lockout UI and end-to-end traversal/collision evidence remain outstanding.",
],
},
{
dungeonId: "blackrock-spire",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: BRS_INSTANCE,
sha256: "0b3b2f51fa7acd6e17dfa5ec4fe4f50c9f582b730bdb2e781e8b3c44ee996ee1",
anchors: [
"Dragonspire source mob entries and passage-door bindings (lines 31-72)",
"rune/door restoration and Emberseer/Urok object capture (lines 176-282)",
"Emberseer and Urok ProcessEvent entry points (lines 308-328)",
"three-second seven-rune scan and Emberseer gate release (lines 502-622)",
],
},
{
path: BRS_HEADER,
sha256: "40ae38809110acd1cf0e1f848e62cd81c0c3a1958a3c7c40c0b7afa7f475f8c1",
anchors: [
"Dragonspire/rune encounter ids (lines 29-57)",
"Emberseer, Rend, Gyth, Drakkisath and event actor entries (lines 69-94)",
"reviewed gates, runes, stadium, Drakkisath and Urok objects (lines 112-144)",
],
},
{
path: BRS_EMBERSEER,
sha256: "9eb330622df03d3ae19b19e3f040f43413664c5d9f4036500ca66108fe51e678",
anchors: [
"reset/retry and pre-fight setup (lines 78-116)",
"twenty growth stacks and five-second combat release (lines 137-159)",
"death opens three gates and seven runes (lines 161-191)",
"seven incarcerator activation/respawn behavior (lines 203-256)",
],
},
{
path: BRS_REND,
sha256: "a7493da0a46cf13c09176540edbef0ff2f2e79b09a0480f84b574fb599e7e58c",
anchors: [
"wave completion schedules thirteen-second dialogue transitions (lines 121-145)",
"stadium area-trigger start and wipe reset (lines 161-220)",
"six source group summons and exact Gyth spawn (lines 289-407)",
],
},
{
path: BRS_GYTH,
sha256: "ea6eb2a44895036ad2f0fc73b5632adc85859b1a389ef023ffee850f7a0ed483",
anchors: [
"Gyth releases Rend at twenty-five percent (lines 63-73)",
"wipe resets both encounter states (lines 82-91)",
"Corrosive Acid, Freeze, Flame Breath, and Knock Away schedules (lines 93-160)",
],
},
{
path: BRS_UROK,
sha256: "6b0dcaa93c09e72eeb18109be8ee9cb38321e5bb703dcc6666a9deb9dbeaaece",
anchors: [
"Urok spawn removes challenge and disables tribute pile (lines 42-58)",
"source boss combat schedule (lines 60-98)",
],
},
{
path: WORLD_SUMMON_GROUPS,
sha256: WORLD_SUMMON_GROUPS_SHA256,
anchors: [
"Rend's exact six groups contain 4/3/4/4/5/5 actors (lines 563-587)",
],
},
{
path: WORLD_SMART_SCRIPTS,
sha256: WORLD_SMART_SCRIPTS_SHA256,
anchors: [
"Urok pile spell creates challenge and twenty-count failure (lines 38706-38709)",
"Spirestone Battle Lord and Butcher use 85% initialization despawns with no combat casts (lines 12240-12241)",
"Crystal Fang uses the same rare-spawn roll and casts Summon Spire Spiderling on death (lines 12743-12744)",
],
},
{
path: WORLD_GAMEOBJECTS,
sha256: WORLD_GAMEOBJECTS_SHA256,
anchors: [
"seven hall and seven Emberseer rune spawns 87824-87837 (lines 60607-60620)",
"Emberseer and stadium gates 87839-87845 (lines 60622-60628)",
"Drakkisath passage doors 87854/87855 (lines 60637-60638)",
"Urok tribute pile spawn 99791 (line 60858)",
],
},
],
reviewedBehavior: "Seven Dragonspire rune neighborhoods unlock Emberseer's approach; seven incarcerators start his twenty-stack release and death opens the exit; Rend's stadium uses six exact summon groups before Gyth releases Rend at 25%; Drakkisath controls two passage doors; the Urok pile starts a separate optional event; the three rare Spirestone bosses retain their source 15% appearance rate and Crystal Fang summons a Spire Spiderling on death.",
adaptations: [
"The source's three-second spatial rune scans are deterministic exact imported-spawn death checks. The area trigger and Emberseer ProcessEvent are explicit reviewed-object/procedural-trigger interactions.",
"Emberseer's missing aura engine is represented by a twenty-second growth phase plus the source five-second pre-combat release; incarcerator deaths, gate reset, and retry stay finite.",
"Rend keeps all six exact source groups and 13-second intermissions; unsupported speech, facing, paths, and teleports become deterministic delays. Gyth's four source combat schedules are executable, his three 20-yard frontal spells use runtime cone geometry, and his 25% live health edge releases Rend with a death fallback preventing a soft lock.",
"Freeze preserves its direct Frost hit and ten-second stun on the same targets. Its periodic damage, Corrosive Acid's periodic damage and armor reduction, and the exact client cone angle cannot be expressed by the current enemy-effect contract and are explicitly omitted rather than replaced with invented effects.",
"A direct Urok boss fallback is used because the pinned source starts instance state but contains no wave scheduler. His Rend, Strike, and Intimidating Roar schedules are executable; Roar uses the source eight-yard/eight-second nearby fear while the distinct primary-target root is collapsed into that one supported area-control effect. The tribute-item spell and twenty-add failure counter are not fabricated.",
"The database's 85% initialization despawns are compiled as 15% rare-spawn chances. Crystal Fang's death spell becomes one deterministic Spire Spiderling spawn at the boss location because generic spell-summon visuals are unavailable.",
],
remainingGaps: [
"Dragonspire's native three-second proximity scan, rune/door animation and audio, channeling presentation, and actor spell kits remain adapted.",
"Emberseer encage/growth/fire-shield spells, immunity/faction transitions, dialogue, and exact incarcerator coordination remain unsupported.",
"Stadium Nefarius/Rend dialogue, emotes, movement paths, teleports, mount/dismount visuals, Gyth's chromatic-protection resistance proc, Freeze periodic damage, Corrosive Acid periodic damage/armor reduction, exact frontal cone angle, exact knockback displacement, overlapping combat edge cases, and native door toggles remain adapted.",
"Drakkisath guard linkage, native passage-door semantics, Urok's Rend periodic damage and Roar's split primary-root/nearby-fear targeting, required Omokk-head item spell, challenge/circle objects, missing source wave logic, twenty-count failure, Crystal Fang's native death-spell visual, loot, quests, and full traversal playtests remain unsupported.",
"The existing Father Flame/Solakar adapter remains authoritative and is intentionally not duplicated here.",
],
},
]);
const REVIEW_BY_DUNGEON = new Map(
WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => [review.dungeonId, review] as const),
);
function adaptedEntityId(entry: number): string {
return `wow-classic-batch-three-entry-${entry}`;
}
function findEntityIdByEntry(entities: PopulationDefinitionMap, entry: number): string | undefined {
return Object.entries(entities).find(([, entity]) => entity.combat?.serverEntry === entry)?.[0];
}
function createAdaptedMob(spec: AdaptedMobSpec): PopulationEntityDefinition {
const caster = spec.role === "caster";
const common = {
id: adaptedEntityId(spec.entry),
name: spec.name,
hasLoot: spec.boss ?? false,
combat: {
serverEntry: spec.entry,
level: spec.level,
healthMultiplier: spec.boss ? 4.8 : caster ? 0.92 : 1.06,
damageMultiplier: spec.boss ? 1.18 : 0.92,
moveSpeed: 3.5,
aggroRange: 32,
leashRange: 90,
mechanicIds: Object.freeze((spec.attacks ?? []).map((attack) => attack.id)),
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 ? "shadow" as const : "physical" as const,
animation: caster ? "cast" as const : "attack" as const,
range: caster ? 25 : 4.5,
cooldownMs: caster ? 3_000 : 2_000,
damageMultiplier: caster ? 0.8 : 1,
}, ...(spec.attacks ?? [])]),
},
visual: {
primaryColor: spec.boss ? "#713f34" : caster ? "#5a4a78" : "#705044",
accentColor: spec.boss ? "#ef9a61" : caster ? "#c3a2eb" : "#d6a17e",
scale: spec.boss ? 1.18 : 1,
archetype: "humanoid" as const,
},
};
return Object.freeze(spec.boss
? { ...common, kind: "boss" as const, title: "Dungeon Boss" }
: { ...common, kind: "mob" as const });
}
function ensureAdaptedMobs(
definition: DungeonDefinition,
specs: readonly AdaptedMobSpec[],
): { readonly entities: PopulationDefinitionMap; readonly idsByEntry: ReadonlyMap<number, string> } {
const entities: Record<string, PopulationEntityDefinition> = { ...definition.entities };
const idsByEntry = new Map<number, string>();
for (const spec of specs) {
const existingId = findEntityIdByEntry(entities, spec.entry);
if (existingId) {
idsByEntry.set(spec.entry, existingId);
continue;
}
const entity = createAdaptedMob(spec);
if (entities[entity.id]) throw new Error(`Duplicate Classic batch-three entity ${entity.id}`);
entities[entity.id] = entity;
idsByEntry.set(spec.entry, entity.id);
}
return { entities: Object.freeze(entities), idsByEntry };
}
function entityEntry(definition: DungeonDefinition, entityId: string): number | undefined {
return definition.entities[entityId]?.combat?.serverEntry;
}
function staticSpawnsForEntry(definition: DungeonDefinition, entry: number): readonly StaticMobSpawnDefinition[] {
return definition.staticSpawns.filter((spawn) => entityEntry(definition, spawn.entityId) === entry);
}
function staticSpawnForEntry(definition: DungeonDefinition, entry: number): StaticMobSpawnDefinition {
const spawns = staticSpawnsForEntry(definition, entry);
if (spawns.length !== 1) {
throw new Error(`${definition.id} expected one static spawn for entry ${entry}, found ${spawns.length}`);
}
return spawns[0];
}
function objectiveForEntry(definition: DungeonDefinition, entry: number): DungeonBossObjective {
const spawnIds = new Set(staticSpawnsForEntry(definition, entry).map((spawn) => spawn.id));
const objective = definition.bosses.find((boss) => spawnIds.has(boss.id));
if (!objective) throw new Error(`${definition.id} is missing boss objective for entry ${entry}`);
return objective;
}
function sourceObjectBySpawnId(definition: DungeonDefinition, spawnId: number): DungeonWorldObjectDefinition {
const objects = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
if (objects.length !== 1) {
throw new Error(`${definition.id} expected one source gameobject spawn ${spawnId}, found ${objects.length}`);
}
return objects[0];
}
function reviewedObject(
source: DungeonWorldObjectDefinition,
options: {
readonly kind: DungeonWorldObjectDefinition["kind"];
readonly initialState: string;
readonly states: readonly DungeonWorldObjectStateDefinition[];
readonly color: string;
readonly emissive: string;
readonly interaction?: DungeonWorldObjectDefinition["interaction"];
readonly note: string;
},
): DungeonWorldObjectDefinition {
if (!source.source?.spawnId) throw new Error(`Reviewed source object ${source.id} lacks a source spawn id`);
return Object.freeze({
...source,
kind: options.kind,
initialState: options.initialState,
states: Object.freeze(options.states),
...(options.interaction ? { interaction: Object.freeze(options.interaction) } : { interaction: undefined }),
visual: Object.freeze({ color: options.color, emissive: options.emissive }),
source: Object.freeze({ ...source.source, fidelity: "source-backed-adapted", note: options.note }),
});
}
function reviewedDoor(
source: DungeonWorldObjectDefinition,
initialState: "closed" | "open",
note: string,
): DungeonWorldObjectDefinition {
return reviewedObject(source, {
kind: source.kind === "gate" ? "gate" : "door",
initialState,
states: [
{ id: "closed", label: "Closed", visible: true, blocksMovement: true, blocksSight: true, interactionEnabled: false },
{ id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false, interactionEnabled: false },
],
color: "#5d5141",
emissive: "#17110b",
note,
});
}
function reviewedOpenableDoor(
source: DungeonWorldObjectDefinition,
signalId: string,
prompt: string,
note: string,
): DungeonWorldObjectDefinition {
return reviewedObject(source, {
kind: source.kind === "gate" ? "gate" : "door",
initialState: "closed",
states: [
{ id: "closed", label: "Closed", visible: true, blocksMovement: true, blocksSight: true, interactionEnabled: true },
{ id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false, interactionEnabled: false },
],
color: "#6a5942",
emissive: "#1b140c",
interaction: { prompt, range: 3.4, signalId, allowedStates: ["closed"], oneShot: false },
note,
});
}
function reviewedSwitch(
source: DungeonWorldObjectDefinition,
options: {
readonly initialState: string;
readonly states: readonly DungeonWorldObjectStateDefinition[];
readonly prompt: string;
readonly allowedStates: readonly string[];
readonly signalId: string;
readonly color: string;
readonly note: string;
},
): DungeonWorldObjectDefinition {
return reviewedObject(source, {
kind: "switch",
initialState: options.initialState,
states: options.states,
color: options.color,
emissive: options.color,
interaction: {
prompt: options.prompt,
range: 3.4,
signalId: options.signalId,
allowedStates: options.allowedStates,
oneShot: false,
},
note: options.note,
});
}
function proceduralObject(options: {
readonly id: string;
readonly name: string;
readonly kind: DungeonWorldObjectDefinition["kind"];
readonly position: Vector3Tuple;
readonly initialState: string;
readonly states: readonly DungeonWorldObjectStateDefinition[];
readonly prompt?: string;
readonly signalId?: string;
readonly allowedStates?: readonly string[];
readonly color: string;
readonly files: readonly string[];
readonly note: string;
}): DungeonWorldObjectDefinition {
return Object.freeze({
id: options.id,
name: options.name,
kind: options.kind,
position: options.position,
halfExtents: [0.9, options.kind === "npc" ? 1.8 : 0.9, 0.9] as Vector3Tuple,
initialState: options.initialState,
states: Object.freeze(options.states),
...(options.prompt && options.signalId ? {
interaction: Object.freeze({
prompt: options.prompt,
range: 3.4,
signalId: options.signalId,
allowedStates: options.allowedStates,
oneShot: false,
}),
} : {}),
visual: Object.freeze({ color: options.color, emissive: options.color }),
source: Object.freeze({ fidelity: "source-backed-adapted" as const, files: options.files, note: options.note }),
});
}
function replaceObjects(
definition: DungeonDefinition,
replacements: readonly DungeonWorldObjectDefinition[],
additions: readonly DungeonWorldObjectDefinition[] = [],
): readonly DungeonWorldObjectDefinition[] {
const byId = new Map(replacements.map((object) => [object.id, object] as const));
return Object.freeze([...definition.serverGameObjects.map((object) => byId.get(object.id) ?? object), ...additions]);
}
function sourceReview(definition: DungeonDefinition): DungeonEncounterDefinition["source"] {
const review = REVIEW_BY_DUNGEON.get(definition.id);
if (!review) throw new Error(`Missing Classic batch-three source review for ${definition.id}`);
return Object.freeze({
fidelity: review.fidelity,
scope: "instance-orchestration",
files: review.sourceFiles.map((file) => `${file.path} (sha256 ${file.sha256}; ${file.anchors.join("; ")})`),
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 eventSpawn(id: string, entityId: string, position: Vector3Tuple, yaw = 0): StaticMobSpawnDefinition {
return Object.freeze({ id, entityId, position, yaw, spawnMask: 3 });
}
function eventGroup(id: string, spawns: readonly StaticMobSpawnDefinition[]): DungeonEncounterSpawnGroupDefinition {
return Object.freeze({ id, spawns: Object.freeze(spawns) });
}
function removeStaticSpawnIds(
definition: DungeonDefinition,
ids: ReadonlySet<string>,
): readonly StaticMobSpawnDefinition[] {
return Object.freeze(definition.staticSpawns.filter((spawn) => !ids.has(spawn.id)));
}
function addObjectives(
definition: DungeonDefinition,
objectives: readonly DungeonBossObjective[],
): Pick<DungeonDefinition, "bosses" | "objectiveOrder"> {
const additions = objectives.filter((objective) => !definition.bosses.some((boss) => boss.id === objective.id));
return {
bosses: Object.freeze([...definition.bosses, ...additions]),
objectiveOrder: Object.freeze([...definition.objectiveOrder, ...additions]),
};
}
function markEventObjectives(
definition: DungeonDefinition,
objectives: readonly DungeonBossObjective[],
): Pick<DungeonDefinition, "bosses" | "objectiveOrder"> {
const byId = new Map(objectives.map((objective) => [objective.id, Object.freeze({ ...objective, eventOnly: true })] as const));
return {
bosses: Object.freeze(definition.bosses.map((objective) => byId.get(objective.id) ?? objective)),
objectiveOrder: Object.freeze(definition.objectiveOrder.map((objective) => byId.get(objective.id) ?? objective)),
};
}
function makeObjectivesOptional(
objectives: readonly DungeonBossObjective[],
optionalIds: ReadonlySet<string>,
): readonly DungeonBossObjective[] {
return Object.freeze(objectives.map((objective) => optionalIds.has(objective.id)
? Object.freeze({ ...objective, optional: true })
: objective));
}
function sourcePosition(source: readonly [x: number, y: number, z: number]): Vector3Tuple {
return [-source[0], source[2], source[1]];
}
function direMaulSourcePosition(source: readonly [x: number, y: number, z: number]): Vector3Tuple {
return [source[0], source[2], -source[1]];
}
function patchFleetForRequiredEventObjective(
encounters: readonly DungeonEncounterDefinition[] | undefined,
dungeonId: string,
objective: DungeonBossObjective,
): readonly DungeonEncounterDefinition[] {
return Object.freeze((encounters ?? []).map((encounter) => {
if (encounter.id !== `${dungeonId}:boss-progression`) return encounter;
const completion = encounter.rules.find((rule) => rule.id === "all-required-bosses-defeated");
if (!completion || completion.guards?.some((guard) => guard.kind === "objective-complete" && guard.objectiveId === objective.id)) {
return encounter;
}
const eventRule: DungeonEncounterRule = {
id: "classic-batch-three-required-event-defeated",
trigger: { kind: "entities-dead", entityIds: [objective.id] },
actions: [
{ id: "classic-batch-three-record-required-event", kind: "complete-objective", objectiveId: objective.id, persistence: "run" },
{ id: "classic-batch-three-checkpoint-required-event", kind: "set-checkpoint", checkpointId: objective.id, persistence: "checkpoint" },
],
};
return Object.freeze({
...encounter,
rules: Object.freeze(encounter.rules.flatMap((rule) => rule.id === completion.id
? [eventRule, Object.freeze({
...rule,
guards: Object.freeze([
...(rule.guards ?? []),
{ kind: "objective-complete" as const, objectiveId: objective.id, equals: true },
]),
})]
: [rule])),
});
}));
}
function patchFleetOptionalGuards(
encounters: readonly DungeonEncounterDefinition[] | undefined,
dungeonId: string,
optionalObjectiveIds: ReadonlySet<string>,
): readonly DungeonEncounterDefinition[] {
return Object.freeze((encounters ?? []).map((encounter) => {
if (encounter.id !== `${dungeonId}:boss-progression`) return encounter;
return Object.freeze({
...encounter,
rules: Object.freeze(encounter.rules.map((rule) => rule.id !== "all-required-bosses-defeated"
? rule
: Object.freeze({
...rule,
guards: Object.freeze((rule.guards ?? []).filter((guard) => (
guard.kind !== "objective-complete" || !optionalObjectiveIds.has(guard.objectiveId)
))),
}))),
});
}));
}
function buildGnomeregan(definition: DungeonDefinition): DungeonDefinition {
const { entities, idsByEntry } = ensureAdaptedMobs(definition, [
{ entry: 6_207, name: "Caverndeep Ambusher", level: 27, role: "melee" },
{ entry: 6_215, name: "Chomper", level: 32, role: "melee" },
{ entry: 7_361, name: "Grubbis", level: 32, role: "melee", boss: true },
]);
const emiSpawn = staticSpawnForEntry(definition, 7_998);
const grubbisObjective: DungeonBossObjective = Object.freeze({
id: "gnomeregan:batch-three:grubbis",
name: "Grubbis",
position: sourcePosition([-495.24, -90.808, -149.493]),
eventOnly: true,
});
const objectives = addObjectives(definition, [grubbisObjective]);
const caveInOne = reviewedDoor(
sourceObjectBySpawnId(definition, 6_447),
"closed",
"Exact source cave-in one becomes collidable and opens when Grubbis dies; native cave-in animation and explosion visuals remain unsupported.",
);
const caveInTwo = reviewedDoor(
sourceObjectBySpawnId(definition, 17_633),
"closed",
"Exact source cave-in two becomes collidable and opens at Emi's reviewed mid-event blast point; native explosion visuals remain unsupported.",
);
const workshopDoor = reviewedOpenableDoor(
sourceObjectBySpawnId(definition, 12_003),
"gnomeregan:batch-three:workshop-door",
"Open Workshop Door",
"The exact saveable source door is directly usable; no boss/key gate exists in the pinned instance or SmartAI source.",
);
const finalDoor = reviewedOpenableDoor(
sourceObjectBySpawnId(definition, 6_910),
"gnomeregan:batch-three:final-door",
"Open Final Chamber",
"The exact saveable source door is directly usable; no boss/key gate exists in the pinned instance or SmartAI source.",
);
const emi = proceduralObject({
id: "gnomeregan:batch-three:emi-shortfuse",
name: "Blastmaster Emi Shortfuse",
kind: "npc",
position: emiSpawn.position,
initialState: "ready",
states: [
{ id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "active", label: "Blasting in progress", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "complete", label: "Event complete", visible: false, blocksMovement: false, interactionEnabled: false },
],
prompt: "Begin the Grubbis blast sequence",
signalId: "gnomeregan:batch-three:start-emi",
allowedStates: ["ready"],
color: "#b78945",
files: [GNOMEREGAN_INSTANCE, WORLD_SMART_SCRIPTS, WORLD_SUMMON_GROUPS],
note: "Uses Emi's exact imported source position as a deterministic interaction proxy for unsupported gossip and waypoint movement.",
});
const groupRows: readonly (readonly [entry: number, x: number, y: number, z: number, yaw: number])[][] = [
[
[6_207, -554.03, -115.983, -152.635, 0.695],
[6_207, -550.708, -116.436, -153.103, 0.679],
[6_207, -552.534, -110.012, -153.577, 0.747],
[6_207, -547.274, -114.109, -153.952, 0.735],
[6_207, -547.736, -105.154, -155.176, 0.372],
[6_207, -546.905, -108.34, -154.877, 0.729],
[6_207, -548.692, -111.089, -154.09, 0.621],
[6_207, -552.154, -112.476, -153.349, 0.621],
[6_207, -555.263, -113.802, -152.737, 0.311],
[6_207, -557.63, -114.514, -152.209, 0.641],
],
[
[6_207, -548.692, -111.089, -154.09, 0.621],
[6_207, -552.154, -112.476, -153.349, 0.621],
[6_207, -555.263, -113.802, -152.737, 0.311],
[6_207, -557.63, -114.514, -152.209, 0.641],
],
[
[6_207, -490.554, -89.114, -148.055, 3.23],
[6_207, -490.732, -90.739, -148.091, 3.23],
[6_207, -491.995, -87.619, -148.197, 3.23],
[6_207, -493.349, -90.845, -148.882, 3.717],
[6_207, -494.595, -87.516, -149.116, 3.344],
],
[
[6_207, -490.554, -89.114, -148.055, 3.23],
[6_207, -490.732, -90.739, -148.091, 3.23],
[6_207, -491.995, -87.619, -148.197, 3.23],
[6_207, -493.349, -90.845, -148.882, 3.717],
[6_207, -494.595, -87.516, -149.116, 3.344],
],
[
[6_215, -494.195, -89.553, -149.131, 3.254],
[7_361, -495.24, -90.808, -149.493, 3.238],
],
];
const groupNames = ["ambushers-1", "ambushers-2", "ambushers-3a", "ambushers-3b", "grubbis"] as const;
const groups = Object.freeze(groupRows.map((rows, groupIndex) => eventGroup(
`gnomeregan:batch-three:${groupNames[groupIndex]}`,
rows.map(([entry, x, y, z, yaw], spawnIndex) => eventSpawn(
groupIndex === 4 && entry === 7_361
? grubbisObjective.id
: `gnomeregan:batch-three:${groupNames[groupIndex]}-${spawnIndex + 1}`,
idsByEntry.get(entry)!,
sourcePosition([x, y, z]),
-yaw,
)),
)));
const rules: DungeonEncounterRule[] = [
{
id: "open-workshop-door",
trigger: { kind: "interact", objectId: workshopDoor.id },
guards: [{ kind: "world-object-state", objectId: workshopDoor.id, states: ["closed"] }],
actions: [{ id: "batch-three-open-workshop-door", kind: "set-world-object-state", objectId: workshopDoor.id, state: "open", persistence: "run" }],
},
{
id: "open-final-door",
trigger: { kind: "interact", objectId: finalDoor.id },
guards: [{ kind: "world-object-state", objectId: finalDoor.id, states: ["closed"] }],
actions: [{ id: "batch-three-open-final-door", kind: "set-world-object-state", objectId: finalDoor.id, state: "open", persistence: "run" }],
},
{
id: "start-emi",
trigger: { kind: "interact", objectId: emi.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "world-object-state", objectId: emi.id, states: ["ready"] },
],
actions: [
{ id: "batch-three-mark-emi-active", kind: "set-world-object-state", objectId: emi.id, state: "active" },
{ id: "batch-three-enter-emi-wave-1", kind: "set-phase", phase: "emi-wave-1" },
{ id: "batch-three-spawn-emi-wave-1", kind: "activate-spawn-group", spawnGroupId: groups[0].id },
],
},
];
const transitions = [
{ from: "emi-wave-1", group: groups[0], next: "emi-wave-2-delay", delay: 5_000, nextGroup: groups[1] },
{ from: "emi-wave-2", group: groups[1], next: "emi-cave-two-delay", delay: 40_000, nextGroup: groups[2], openCaveTwo: true },
{ from: "emi-wave-3a", group: groups[2], next: "emi-wave-3b-delay", delay: 3_000, nextGroup: groups[3] },
{ from: "emi-wave-3b", group: groups[3], next: "emi-grubbis-delay", delay: 60_000, nextGroup: groups[4] },
] as const;
transitions.forEach((transition, index) => {
rules.push({
id: `finish-emi-stage-${index + 1}`,
trigger: { kind: "entities-dead", entityIds: transition.group.spawns.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: [transition.from] }],
actions: [
{ id: `batch-three-despawn-emi-stage-${index + 1}`, kind: "deactivate-spawn-group", spawnGroupId: transition.group.id },
{ id: `batch-three-enter-emi-delay-${index + 1}`, kind: "set-phase", phase: transition.next },
],
});
rules.push({
id: `advance-emi-stage-${index + 1}`,
trigger: { kind: "elapsed", afterMs: transition.delay },
guards: [{ kind: "phase", phases: [transition.next] }],
actions: [
...("openCaveTwo" in transition && transition.openCaveTwo ? [{
id: "batch-three-open-cave-in-two",
kind: "set-world-object-state" as const,
objectId: caveInTwo.id,
state: "open",
}] : []),
{ id: `batch-three-enter-next-emi-wave-${index + 1}`, kind: "set-phase", phase: index === 0 ? "emi-wave-2" : index === 1 ? "emi-wave-3a" : index === 2 ? "emi-wave-3b" : "emi-grubbis" },
{ id: `batch-three-spawn-next-emi-wave-${index + 1}`, kind: "activate-spawn-group", spawnGroupId: transition.nextGroup.id },
],
});
});
rules.push({
id: "grubbis-defeated",
trigger: { kind: "entity-death", entityId: grubbisObjective.id },
guards: [{ kind: "phase", phases: ["emi-grubbis"] }],
actions: [
{ id: "batch-three-despawn-grubbis-group", kind: "deactivate-spawn-group", spawnGroupId: groups[4].id },
{ id: "batch-three-open-cave-in-one", kind: "set-world-object-state", objectId: caveInOne.id, state: "open", persistence: "run" },
{ id: "batch-three-mark-emi-complete", kind: "set-world-object-state", objectId: emi.id, state: "complete", persistence: "run" },
{ id: "batch-three-complete-grubbis", kind: "complete-objective", objectiveId: grubbisObjective.id, persistence: "run" },
{ id: "batch-three-checkpoint-grubbis", kind: "set-checkpoint", checkpointId: "gnomeregan-grubbis", persistence: "checkpoint" },
{ id: "batch-three-complete-emi-phase", kind: "set-phase", phase: "complete", persistence: "run" },
],
});
const scripted: DungeonEncounterDefinition = Object.freeze({
id: "gnomeregan:classic-batch-three",
dungeonId: definition.id,
initialPhase: "idle",
initialState: Object.freeze({
worldObjectStates: Object.freeze({
[caveInOne.id]: "closed",
[caveInTwo.id]: "closed",
[workshopDoor.id]: "closed",
[finalDoor.id]: "closed",
[emi.id]: "ready",
}),
}),
resetPolicy: Object.freeze({ bossWipePhase: "idle", checkpointPhase: "idle" }),
source: sourceReview(definition),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
...objectives,
staticSpawns: removeStaticSpawnIds(definition, new Set([emiSpawn.id])),
encounters: Object.freeze([
...patchFleetForRequiredEventObjective(definition.encounters, definition.id, grubbisObjective),
scripted,
]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), ...groups]),
serverGameObjects: replaceObjects(definition, [caveInOne, caveInTwo, workshopDoor, finalDoor], [emi]),
validation: appendValidationWarnings(definition, [
"Gnomeregan's Grubbis event is source-backed but adapted: waypoint movement, dialogue, explosives, native visuals, and escort failure semantics remain unsupported.",
]),
});
}
function buildDireMaul(definition: DungeonDefinition): DungeonDefinition {
const { entities, idsByEntry } = ensureAdaptedMobs(definition, [
{ entry: 11_492, name: "Alzzin the Wildshaper", level: 58, role: "caster", boss: true },
]);
const immolSpawn = staticSpawnForEntry(definition, 11_496);
const immolObjective = objectiveForEntry(definition, 11_496);
const zevrimSpawn = staticSpawnForEntry(definition, 11_490);
const princeSpawn = staticSpawnForEntry(definition, 11_486);
const kingSpawn = staticSpawnForEntry(definition, 11_501);
const choRushSpawn = staticSpawnForEntry(definition, 14_324);
const eventMarked = markEventObjectives(definition, [immolObjective]);
const alzzinObjective: DungeonBossObjective = Object.freeze({
id: "dire-maul:batch-three:alzzin",
name: "Alzzin the Wildshaper",
position: direMaulSourcePosition([274.844, -427.251, -119.962]),
optional: true,
eventOnly: true,
});
const princeObjective: DungeonBossObjective = Object.freeze({
id: princeSpawn.id,
name: "Prince Tortheldrin",
position: princeSpawn.position,
optional: true,
});
const choRushObjective: DungeonBossObjective = Object.freeze({
id: choRushSpawn.id,
name: "Cho'Rush the Observer",
position: choRushSpawn.position,
optional: true,
});
const optionalTributeIds = new Set([
staticSpawnForEntry(definition, 14_326).id,
staticSpawnForEntry(definition, 14_321).id,
staticSpawnForEntry(definition, 14_323).id,
staticSpawnForEntry(definition, 14_325).id,
choRushSpawn.id,
]);
const addedBosses = [alzzinObjective, princeObjective, choRushObjective].filter((objective) => (
!eventMarked.bosses.some((existing) => existing.id === objective.id)
));
const addedOrder = [alzzinObjective, princeObjective, choRushObjective].filter((objective) => (
!eventMarked.objectiveOrder.some((existing) => existing.id === objective.id)
));
const bosses = makeObjectivesOptional([...eventMarked.bosses, ...addedBosses], optionalTributeIds);
const objectiveOrder = makeObjectivesOptional([...eventMarked.objectiveOrder, ...addedOrder], optionalTributeIds);
const pylonSpawnIds = [29_742, 29_743, 29_744, 29_745, 29_746] as const;
const pylons = pylonSpawnIds.map((spawnId, index) => reviewedSwitch(
sourceObjectBySpawnId(definition, spawnId),
{
initialState: "active",
states: [
{ id: "active", label: "Crystal generator active", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "off", label: "Crystal generator disabled", visible: true, blocksMovement: false, interactionEnabled: false },
],
prompt: `Disable Crystal Generator ${index + 1}`,
allowedStates: ["active"],
signalId: `dire-maul:batch-three:pylon-${index + 1}`,
color: "#6f5cd2",
note: "Exact source pylon spawn and bit identity are retained; direct use safely replaces the unsupported automatic channeler-state scan.",
},
));
const forcefield = reviewedDoor(
sourceObjectBySpawnId(definition, 29_747),
"closed",
"Exact source forcefield blocks Immol'thar until all five pylon bits are set; procedural collision replaces its native field visual.",
);
const courtyardDoor = reviewedOpenableDoor(
sourceObjectBySpawnId(definition, 29_750),
"dire-maul:batch-three:courtyard-door",
"Open Gordok Courtyard Door",
"Exact source gossip-use door records north-wing progress one and remains open for the run.",
);
const innerDoor = reviewedOpenableDoor(
sourceObjectBySpawnId(definition, 29_756),
"dire-maul:batch-three:inner-door",
"Open Gordok Inner Door",
"Exact source gossip-use door records north-wing progress two and remains open for the run.",
);
const conservatoryDoor = reviewedDoor(
sourceObjectBySpawnId(definition, 268_275),
"closed",
"Exact source conservatory door opens after the adapted Old Ironbark path delay.",
);
const crumbleWall = reviewedDoor(
sourceObjectBySpawnId(definition, 268_276),
"closed",
"Exact source wall opens on Alzzin's live at-or-below-fifty-percent health edge.",
);
const ironbark = proceduralObject({
id: "dire-maul:batch-three:old-ironbark",
name: "Old Ironbark",
kind: "npc",
position: direMaulSourcePosition([-56.59, -269.12, -57.87]),
initialState: "locked",
states: [
{ id: "locked", label: "Waiting for Zevrim", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "ready", label: "Ready to open the conservatory", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "walking", label: "Opening the conservatory", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "complete", label: "Conservatory open", visible: false, blocksMovement: false, interactionEnabled: false },
],
prompt: "Ask Old Ironbark to open the conservatory",
signalId: "dire-maul:batch-three:ironbark",
allowedStates: ["ready"],
color: "#647b45",
files: [DIRE_MAUL_INSTANCE, WORLD_SMART_SCRIPTS, WORLD_CREATURES],
note: "Exact source creature position is a stationary proxy for Old Ironbark's unsupported redeemed model and movement path.",
});
const tributeChest = proceduralObject({
id: "dire-maul:batch-three:gordok-tribute",
name: "Gordok Tribute",
kind: "chest",
position: direMaulSourcePosition([808.37, 482.128, 37.3182]),
initialState: "hidden",
states: [
{ id: "hidden", label: "Unavailable", visible: false, blocksMovement: false, interactionEnabled: false },
...Array.from({ length: 6 }, (_, index): DungeonWorldObjectStateDefinition => ({
id: `tier-${index + 1}`,
label: `Tribute tier ${index + 1} of 6`,
visible: true,
blocksMovement: false,
interactionEnabled: false,
})),
],
color: "#d09a35",
files: [DIRE_MAUL_INSTANCE, WORLD_SMART_SCRIPTS],
note: "Uses Mizzle's exact dynamic tribute position. Visible tier state represents the source loot mask; item-table granting is unsupported.",
});
const immolGroup = eventGroup("dire-maul:batch-three:immolthar", [immolSpawn]);
const alzzinGroup = eventGroup("dire-maul:batch-three:alzzin", [eventSpawn(
alzzinObjective.id,
idsByEntry.get(11_492)!,
alzzinObjective.position,
-0.970313,
)]);
const rules: DungeonEncounterRule[] = [];
pylons.forEach((pylon, index) => rules.push({
id: `disable-pylon-${index + 1}`,
trigger: { kind: "interact", objectId: pylon.id },
guards: [{ kind: "world-object-state", objectId: pylon.id, states: ["active"] }],
actions: [
{ id: `batch-three-record-pylon-${index + 1}`, kind: "increment-counter", counterId: "pylons-off", persistence: "run" },
{ id: `batch-three-disable-pylon-${index + 1}`, kind: "set-world-object-state", objectId: pylon.id, state: "off", persistence: "run" },
],
}));
rules.push(
{
id: "release-immolthar",
trigger: { kind: "tick" },
guards: [
{ kind: "counter", counterId: "pylons-off", comparison: "at-least", value: 5 },
{ kind: "flag", flagId: "immolthar-released", equals: false },
],
actions: [
{ id: "batch-three-record-immolthar-released", kind: "set-flag", flagId: "immolthar-released", value: true, persistence: "run" },
{ id: "batch-three-open-immolthar-forcefield", kind: "set-world-object-state", objectId: forcefield.id, state: "open", persistence: "run" },
{ id: "batch-three-spawn-immolthar", kind: "activate-spawn-group", spawnGroupId: immolGroup.id },
],
},
{
id: "recover-immolthar",
trigger: { kind: "tick" },
guards: [
{ kind: "flag", flagId: "immolthar-released", equals: true },
{ kind: "objective-complete", objectiveId: immolObjective.id, equals: false },
{ kind: "spawn-group-active", spawnGroupId: immolGroup.id, equals: false },
],
actions: [{ id: "batch-three-recover-immolthar", kind: "activate-spawn-group", spawnGroupId: immolGroup.id }],
},
{
id: "immolthar-defeated",
trigger: { kind: "entity-death", entityId: immolSpawn.id },
actions: [
{ id: "batch-three-despawn-immolthar", kind: "deactivate-spawn-group", spawnGroupId: immolGroup.id },
{ id: "batch-three-complete-immolthar", kind: "complete-objective", objectiveId: immolObjective.id, persistence: "run" },
{ id: "batch-three-checkpoint-immolthar", kind: "set-checkpoint", checkpointId: "dire-maul-immolthar", persistence: "checkpoint" },
],
},
{
id: "zevrim-frees-ironbark",
trigger: { kind: "entity-death", entityId: zevrimSpawn.id },
guards: [{ kind: "world-object-state", objectId: ironbark.id, states: ["locked"] }],
actions: [{ id: "batch-three-ready-ironbark", kind: "set-world-object-state", objectId: ironbark.id, state: "ready", persistence: "run" }],
},
{
id: "start-ironbark-path",
trigger: { kind: "interact", objectId: ironbark.id },
guards: [{ kind: "world-object-state", objectId: ironbark.id, states: ["ready"] }],
actions: [
{ id: "batch-three-mark-ironbark-walking", kind: "set-world-object-state", objectId: ironbark.id, state: "walking" },
{ id: "batch-three-enter-ironbark-delay", kind: "set-phase", phase: "ironbark-delay" },
],
},
{
id: "ironbark-opens-conservatory",
trigger: { kind: "elapsed", afterMs: 7_000 },
guards: [{ kind: "phase", phases: ["ironbark-delay"] }],
actions: [
{ id: "batch-three-open-conservatory", kind: "set-world-object-state", objectId: conservatoryDoor.id, state: "open", persistence: "run" },
{ id: "batch-three-complete-ironbark", kind: "set-world-object-state", objectId: ironbark.id, state: "complete", persistence: "run" },
{ id: "batch-three-record-alzzin-unlocked", kind: "set-flag", flagId: "alzzin-unlocked", value: true, persistence: "run" },
{ id: "batch-three-spawn-alzzin", kind: "activate-spawn-group", spawnGroupId: alzzinGroup.id },
{ id: "batch-three-return-dire-idle", kind: "set-phase", phase: "idle" },
],
},
{
id: "recover-alzzin",
trigger: { kind: "tick" },
guards: [
{ kind: "flag", flagId: "alzzin-unlocked", equals: true },
{ kind: "objective-complete", objectiveId: alzzinObjective.id, equals: false },
{ kind: "spawn-group-active", spawnGroupId: alzzinGroup.id, equals: false },
],
actions: [{ id: "batch-three-recover-alzzin", kind: "activate-spawn-group", spawnGroupId: alzzinGroup.id }],
},
{
id: "alzzin-breaks-wall",
trigger: { kind: "entity-health", entityId: alzzinObjective.id, atOrBelowPercent: 50 },
guards: [{ kind: "world-object-state", objectId: crumbleWall.id, states: ["closed"] }],
actions: [{ id: "batch-three-open-crumble-wall", kind: "set-world-object-state", objectId: crumbleWall.id, state: "open", persistence: "run" }],
},
{
id: "alzzin-defeated",
trigger: { kind: "entity-death", entityId: alzzinObjective.id },
actions: [
{ id: "batch-three-despawn-alzzin", kind: "deactivate-spawn-group", spawnGroupId: alzzinGroup.id },
{ id: "batch-three-complete-alzzin", kind: "complete-objective", objectiveId: alzzinObjective.id, persistence: "run" },
{ id: "batch-three-checkpoint-alzzin", kind: "set-checkpoint", checkpointId: "dire-maul-alzzin", persistence: "checkpoint" },
],
},
{
id: "open-courtyard-door",
trigger: { kind: "interact", objectId: courtyardDoor.id },
guards: [{ kind: "world-object-state", objectId: courtyardDoor.id, states: ["closed"] }],
actions: [{ id: "batch-three-open-courtyard-door", kind: "set-world-object-state", objectId: courtyardDoor.id, state: "open", persistence: "run" }],
},
{
id: "open-inner-door",
trigger: { kind: "interact", objectId: innerDoor.id },
guards: [{ kind: "world-object-state", objectId: innerDoor.id, states: ["closed"] }],
actions: [{ id: "batch-three-open-inner-door", kind: "set-world-object-state", objectId: innerDoor.id, state: "open", persistence: "run" }],
},
{
id: "prince-defeated",
trigger: { kind: "entity-death", entityId: princeSpawn.id },
actions: [
{ id: "batch-three-complete-prince", kind: "complete-objective", objectiveId: princeObjective.id, persistence: "run" },
{ id: "batch-three-checkpoint-prince", kind: "set-checkpoint", checkpointId: "dire-maul-prince", persistence: "checkpoint" },
],
},
{
id: "chorush-defeated",
trigger: { kind: "entity-death", entityId: choRushSpawn.id },
actions: [{ id: "batch-three-complete-chorush", kind: "complete-objective", objectiveId: choRushObjective.id, persistence: "run" }],
},
);
const tributeSpawns = [14_321, 14_323, 14_324, 14_325, 14_326].map((entry) => staticSpawnForEntry(definition, entry));
tributeSpawns.forEach((spawn, index) => rules.push({
id: `tribute-penalty-${index + 1}`,
trigger: { kind: "entity-death", entityId: spawn.id },
guards: [{ kind: "flag", flagId: "tribute-awarded", equals: false }],
actions: [{ id: `batch-three-record-tribute-penalty-${index + 1}`, kind: "increment-counter", counterId: "tribute-penalties", persistence: "run" }],
}));
for (let penalties = 0; penalties <= 5; penalties += 1) {
rules.push({
id: `award-tribute-${penalties}-penalties`,
trigger: { kind: "entity-death", entityId: kingSpawn.id },
guards: [
{ kind: "counter", counterId: "tribute-penalties", comparison: "equal", value: penalties },
{ kind: "flag", flagId: "tribute-awarded", equals: false },
],
actions: [
{ id: `batch-three-show-tribute-tier-${6 - penalties}`, kind: "set-world-object-state", objectId: tributeChest.id, state: `tier-${6 - penalties}`, persistence: "run" },
{ id: `batch-three-record-tribute-awarded-${penalties}`, kind: "set-flag", flagId: "tribute-awarded", value: true, persistence: "run" },
],
});
}
const scripted: DungeonEncounterDefinition = Object.freeze({
id: "dire-maul:classic-batch-three",
dungeonId: definition.id,
initialPhase: "idle",
initialState: Object.freeze({
flags: Object.freeze({ "immolthar-released": false, "alzzin-unlocked": false, "tribute-awarded": false }),
counters: Object.freeze({ "pylons-off": 0, "tribute-penalties": 0 }),
worldObjectStates: Object.freeze({
...Object.fromEntries(pylons.map((pylon) => [pylon.id, "active"])),
[forcefield.id]: "closed",
[courtyardDoor.id]: "closed",
[innerDoor.id]: "closed",
[conservatoryDoor.id]: "closed",
[crumbleWall.id]: "closed",
[ironbark.id]: "locked",
[tributeChest.id]: "hidden",
}),
}),
resetPolicy: Object.freeze({ bossWipePhase: "idle", checkpointPhase: "idle" }),
source: sourceReview(definition),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
bosses,
objectiveOrder,
staticSpawns: removeStaticSpawnIds(definition, new Set([immolSpawn.id])),
encounters: Object.freeze([
...patchFleetOptionalGuards(definition.encounters, definition.id, optionalTributeIds),
scripted,
]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), immolGroup, alzzinGroup]),
serverGameObjects: replaceObjects(
definition,
[...pylons, forcefield, courtyardDoor, innerDoor, conservatoryDoor, crumbleWall],
[ironbark, tributeChest],
),
validation: appendValidationWarnings(definition, [
"Dire Maul's pylon, east-wing, north-door, and tribute routes are source-backed adaptations; channelers, disguise/key logic, native dialogue/models, and exact tribute item tables remain unsupported.",
]),
});
}
const GYTH_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({
id: "corrosive-acid-16359",
name: "Corrosive Acid",
spellId: 16_359,
delivery: "area",
target: "nearby-party",
school: "nature",
animation: "cast",
range: 20,
radius: 20,
cooldownMs: 13_000,
initialCooldownMs: [8_000, 16_000] as const,
repeatCooldownMs: [10_000, 16_000] as const,
damageMultiplier: 0.65,
telegraph: { shape: "cone", anchor: "source", warningMs: 1_250, length: 20, angleDegrees: 90 } as const,
}),
Object.freeze({
id: "freeze-16350",
name: "Freeze",
spellId: 16_350,
delivery: "area",
target: "nearby-party",
school: "frost",
animation: "cast",
range: 20,
radius: 20,
cooldownMs: 13_000,
initialCooldownMs: [8_000, 16_000] as const,
repeatCooldownMs: [10_000, 16_000] as const,
damageMultiplier: 0,
effect: { kind: "control", mechanic: "stun", durationMs: 10_000, damageMultiplier: 0.8 } as const,
telegraph: { shape: "cone", anchor: "source", warningMs: 1_250, length: 20, angleDegrees: 90 } as const,
}),
Object.freeze({
id: "flame-breath-16390",
name: "Flame Breath",
spellId: 16_390,
delivery: "area",
target: "nearby-party",
school: "fire",
animation: "cast",
range: 20,
radius: 20,
cooldownMs: 13_000,
initialCooldownMs: [8_000, 16_000] as const,
repeatCooldownMs: [10_000, 16_000] as const,
damageMultiplier: 0.9,
telegraph: { shape: "cone", anchor: "source", warningMs: 1_250, length: 20, angleDegrees: 90 } as const,
}),
Object.freeze({
id: "knock-away-10101",
name: "Knock Away",
spellId: 10_101,
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
range: 5,
cooldownMs: 17_000,
initialCooldownMs: [12_000, 18_000] as const,
repeatCooldownMs: [14_000, 20_000] as const,
damageMultiplier: 0.9,
}),
]);
const UROK_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({
id: "rend-16509",
name: "Rend",
spellId: 16_509,
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
range: 5,
cooldownMs: 9_000,
initialCooldownMs: [17_000, 20_000] as const,
repeatCooldownMs: [8_000, 10_000] as const,
damageMultiplier: 0.65,
}),
Object.freeze({
id: "strike-15580",
name: "Strike",
spellId: 15_580,
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
range: 5,
cooldownMs: 9_000,
initialCooldownMs: [10_000, 12_000] as const,
repeatCooldownMs: [8_000, 10_000] as const,
damageMultiplier: 1.3,
}),
Object.freeze({
id: "intimidating-roar-16508",
name: "Intimidating Roar",
spellId: 16_508,
delivery: "area",
target: "nearby-party",
school: "physical",
animation: "cast",
range: 8,
radius: 8,
cooldownMs: 42_500,
initialCooldownMs: [25_000, 30_000] as const,
repeatCooldownMs: [40_000, 45_000] as const,
damageMultiplier: 0,
effect: { kind: "control", mechanic: "fear", durationMs: 8_000 } as const,
telegraph: { shape: "circle", anchor: "source", warningMs: 1_250, radius: 8 } as const,
}),
]);
function buildBlackrockSpire(definition: DungeonDefinition): DungeonDefinition {
const { entities, idsByEntry } = ensureAdaptedMobs(definition, [
{ entry: 10_442, name: "Chromatic Whelp", level: 57, role: "caster" },
{ entry: 10_447, name: "Chromatic Dragonspawn", level: 58, role: "melee" },
{ entry: 10_742, name: "Blackhand Dragon Handler", level: 59, role: "caster" },
{ entry: 10_339, name: "Gyth", level: 60, role: "melee", boss: true, attacks: GYTH_ATTACKS },
{ entry: 10_584, name: "Urok Doomhowl", level: 60, role: "melee", boss: true, attacks: UROK_ATTACKS },
]);
const emberSpawn = staticSpawnForEntry(definition, 9_816);
const emberObjective = objectiveForEntry(definition, 9_816);
const incarceratorSpawns = staticSpawnsForEntry(definition, 10_316);
if (incarceratorSpawns.length !== 7) {
throw new Error(`blackrock-spire expected seven Blackhand Incarcerators, found ${incarceratorSpawns.length}`);
}
const rendSpawn = staticSpawnForEntry(definition, 10_429);
const rendObjective = objectiveForEntry(definition, 10_429);
const drakkSpawn = staticSpawnForEntry(definition, 10_363);
const crystalFangSpawn = staticSpawnForEntry(definition, 10_376);
const spiderlingEntityId = findEntityIdByEntry(entities, 10_375);
if (!spiderlingEntityId) throw new Error("blackrock-spire is missing Spire Spiderling entry 10375");
const eventMarked = markEventObjectives(definition, [emberObjective, rendObjective]);
const hallRuneSpawnIds = [87_827, 87_829, 87_825, 87_830, 87_828, 87_826, 87_824] as const;
const hallRunes = hallRuneSpawnIds.map((spawnId) => reviewedObject(
sourceObjectBySpawnId(definition, spawnId),
{
kind: "switch",
initialState: "active",
states: [
{ id: "active", label: "Channeling", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "inactive", label: "Released", visible: true, blocksMovement: false, interactionEnabled: false },
],
color: "#c38d35",
emissive: "#5f3014",
note: "Exact source rune spawn is visible; its reviewed nearby imported Blackhand group controls the state without player interaction.",
},
));
const emberRunes = [87_831, 87_832, 87_833, 87_834, 87_835, 87_836, 87_837].map((spawnId) => reviewedObject(
sourceObjectBySpawnId(definition, spawnId),
{
kind: "ritual",
initialState: "encaged",
states: [
{ id: "encaged", label: "Binding Emberseer", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "growing", label: "Emberseer growing", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "released", label: "Released", visible: true, blocksMovement: false, interactionEnabled: false },
],
color: "#d14f32",
emissive: "#7d2115",
note: "Exact source Emberseer rune spawn is visible; encage, growth, and release are procedural states because aura visuals are unavailable.",
},
));
const emberIn = reviewedDoor(
sourceObjectBySpawnId(definition, 87_839),
"closed",
"Exact source Emberseer approach gate opens after seven Dragonspire runes and closes only during the adapted jailer event.",
);
const emberDoors = reviewedDoor(
sourceObjectBySpawnId(definition, 87_842),
"closed",
"Exact source second Emberseer gate opens after Dragonspire and closes during the jailer event.",
);
const emberOut = reviewedDoor(
sourceObjectBySpawnId(definition, 87_841),
"closed",
"Exact source Emberseer exit remains closed until Pyroguard Emberseer dies.",
);
const stadiumWaveDoor = reviewedDoor(
sourceObjectBySpawnId(definition, 87_843),
"closed",
"Exact source stadium wave portcullis opens for each active source group and closes for the dialogue intermission.",
);
const stadiumExit = reviewedDoor(
sourceObjectBySpawnId(definition, 87_844),
"closed",
"Exact source stadium passage opens after Gyth and Rend are defeated.",
);
const stadiumEntry = reviewedObject(sourceObjectBySpawnId(definition, 87_845), {
kind: "gate",
initialState: "ready",
states: [
{ id: "ready", label: "Stadium ready", visible: true, blocksMovement: false, blocksSight: false, interactionEnabled: true },
{ id: "active", label: "Stadium sealed", visible: true, blocksMovement: true, blocksSight: true, interactionEnabled: false },
{ id: "complete", label: "Stadium complete", visible: false, blocksMovement: false, blocksSight: false, interactionEnabled: false },
],
color: "#63513c",
emissive: "#21150d",
interaction: {
prompt: "Enter the Blackrock Stadium",
range: 3.4,
signalId: "blackrock-spire:batch-three:stadium",
allowedStates: ["ready"],
oneShot: false,
},
note: "Exact source entry portcullis is the playable interaction proxy for area trigger 2026 and physically seals the stadium during the event.",
});
const drakkDoors = [87_854, 87_855].map((spawnId) => reviewedDoor(
sourceObjectBySpawnId(definition, spawnId),
"closed",
"Exact source Drakkisath passage door remains collidable until General Drakkisath dies.",
));
const urokPile = reviewedSwitch(sourceObjectBySpawnId(definition, 99_791), {
initialState: "ready",
states: [
{ id: "ready", label: "Tribute pile ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "active", label: "Urok summoned", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "complete", label: "Urok defeated", visible: false, blocksMovement: false, interactionEnabled: false },
],
prompt: "Challenge Urok Doomhowl",
allowedStates: ["ready"],
signalId: "blackrock-spire:batch-three:urok",
color: "#8d5c36",
note: "Exact source pile is directly usable as a safe fallback for unsupported spell 16447, challenge object, and absent source wave scheduler.",
});
const emberTrigger = proceduralObject({
id: "blackrock-spire:batch-three:emberseer-trigger",
name: "Emberseer Binding Focus",
kind: "trigger",
position: emberSpawn.position,
initialState: "locked",
states: [
{ id: "locked", label: "Dragonspire sealed", visible: false, blocksMovement: false, interactionEnabled: false },
{ id: "ready", label: "Bindings ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "active", label: "Emberseer event active", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "complete", label: "Emberseer defeated", visible: false, blocksMovement: false, interactionEnabled: false },
],
prompt: "Disrupt Emberseer's bindings",
signalId: "blackrock-spire:batch-three:emberseer",
allowedStates: ["ready"],
color: "#d4542d",
files: [BRS_INSTANCE, BRS_EMBERSEER],
note: "Stationary visible trigger safely represents unsupported ProcessEvent 4884; it is not claimed to be a source gameobject.",
});
const urokObjective: DungeonBossObjective = Object.freeze({
id: "blackrock-spire:batch-three:urok-doomhowl",
name: "Urok Doomhowl",
position: urokPile.position,
optional: true,
eventOnly: true,
});
const objectives = {
bosses: Object.freeze([...eventMarked.bosses, urokObjective]),
objectiveOrder: Object.freeze([...eventMarked.objectiveOrder, urokObjective]),
};
const emberGroup = eventGroup("blackrock-spire:batch-three:emberseer", [emberSpawn]);
const incarceratorGroup = eventGroup("blackrock-spire:batch-three:incarcerators", incarceratorSpawns);
const sourceRendRows = [
[
[10_447, 202.511, -421.307, 110.988], [10_442, 204.015, -418.443, 110.989],
[10_442, 203.142, -423.999, 110.986], [10_442, 201.008, -416.648, 110.974],
],
[[10_447, 209.864, -428.273, 110.988], [10_442, 209.312, -430.872, 110.981], [10_442, 211.331, -425.911, 111.001]],
[[10_742, 208.649, -424.579, 110.987], [10_447, 203.948, -428.945, 110.982], [10_442, 203.344, -426.867, 110.977], [10_442, 206.308, -424.751, 110.994]],
[[10_742, 212.354, -412.683, 111.035], [10_447, 212.575, -410.284, 111.03], [10_442, 212.345, -414.866, 111.035], [10_442, 210.657, -412.155, 111.012]],
[[10_742, 210.219, -410.669, 111.021], [10_447, 209.408, -414.13, 111.026], [10_442, 208.086, -409.315, 111.012], [10_442, 207.981, -413.073, 111.01], [10_442, 208.085, -412.151, 111.006]],
[[10_742, 213.914, -426.512, 111.001], [10_447, 213.712, -429.81, 110.989], [10_447, 213.716, -424.427, 111.009], [10_442, 210.893, -423.913, 111.012], [10_442, 212.264, -430.765, 110.981]],
] as const;
const stadiumGroups = Object.freeze(sourceRendRows.map((rows, groupIndex) => eventGroup(
`blackrock-spire:batch-three:stadium-wave-${groupIndex + 1}`,
rows.map(([entry, x, y, z], spawnIndex) => eventSpawn(
`blackrock-spire:batch-three:stadium-wave-${groupIndex + 1}-${spawnIndex + 1}`,
idsByEntry.get(entry)!,
sourcePosition([x, y, z]),
)),
)));
const gythGroup = eventGroup("blackrock-spire:batch-three:gyth", [eventSpawn(
"blackrock-spire:batch-three:gyth",
idsByEntry.get(10_339)!,
sourcePosition([211.762, -397.5885, 111.1817]),
-4.747295,
)]);
const rendGroup = eventGroup("blackrock-spire:batch-three:rend", [Object.freeze({
...rendSpawn,
position: sourcePosition([216.485, -434.93, 110.888]),
yaw: 0.01225555,
})]);
const urokGroup = eventGroup("blackrock-spire:batch-three:urok", [eventSpawn(
urokObjective.id,
idsByEntry.get(10_584)!,
urokObjective.position,
urokPile.yaw ?? 0,
)]);
const crystalFangDeathGroup = eventGroup("blackrock-spire:batch-three:crystal-fang-spiderling", [eventSpawn(
"blackrock-spire:batch-three:crystal-fang-spiderling",
spiderlingEntityId,
crystalFangSpawn.position,
crystalFangSpawn.yaw ?? 0,
)]);
const runeMobIds = [
["creature-137869", "creature-137871", "creature-137928", "creature-137930"],
["creature-137872", "creature-137896", "creature-137898"],
["creature-137870", "creature-137886", "creature-137929"],
["creature-137873", "creature-137892", "creature-137897", "creature-137903", "creature-137904", "creature-137905", "creature-137931", "creature-137934"],
["creature-137878", "creature-137911", "creature-137912", "creature-137939"],
["creature-137880", "creature-137881", "creature-137942", "creature-137943"],
["creature-137877", "creature-137908", "creature-137909", "creature-137910", "creature-137938"],
] as const;
const staticSpawnIds = new Set(definition.staticSpawns.map((spawn) => spawn.id));
for (const id of runeMobIds.flat()) {
if (!staticSpawnIds.has(id)) throw new Error(`blackrock-spire missing reviewed Dragonspire spawn ${id}`);
}
const rules: DungeonEncounterRule[] = [];
hallRunes.forEach((rune, index) => rules.push({
id: `release-dragonspire-rune-${index + 1}`,
trigger: { kind: "entities-dead", entityIds: [...runeMobIds[index]] },
guards: [{ kind: "world-object-state", objectId: rune.id, states: ["active"] }],
actions: [
{ id: `batch-three-release-dragonspire-rune-${index + 1}`, kind: "set-world-object-state", objectId: rune.id, state: "inactive", persistence: "run" },
{ id: `batch-three-record-dragonspire-rune-${index + 1}`, kind: "increment-counter", counterId: "dragonspire-runes", persistence: "run" },
],
}));
rules.push(
{
id: "open-emberseer-approach",
trigger: { kind: "tick" },
guards: [
{ kind: "counter", counterId: "dragonspire-runes", comparison: "at-least", value: 7 },
{ kind: "flag", flagId: "dragonspire-cleared", equals: false },
],
actions: [
{ id: "batch-three-record-dragonspire-cleared", kind: "set-flag", flagId: "dragonspire-cleared", value: true, persistence: "run" },
{ id: "batch-three-open-emberseer-in", kind: "set-world-object-state", objectId: emberIn.id, state: "open", persistence: "run" },
{ id: "batch-three-open-emberseer-doors", kind: "set-world-object-state", objectId: emberDoors.id, state: "open", persistence: "run" },
{ id: "batch-three-ready-emberseer-trigger", kind: "set-world-object-state", objectId: emberTrigger.id, state: "ready", persistence: "run" },
],
},
{
id: "start-emberseer",
trigger: { kind: "interact", objectId: emberTrigger.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "world-object-state", objectId: emberTrigger.id, states: ["ready"] },
],
actions: [
{ id: "batch-three-activate-emberseer-trigger", kind: "set-world-object-state", objectId: emberTrigger.id, state: "active" },
{ id: "batch-three-close-emberseer-in", kind: "set-world-object-state", objectId: emberIn.id, state: "closed" },
{ id: "batch-three-close-emberseer-doors", kind: "set-world-object-state", objectId: emberDoors.id, state: "closed" },
{ id: "batch-three-enter-emberseer-prep", kind: "set-phase", phase: "emberseer-prep" },
],
},
{
id: "recover-emberseer-after-wipe",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "flag", flagId: "dragonspire-cleared", equals: true },
{ kind: "objective-complete", objectiveId: emberObjective.id, equals: false },
{ kind: "world-object-state", objectId: emberTrigger.id, states: ["active"] },
],
actions: [
{ id: "batch-three-recover-emberseer-trigger", kind: "set-world-object-state", objectId: emberTrigger.id, state: "ready" },
{ id: "batch-three-recover-emberseer-in", kind: "set-world-object-state", objectId: emberIn.id, state: "open" },
{ id: "batch-three-recover-emberseer-doors", kind: "set-world-object-state", objectId: emberDoors.id, state: "open" },
...emberRunes.map((rune, index) => ({
id: `batch-three-recover-emberseer-binding-${index + 1}`,
kind: "set-world-object-state" as const,
objectId: rune.id,
state: "encaged",
})),
],
},
{
id: "activate-incarcerators",
trigger: { kind: "elapsed", afterMs: 2_000 },
guards: [{ kind: "phase", phases: ["emberseer-prep"] }],
actions: [
{ id: "batch-three-enter-emberseer-jailers", kind: "set-phase", phase: "emberseer-jailers" },
{ id: "batch-three-spawn-incarcerators", kind: "activate-spawn-group", spawnGroupId: incarceratorGroup.id },
],
},
{
id: "incarcerators-defeated",
trigger: { kind: "entities-dead", entityIds: incarceratorSpawns.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: ["emberseer-jailers"] }],
actions: [
{ id: "batch-three-despawn-incarcerators", kind: "deactivate-spawn-group", spawnGroupId: incarceratorGroup.id },
{ id: "batch-three-enter-emberseer-growth", kind: "set-phase", phase: "emberseer-growth" },
...emberRunes.map((rune, index) => ({
id: `batch-three-grow-emberseer-rune-${index + 1}`,
kind: "set-world-object-state" as const,
objectId: rune.id,
state: "growing",
})),
],
},
{
id: "release-emberseer",
trigger: { kind: "elapsed", afterMs: 25_000 },
guards: [{ kind: "phase", phases: ["emberseer-growth"] }],
actions: [
{ id: "batch-three-enter-emberseer-combat", kind: "set-phase", phase: "emberseer-combat" },
{ id: "batch-three-spawn-emberseer", kind: "activate-spawn-group", spawnGroupId: emberGroup.id },
],
},
{
id: "emberseer-defeated",
trigger: { kind: "entity-death", entityId: emberSpawn.id },
guards: [{ kind: "phase", phases: ["emberseer-combat"] }],
actions: [
{ id: "batch-three-despawn-emberseer", kind: "deactivate-spawn-group", spawnGroupId: emberGroup.id },
{ id: "batch-three-complete-emberseer", kind: "complete-objective", objectiveId: emberObjective.id, persistence: "run" },
{ id: "batch-three-complete-emberseer-trigger", kind: "set-world-object-state", objectId: emberTrigger.id, state: "complete", persistence: "run" },
{ id: "batch-three-reopen-emberseer-in", kind: "set-world-object-state", objectId: emberIn.id, state: "open", persistence: "run" },
{ id: "batch-three-reopen-emberseer-doors", kind: "set-world-object-state", objectId: emberDoors.id, state: "open", persistence: "run" },
{ id: "batch-three-open-emberseer-out", kind: "set-world-object-state", objectId: emberOut.id, state: "open", persistence: "run" },
...emberRunes.map((rune, index) => ({
id: `batch-three-release-emberseer-binding-${index + 1}`,
kind: "set-world-object-state" as const,
objectId: rune.id,
state: "released",
persistence: "run" as const,
})),
{ id: "batch-three-checkpoint-emberseer", kind: "set-checkpoint", checkpointId: "blackrock-spire-emberseer", persistence: "checkpoint" },
{ id: "batch-three-return-idle-after-emberseer", kind: "set-phase", phase: "idle" },
],
},
{
id: "start-stadium",
trigger: { kind: "interact", objectId: stadiumEntry.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "world-object-state", objectId: stadiumEntry.id, states: ["ready"] },
],
actions: [
{ id: "batch-three-seal-stadium-entry", kind: "set-world-object-state", objectId: stadiumEntry.id, state: "active" },
{ id: "batch-three-enter-stadium-intro", kind: "set-phase", phase: "stadium-intro" },
],
},
{
id: "spawn-stadium-wave-1",
trigger: { kind: "elapsed", afterMs: 11_000 },
guards: [{ kind: "phase", phases: ["stadium-intro"] }],
actions: [
{ id: "batch-three-enter-stadium-wave-1", kind: "set-phase", phase: "stadium-wave-1" },
{ id: "batch-three-open-stadium-wave-door-1", kind: "set-world-object-state", objectId: stadiumWaveDoor.id, state: "open" },
{ id: "batch-three-spawn-stadium-wave-1", kind: "activate-spawn-group", spawnGroupId: stadiumGroups[0].id },
],
},
);
stadiumGroups.forEach((group, index) => {
const number = index + 1;
rules.push({
id: `finish-stadium-wave-${number}`,
trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: [`stadium-wave-${number}`] }],
actions: [
{ id: `batch-three-despawn-stadium-wave-${number}`, kind: "deactivate-spawn-group", spawnGroupId: group.id },
{ id: `batch-three-close-stadium-wave-door-${number}`, kind: "set-world-object-state", objectId: stadiumWaveDoor.id, state: "closed" },
{ id: `batch-three-enter-stadium-delay-${number}`, kind: "set-phase", phase: `stadium-delay-${number}` },
],
});
if (index < stadiumGroups.length - 1) {
rules.push({
id: `advance-stadium-wave-${number + 1}`,
trigger: { kind: "elapsed", afterMs: 13_000 },
guards: [{ kind: "phase", phases: [`stadium-delay-${number}`] }],
actions: [
{ id: `batch-three-enter-stadium-wave-${number + 1}`, kind: "set-phase", phase: `stadium-wave-${number + 1}` },
{ id: `batch-three-open-stadium-wave-door-${number + 1}`, kind: "set-world-object-state", objectId: stadiumWaveDoor.id, state: "open" },
{ id: `batch-three-spawn-stadium-wave-${number + 1}`, kind: "activate-spawn-group", spawnGroupId: stadiumGroups[index + 1].id },
],
});
}
});
rules.push(
{
id: "spawn-gyth",
trigger: { kind: "elapsed", afterMs: 18_000 },
guards: [{ kind: "phase", phases: ["stadium-delay-6"] }],
actions: [
{ id: "batch-three-enter-gyth-combat", kind: "set-phase", phase: "stadium-gyth" },
{ id: "batch-three-open-stadium-wave-door-gyth", kind: "set-world-object-state", objectId: stadiumWaveDoor.id, state: "open" },
{ id: "batch-three-spawn-gyth", kind: "activate-spawn-group", spawnGroupId: gythGroup.id },
],
},
{
id: "gyth-releases-rend",
trigger: { kind: "entity-health", entityId: gythGroup.spawns[0].id, atOrBelowPercent: 25 },
guards: [{ kind: "flag", flagId: "rend-released", equals: false }],
actions: [
{ id: "batch-three-record-rend-released-at-quarter", kind: "set-flag", flagId: "rend-released", value: true },
{ id: "batch-three-spawn-rend-at-quarter", kind: "activate-spawn-group", spawnGroupId: rendGroup.id },
],
},
{
id: "record-gyth-death-and-fallback-rend",
trigger: { kind: "entity-death", entityId: gythGroup.spawns[0].id },
actions: [
{ id: "batch-three-record-gyth-dead", kind: "set-flag", flagId: "gyth-dead", value: true },
{ id: "batch-three-record-rend-released-on-gyth-death", kind: "set-flag", flagId: "rend-released", value: true },
{ id: "batch-three-spawn-rend-on-gyth-death", kind: "activate-spawn-group", spawnGroupId: rendGroup.id },
],
},
{
id: "record-rend-death",
trigger: { kind: "entity-death", entityId: rendSpawn.id },
actions: [{ id: "batch-three-record-rend-dead", kind: "set-flag", flagId: "rend-dead", value: true }],
},
{
id: "complete-stadium",
trigger: { kind: "tick" },
guards: [
{ kind: "flag", flagId: "gyth-dead", equals: true },
{ kind: "flag", flagId: "rend-dead", equals: true },
],
actions: [
{ id: "batch-three-despawn-gyth", kind: "deactivate-spawn-group", spawnGroupId: gythGroup.id },
{ id: "batch-three-despawn-rend", kind: "deactivate-spawn-group", spawnGroupId: rendGroup.id },
{ id: "batch-three-complete-rend", kind: "complete-objective", objectiveId: rendObjective.id, persistence: "run" },
{ id: "batch-three-complete-stadium-entry", kind: "set-world-object-state", objectId: stadiumEntry.id, state: "complete", persistence: "run" },
{ id: "batch-three-open-stadium-wave-door-final", kind: "set-world-object-state", objectId: stadiumWaveDoor.id, state: "open", persistence: "run" },
{ id: "batch-three-open-stadium-exit", kind: "set-world-object-state", objectId: stadiumExit.id, state: "open", persistence: "run" },
{ id: "batch-three-checkpoint-stadium", kind: "set-checkpoint", checkpointId: "blackrock-spire-stadium", persistence: "checkpoint" },
{ id: "batch-three-return-idle-after-stadium", kind: "set-phase", phase: "idle" },
],
},
{
id: "drakkisath-opens-passages",
trigger: { kind: "entity-death", entityId: drakkSpawn.id },
actions: [
{ id: "batch-three-open-drakkisath-door-1", kind: "set-world-object-state", objectId: drakkDoors[0].id, state: "open", persistence: "run" },
{ id: "batch-three-open-drakkisath-door-2", kind: "set-world-object-state", objectId: drakkDoors[1].id, state: "open", persistence: "run" },
],
},
{
id: "crystal-fang-summons-spiderling",
trigger: { kind: "entity-death", entityId: crystalFangSpawn.id },
actions: [{
id: "batch-three-spawn-crystal-fang-spiderling",
kind: "activate-spawn-group",
spawnGroupId: crystalFangDeathGroup.id,
}],
},
{
id: "start-urok",
trigger: { kind: "interact", objectId: urokPile.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "world-object-state", objectId: urokPile.id, states: ["ready"] },
],
actions: [
{ id: "batch-three-activate-urok-pile", kind: "set-world-object-state", objectId: urokPile.id, state: "active" },
{ id: "batch-three-enter-urok-combat", kind: "set-phase", phase: "urok-combat" },
{ id: "batch-three-spawn-urok", kind: "activate-spawn-group", spawnGroupId: urokGroup.id },
],
},
{
id: "urok-defeated",
trigger: { kind: "entity-death", entityId: urokObjective.id },
guards: [{ kind: "phase", phases: ["urok-combat"] }],
actions: [
{ id: "batch-three-despawn-urok", kind: "deactivate-spawn-group", spawnGroupId: urokGroup.id },
{ id: "batch-three-complete-urok", kind: "complete-objective", objectiveId: urokObjective.id, persistence: "run" },
{ id: "batch-three-complete-urok-pile", kind: "set-world-object-state", objectId: urokPile.id, state: "complete", persistence: "run" },
{ id: "batch-three-checkpoint-urok", kind: "set-checkpoint", checkpointId: "blackrock-spire-urok", persistence: "checkpoint" },
{ id: "batch-three-return-idle-after-urok", kind: "set-phase", phase: "idle" },
],
},
);
const scripted: DungeonEncounterDefinition = Object.freeze({
id: "blackrock-spire:classic-batch-three",
dungeonId: definition.id,
initialPhase: "idle",
initialState: Object.freeze({
flags: Object.freeze({
"dragonspire-cleared": false,
"rend-released": false,
"gyth-dead": false,
"rend-dead": false,
}),
counters: Object.freeze({ "dragonspire-runes": 0 }),
worldObjectStates: Object.freeze({
...Object.fromEntries(hallRunes.map((rune) => [rune.id, "active"])),
...Object.fromEntries(emberRunes.map((rune) => [rune.id, "encaged"])),
[emberIn.id]: "closed",
[emberDoors.id]: "closed",
[emberOut.id]: "closed",
[emberTrigger.id]: "locked",
[stadiumWaveDoor.id]: "closed",
[stadiumEntry.id]: "ready",
[stadiumExit.id]: "closed",
...Object.fromEntries(drakkDoors.map((door) => [door.id, "closed"])),
[urokPile.id]: "ready",
}),
}),
resetPolicy: Object.freeze({ bossWipePhase: "idle", checkpointPhase: "idle" }),
source: sourceReview(definition),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
...objectives,
staticSpawns: removeStaticSpawnIds(definition, new Set([
emberSpawn.id,
rendSpawn.id,
...incarceratorSpawns.map((spawn) => spawn.id),
])),
encounters: Object.freeze([...(definition.encounters ?? []), scripted]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []),
emberGroup,
incarceratorGroup,
...stadiumGroups,
gythGroup,
rendGroup,
urokGroup,
crystalFangDeathGroup,
]),
serverGameObjects: replaceObjects(
definition,
[
...hallRunes,
...emberRunes,
emberIn,
emberDoors,
emberOut,
stadiumWaveDoor,
stadiumExit,
stadiumEntry,
...drakkDoors,
urokPile,
],
[emberTrigger],
),
validation: appendValidationWarnings(definition, [
"Blackrock Spire's Dragonspire, Emberseer, stadium, Drakkisath, and Urok routes are source-backed adaptations; native aura/dialogue/movement/item/wave behavior and full traversal evidence remain unsupported.",
"The pre-existing Father Flame/Solakar encounter remains authoritative and is preserved without duplicate ids or objects.",
]),
});
}
export function withWowClassicEncounterBatchThree(definition: DungeonDefinition): DungeonDefinition {
if (definition.encounters?.some((encounter) => encounter.id === `${definition.id}:classic-batch-three`)) {
return definition;
}
if (definition.id === "gnomeregan") return buildGnomeregan(definition);
if (definition.id === "dire-maul") return buildDireMaul(definition);
if (definition.id === "blackrock-spire") return buildBlackrockSpire(definition);
return definition;
}