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

2016 lines
90 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";
export interface WowClassicBatchTwoSourceFile {
readonly path: string;
readonly sha256: string;
readonly anchors: readonly string[];
}
export interface WowClassicEncounterBatchTwoReview {
readonly dungeonId: string;
readonly fidelity: "source-backed-adapted";
readonly sourceFiles: readonly WowClassicBatchTwoSourceFile[];
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";
}
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_SMART_SCRIPTS_SHA256 = "622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2";
const WORLD_GAMEOBJECTS_SHA256 = "3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42";
const WORLD_CREATURES_SHA256 = "f54ddfe7ee00080b4b0bc970a809d6f947f6fb2627dbcd67e92b402fb34cb701";
const SUNKEN_FILES = Object.freeze([
`${SOURCE_ROOT}/EasternKingdoms/SunkenTemple/instance_sunken_temple.cpp`,
`${SOURCE_ROOT}/EasternKingdoms/SunkenTemple/sunken_temple.h`,
WORLD_SMART_SCRIPTS,
WORLD_GAMEOBJECTS,
]);
const BLACKROCK_FILES = Object.freeze([
`${SOURCE_ROOT}/EasternKingdoms/BlackrockMountain/BlackrockDepths/instance_blackrock_depths.cpp`,
`${SOURCE_ROOT}/EasternKingdoms/BlackrockMountain/BlackrockDepths/blackrock_depths.cpp`,
`${SOURCE_ROOT}/EasternKingdoms/BlackrockMountain/BlackrockDepths/boss_tomb_of_seven.cpp`,
`${SOURCE_ROOT}/EasternKingdoms/BlackrockMountain/BlackrockDepths/blackrock_depths.h`,
WORLD_SMART_SCRIPTS,
WORLD_GAMEOBJECTS,
WORLD_CREATURES,
]);
const SCHOLOMANCE_FILES = Object.freeze([
`${SOURCE_ROOT}/EasternKingdoms/Scholomance/instance_scholomance.cpp`,
`${SOURCE_ROOT}/EasternKingdoms/Scholomance/boss_kirtonos_the_herald.cpp`,
`${SOURCE_ROOT}/EasternKingdoms/Scholomance/scholomance.h`,
WORLD_SMART_SCRIPTS,
WORLD_GAMEOBJECTS,
]);
const STRATHOLME_FILES = Object.freeze([
`${SOURCE_ROOT}/EasternKingdoms/Stratholme/instance_stratholme.cpp`,
`${SOURCE_ROOT}/EasternKingdoms/Stratholme/boss_baroness_anastari.cpp`,
`${SOURCE_ROOT}/EasternKingdoms/Stratholme/stratholme.h`,
WORLD_SMART_SCRIPTS,
WORLD_GAMEOBJECTS,
WORLD_CREATURES,
]);
export const WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS: readonly WowClassicEncounterBatchTwoReview[] = Object.freeze([
{
dungeonId: "sunken-temple",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: SUNKEN_FILES[0],
sha256: "f642b104da95317a684012ddc5b77969f38147a5ec327e506afa57e2ea4d95da",
anchors: [
"six-statue restore and dynamic idol creation (lines 77-105)",
"six-defender forcefield and Jammal'an release gates (lines 109-138)",
"saved statue/defender state (lines 161-190)",
"Awaken the Soulflayer summon gate (lines 256-279)",
],
},
{
path: SUNKEN_FILES[1],
sha256: "21f315d929ab5a4f2b693cf32ccae945f88d248ea9dadf5d339d9fbc1c370e97",
anchors: [
"six-statue and six-defender constants (lines 23-34)",
"statue, idol, light, and forcefield entries (lines 37-48)",
"Jammal'an, Eranikus, and Shade of Hakkar entries (lines 51-57)",
],
},
{
path: SUNKEN_FILES[2],
sha256: WORLD_SMART_SCRIPTS_SHA256,
anchors: [
"Shade of Hakkar four-counter and bounded summon behavior (lines 11675-11697)",
"six defender deaths update instance data 11 (lines 9782-9803)",
"six statue interactions update data, lights, and spells (lines 38436-38460)",
],
},
{
path: SUNKEN_FILES[3],
sha256: WORLD_GAMEOBJECTS_SHA256,
anchors: [
"Altar of Hakkar spawn 11922 (line 5649)",
"statue spawns 27898/27899 and 28111-28114 (lines 18107-18319)",
"four Eternal Flame spawns 47561-47564 (lines 33967-33970)",
"forcefield spawn 268932 (line 87733)",
],
},
],
reviewedBehavior: "Six Atal'ai statues advance a saved phase and reveal Atal'alarion; six named defenders open Jammal'an's forcefield, Jammal'an releases Eranikus, and the separate Shade of Hakkar ritual consumes four Eternal Flames before summoning the Avatar.",
adaptations: [
"The statues enforce ascending template order 148830-148835, matching the instance restore invariant; the pinned SmartGameObjectAI itself increments on any unused statue and does not reject an out-of-order click.",
"The unsupported Egg/Essence item spell that starts Awaken the Soulflayer is mapped to the reviewed Altar of Hakkar. Four finite suppressor/scalebane packs stand in for the source's repeating timed summons and blood-item loop.",
"Atal'alarion, the Avatar, and Eranikus are removed from unsafe static combat and mounted only at their reviewed source gates.",
],
remainingGaps: [
"Statue poison/dark-energy/flame spells, summoned light models, idol loot interaction, native object animation/audio, and the canonical plaque presentation remain unported.",
"The Hakkar blood inventory loop, 70/110-second repeating summon schedule, Shade dialogue/counters, and native Avatar summon visual are represented by a deterministic four-pack ritual.",
"Jammal'an/Ogom coordination, Eranikus's zone-wide dragonkin pull, non-selectable flags, quests, loot presentation, and live traversal evidence remain unsupported.",
],
},
{
dungeonId: "blackrock-depths",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: BLACKROCK_FILES[0],
sha256: "c3a50081c28a110cf9d2920213e5b7b3854ef2e5e4fffd8923ec488aac1e3970",
anchors: [
"random arena outcome and tomb initialization (lines 207-222)",
"arena, tomb, vault, and door state handling (lines 310-511)",
"saved encounter reset rules (lines 675-709)",
"seven ghosts at one then thirty-second intervals and fifteen-second reset (lines 712-801)",
],
},
{
path: BLACKROCK_FILES[1],
sha256: "95f7ddab4195cf1ad5760889add96fc8be627093f2fe36976947def362d0e377",
anchors: [
"six exact arena pack outcomes and six bosses (lines 119-135)",
"Ring of Law trigger and two-minute retry block (lines 138-157)",
"two trash waves, boss summon, doors, and completion (lines 260-389)",
],
},
{
path: BLACKROCK_FILES[2],
sha256: "e2efdbf5329dbc835878047fda09d1c7ca5ebb224096c79982c78cf27b2b4d93",
anchors: [
"Doom'rel gossip starts the Tomb of Seven (lines 82-103)",
"friendly/immune reset and completed recovery (lines 106-118)",
],
},
{
path: BLACKROCK_FILES[3],
sha256: "b2353af4b1bd185cb7bb1266f1cd8b93e7b688df530fb8e912bece25f5b1dc7b",
anchors: [
"arena, vault, tomb, and coffer data ids (lines 38-85)",
"seven spectral dwarf and six arena boss entries (lines 88-133)",
],
},
{
path: BLACKROCK_FILES[4],
sha256: WORLD_SMART_SCRIPTS_SHA256,
anchors: [
"Watchman Doomgrip reward-door behavior (lines 12379-12385)",
"twelve coffer doors report data 30 after key spell 13478 (lines 38598-38622)",
],
},
{
path: BLACKROCK_FILES[5],
sha256: WORLD_GAMEOBJECTS_SHA256,
anchors: [
"four arena gates by spawn 15295/15309/15332/18280 (lines 7816-10161)",
"twelve coffer doors by source spawn id (lines 7827-28111)",
"Tomb doors 15334/15551 and reviewed vault rewards 43094/43137 (lines 7855-30611)",
],
},
{
path: BLACKROCK_FILES[6],
sha256: WORLD_CREATURES_SHA256,
anchors: [
"seven spectral dwarf source spawns 91020-91026 (lines 87636-87642)",
],
},
],
reviewedBehavior: "Ring of Law selects one saved pack and boss, runs two trash waves, then opens the arena exits; twelve keyed coffer doors summon Watchman Doomgrip; Doom'rel starts seven spectral dwarves one second then thirty seconds apart and opens the tomb after all seven die.",
adaptations: [
"Ring of Law uses the first valid pinned random outcome: two three-Dredge-Worm waves followed by Gorosh. This is source-valid but deterministic for serialization and tests.",
"The source area trigger is represented by interaction with exact source Arena Gate 4. Wipe retry is immediate instead of enforcing the source's two-minute resurrection grace period.",
"The two arena trash packs are death-gated with short deterministic transitions instead of retaining Grimstone's movement-dependent overlapping fifteen-second escort cadence.",
"Coffer key spell 13478 is unavailable, so each of the exact twelve reviewed source coffer doors is directly usable once; Doomgrip still appears only at count twelve.",
"Doom'rel is a procedural source-NPC interaction marker. The exact seven order and 1s/30s cadence are retained; a wipe immediately resets all seven instead of waiting fifteen seconds.",
],
remainingGaps: [
"Grimstone escort movement/dialogue, cheering/faction changes, alternate random packs/bosses, Banner of Provocation/Theldren, arena spoils, and the two-minute retry delay remain unported.",
"Relic Coffer Key inventory consumption, all twelve chest loot links, Doomgrip construct awakening, secret-safe loot, and native coffer animation/audio remain unsupported.",
"Spectral dwarf dialogue, immunity/faction transitions, concurrent slow-kill edge behavior, chest loot, and native tomb door/chalice presentation remain adapted.",
],
},
{
dungeonId: "scholomance",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: SCHOLOMANCE_FILES[0],
sha256: "d1bf3364f122e3dcf5dd4bc00180d53b99e043f25c2d2718cdc63ee1745678f6",
anchors: [
"Kirtonos and seven Gandling gate capture (lines 50-94)",
"Kirtonos summon, wipe reset, and door lifecycle (lines 125-173)",
"six-mini-boss Gandling reveal and entrance gate lifecycle (lines 174-202)",
"saved Kirtonos and mini-boss counts (lines 219-227)",
],
},
{
path: SCHOLOMANCE_FILES[1],
sha256: "67e7b6237c1c4f7e88b76cf71198bec8afb2aeb29d488eab76c1d71ffa553f66",
anchors: [
"exact Kirtonos spawn and landing positions (lines 65-69)",
"death/wipe instance states (lines 98-107)",
"flight, landing, transform, and attackable intro (lines 109-170)",
],
},
{
path: SCHOLOMANCE_FILES[2],
sha256: "81dcce4f56f3dadfbb43b0aeddc617b9cf77067d7c44bfc9b2646aedbed25517",
anchors: [
"Kirtonos, mini-boss, and Gandling data ids (lines 25-30)",
"Kirtonos/Gandling entries and eight reviewed gates/brazier (lines 43-64)",
],
},
{
path: SCHOLOMANCE_FILES[3],
sha256: WORLD_SMART_SCRIPTS_SHA256,
anchors: [
"Gandling hidden reset, five-second reveal, and entrance gate behavior (lines 5996-6007)",
"six named mini-boss deaths increment instance data 1 (lines 13057-13694)",
"Brazier of the Herald activation starts instance data 0 (line 38692)",
],
},
{
path: SCHOLOMANCE_FILES[4],
sha256: WORLD_GAMEOBJECTS_SHA256,
anchors: [
"Kirtonos gate spawn 18892 (line 10540)",
"Gandling gate source spawns 35798/43184/43187/43188/43196/43201/43203 (lines 24013-30658)",
"Brazier of the Herald spawn 43208 (line 30663)",
],
},
],
reviewedBehavior: "The Herald brazier summons Kirtonos and closes his gate until death or failure. Six exact school mini-boss deaths reveal Darkmaster Gandling after five seconds; his entrance closes in combat and reopens on reset or death.",
adaptations: [
"The Blood of Innocents/item condition is not available, so the exact reviewed Herald brazier directly starts a finite Kirtonos fight after a short landing delay.",
"The six source mini-bosses remain their imported static actors. Gandling is removed from unsafe static combat, revealed from his exact spawn after all six are dead, and becomes a required event-only final objective.",
"Kirtonos flight, transform, and landing are represented by a five-second staged summon; native motion and model swapping are not serialized.",
],
remainingGaps: [
"Brazier item consumption, Kirtonos flight spline 105061, transformation visuals/dialogue, Dominate Mind nuances, and native gate/brazier presentation remain unsupported.",
"Gandling's player teleport rooms, six room-gate combat loops, risen guardians, dialogue, and native reveal visual remain unported.",
"Ras human transformation, quests, loot presentation, and live traversal/collision evidence remain outstanding.",
],
},
{
dungeonId: "stratholme",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: STRATHOLME_FILES[0],
sha256: "c441245d86252a51c8eedbe318d6b8dac22fa77ded5162927640c41a7116d28f",
anchors: [
"slaughter population accounting and 13-to-Ramstein transition (lines 94-177)",
"five ziggurat doors and two progression gates restore state (lines 179-256)",
"three two-step ziggurats open gauntlet/slaughter gates (lines 258-339)",
"saved partial slaughter resume (lines 370-403)",
"33 mindless and five Black Guard event scheduling (lines 548-584)",
],
},
{
path: STRATHOLME_FILES[1],
sha256: "d4a6f428a6e966c2cc784ac8acbd7bd388c11d936290ccdb582e62dd72e37830",
anchors: [
"Baroness binds ziggurat data slot 1 (line 43)",
"Baroness death advances ziggurat one (lines 81-84)",
],
},
{
path: STRATHOLME_FILES[2],
sha256: "3e2fde5ecbf18a3b55c07134ff5e3346f72acc8e9ed568e8369b7c7c19206657",
anchors: [
"three ziggurat and Baron data slots (lines 24-37)",
"13-event creature entries (lines 40-57)",
"five ziggurat and three slaughter gate entries (lines 59-79)",
],
},
{
path: STRATHOLME_FILES[3],
sha256: WORLD_SMART_SCRIPTS_SHA256,
anchors: [
"three spawn-specific Ash'ari crystal/acolyte state-two updates (lines 3848-3853)",
"Nerub'enkan and Maleki ziggurat death updates (lines 12942-12947)",
],
},
{
path: STRATHOLME_FILES[4],
sha256: WORLD_GAMEOBJECTS_SHA256,
anchors: [
"slaughter and gauntlet gate spawns 6908/6911 (lines 3170-3172)",
"ziggurat door spawns 11414-11416 and 11808 (lines 5344-5556)",
"Baron door spawn 35848 and side gate 49594 (lines 24060-35375)",
],
},
{
path: STRATHOLME_FILES[5],
sha256: WORLD_CREATURES_SHA256,
anchors: [
"three spawn-specific Thuzadin Acolytes 53955/53963/53968 (lines 53105-53117)",
],
},
],
reviewedBehavior: "Each ziggurat boss opens its door and a bound acolyte/crystal advances that ziggurat to state two. All three open the gauntlet and slaughter gates; thirteen bile/venom actors lead to Ramstein, 33 mindless undead, five Black Guards, and finally the Baron door.",
adaptations: [
"The three omitted source acolyte/crystal actors are finite event spawns at their exact source rows after their matching boss dies; killing each represents the source data-two crystal shutdown.",
"All 33 Mindless Undead activate as one safely spread bounded group after five seconds instead of 210ms-apart summons at one point. Five Black Guards spawn at their exact source destination formation because scripted walking is unavailable.",
"Baron Rivendare is removed from unsafe static combat and respawned behind the reviewed gate after the guards die. Partial slaughter stages persist and recover after wipes like the source save/load path.",
],
remainingGaps: [
"The 45-minute Baron ultimatum, Ysida prisoner outcome, aura/timed dialogue, postboxes, Barthilas run, two plague-critter gate traps, and optional Jarien/Sothos event remain unported.",
"Crystal channels/death visuals, exact 210ms spawn cadence, Black Guard walking/dialogue, native door animation/audio, quests, and loot presentation remain adapted.",
"Baron combat scripting and dungeon-route completion semantics remain fleet-owned; this optional event objective does not force both Scarlet and undead wings for completion.",
],
},
]);
const REVIEW_BY_DUNGEON = new Map(
WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => [review.dungeonId, review] as const),
);
function adaptedEntityId(entry: number): string {
return `wow-classic-batch-two-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 entity: PopulationEntityDefinition = {
id: adaptedEntityId(spec.entry),
kind: "mob",
name: spec.name,
hasLoot: false,
combat: {
serverEntry: spec.entry,
level: spec.level,
healthMultiplier: caster ? 0.92 : 1.06,
damageMultiplier: 0.92,
moveSpeed: 3.5,
aggroRange: 32,
leashRange: 90,
attacks: [{
id: "basic-attack",
name: caster ? "Source Spell Proxy" : "Basic Attack",
delivery: caster ? "projectile" : "melee",
target: "primary",
school: caster ? "shadow" : "physical",
animation: caster ? "cast" : "attack",
range: caster ? 25 : 4.5,
cooldownMs: caster ? 3_000 : 2_000,
damageMultiplier: caster ? 0.8 : 1,
}],
},
visual: {
primaryColor: caster ? "#5a4a78" : "#705044",
accentColor: caster ? "#c3a2eb" : "#d6a17e",
scale: 1,
archetype: "humanoid",
},
};
return Object.freeze(entity);
}
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-two 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 = "The exact source spawn transform and client bounds are retained; procedural geometry supplies physical collision until the native legacy door model and animation are exported.",
): 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 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 scriptedNpcObject(
options: {
readonly id: string;
readonly name: string;
readonly position: Vector3Tuple;
readonly prompt: string;
readonly signalId: string;
readonly sourceFiles: readonly string[];
readonly note: string;
readonly initialState?: string;
readonly states?: readonly DungeonWorldObjectStateDefinition[];
readonly allowedStates?: readonly string[];
},
): DungeonWorldObjectDefinition {
const initialState = options.initialState ?? "ready";
const states = options.states ?? [
{ id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "active", label: "Event in progress", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "complete", label: "Complete", visible: true, blocksMovement: false, interactionEnabled: false },
];
return Object.freeze({
id: options.id,
name: options.name,
kind: "npc",
position: options.position,
halfExtents: [0.9, 1.8, 0.9] as Vector3Tuple,
initialState,
states: Object.freeze(states),
interaction: Object.freeze({
prompt: options.prompt,
range: 3.4,
signalId: options.signalId,
allowedStates: options.allowedStates ?? ["ready"],
oneShot: false,
}),
visual: Object.freeze({
color: "#9b835f",
emissive: "#2d2114",
scale: [0.9, 1.8, 0.9] as Vector3Tuple,
}),
source: Object.freeze({
fidelity: "source-backed-adapted",
files: options.sourceFiles,
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-two 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 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((boss) => byId.get(boss.id) ?? boss)),
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => byId.get(boss.id) ?? boss)),
};
}
function addEventObjectives(
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 withoutFleetCompletionRule(
encounters: readonly DungeonEncounterDefinition[] | undefined,
encounterId: string,
): readonly DungeonEncounterDefinition[] {
return Object.freeze((encounters ?? []).map((encounter) => {
if (encounter.id !== encounterId) return encounter;
return Object.freeze({
...encounter,
rules: Object.freeze(encounter.rules.filter((rule) => rule.id !== "all-required-bosses-defeated")),
});
}));
}
function sourcePosition(source: readonly [x: number, y: number, z: number]): Vector3Tuple {
return [-source[0], source[2], source[1]];
}
function stratholmeSourcePosition(source: readonly [x: number, y: number, z: number]): Vector3Tuple {
return [-source[0] + 3_449.6608, source[2] - 144.3848, source[1] + 3_379.6966];
}
function buildSunkenTemple(definition: DungeonDefinition): DungeonDefinition {
const { entities, idsByEntry } = ensureAdaptedMobs(definition, [
{ entry: 8_497, name: "Nightmare Suppressor", level: 50, role: "caster" },
{ entry: 8_438, name: "Nightmare Scalebane", level: 50, role: "melee" },
]);
const atalAlarionSpawn = staticSpawnForEntry(definition, 8_580);
const avatarSpawn = staticSpawnForEntry(definition, 8_443);
const eranikusSpawn = staticSpawnForEntry(definition, 5_709);
const jammalanSpawn = staticSpawnForEntry(definition, 5_710);
const atalAlarionObjective = objectiveForEntry(definition, 8_580);
const avatarObjective = objectiveForEntry(definition, 8_443);
const eranikusObjective = objectiveForEntry(definition, 5_709);
const eventObjectives = markEventObjectives(definition, [
atalAlarionObjective,
avatarObjective,
eranikusObjective,
]);
const statues = [27_898, 27_899, 28_111, 28_112, 28_113, 28_114].map((spawnId, index) => {
const source = sourceObjectBySpawnId(definition, spawnId);
return reviewedSwitch(source, {
initialState: "ready",
states: [
{ id: "ready", label: "Dormant", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "used", label: "Activated", visible: true, blocksMovement: false, interactionEnabled: false },
],
prompt: `Activate Atal'ai Statue ${index + 1}`,
allowedStates: ["ready"],
signalId: `sunken-temple:statue-${index + 1}`,
color: "#6c8454",
note: "The exact source statue spawn is executable. Ascending interaction order is the playable representation of the instance's saved restore invariant; native spell, light, and sound effects remain unsupported.",
});
});
const altar = reviewedSwitch(sourceObjectBySpawnId(definition, 11_922), {
initialState: "ready",
states: [
{ id: "ready", label: "Dormant", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "active", label: "Ritual active", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "complete", label: "Ritual complete", visible: true, blocksMovement: false, interactionEnabled: false },
],
prompt: "Begin Awaken the Soulflayer",
allowedStates: ["ready"],
signalId: "sunken-temple:hakkar-altar",
color: "#8c4e39",
note: "The exact source Altar of Hakkar spawn replaces the unsupported Egg/Essence item spell as the finite ritual start interaction.",
});
const flames = [47_561, 47_562, 47_563, 47_564].map((spawnId, index) => reviewedSwitch(
sourceObjectBySpawnId(definition, spawnId),
{
initialState: "locked",
states: [
{ id: "locked", label: "Inert", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "ready", label: "Burning", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "doused", label: "Doused", visible: true, blocksMovement: false, interactionEnabled: false },
],
prompt: `Douse Eternal Flame ${index + 1}`,
allowedStates: ["ready"],
signalId: `sunken-temple:eternal-flame-${index + 1}`,
color: "#d56d2d",
note: "The exact source Eternal Flame spawn is bound to one finite ritual pack. Blood inventory consumption and native flame animation are adapted.",
},
));
const forcefield = reviewedDoor(
sourceObjectBySpawnId(definition, 268_932),
"closed",
"The exact source forcefield spawn blocks Jammal'an until all six reviewed defender spawns are dead; procedural collision replaces its native visual state.",
);
const atalAlarionGroup = eventGroup("sunken-temple:atal-alarion", [atalAlarionSpawn]);
const avatarGroup = eventGroup("sunken-temple:avatar-of-hakkar", [avatarSpawn]);
const eranikusGroup = eventGroup("sunken-temple:shade-of-eranikus", [eranikusSpawn]);
const suppressorPositions = [
sourcePosition([-510.14, 287.44, -90.82]),
sourcePosition([-421.86, 267.98, -90.82]),
] as const;
const scalebanePositions = [
sourcePosition([-434.48, 288.33, -90.82]),
sourcePosition([-507.36, 265.35, -90.82]),
] as const;
const ritualGroups = Object.freeze(Array.from({ length: 4 }, (_, index) => eventGroup(
`sunken-temple:hakkar-pack-${index + 1}`,
[
eventSpawn(
`sunken-temple:hakkar-pack-${index + 1}-suppressor`,
idsByEntry.get(8_497)!,
suppressorPositions[index % suppressorPositions.length],
),
eventSpawn(
`sunken-temple:hakkar-pack-${index + 1}-scalebane`,
idsByEntry.get(8_438)!,
scalebanePositions[index % scalebanePositions.length],
),
],
)));
const rules: DungeonEncounterRule[] = [];
statues.forEach((statue, index) => {
const number = index + 1;
rules.push({
id: `activate-statue-${number}`,
trigger: { kind: "interact", objectId: statue.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "counter", counterId: "statue-progress", comparison: "equal", value: index },
{ kind: "world-object-state", objectId: statue.id, states: ["ready"] },
],
actions: [
{ id: `record-statue-${number}`, kind: "increment-counter", counterId: "statue-progress", persistence: "run" },
{ id: `mark-statue-${number}-used`, kind: "set-world-object-state", objectId: statue.id, state: "used", persistence: "run" },
...(number === statues.length ? [
{ id: "enter-atal-alarion", kind: "set-phase" as const, phase: "atal-alarion-active" },
{ id: "spawn-atal-alarion", kind: "activate-spawn-group" as const, spawnGroupId: atalAlarionGroup.id },
] : []),
],
});
});
rules.push({
id: "defenders-open-forcefield",
trigger: {
kind: "entities-dead",
entityIds: [5_712, 5_713, 5_714, 5_715, 5_716, 5_717]
.map((entry) => staticSpawnForEntry(definition, entry).id),
},
guards: [{ kind: "flag", flagId: "defenders-cleared", equals: false }],
actions: [
{ id: "record-defenders-cleared", kind: "set-flag", flagId: "defenders-cleared", value: true, persistence: "run" },
{ id: "open-jammalan-forcefield", kind: "set-world-object-state", objectId: forcefield.id, state: "open", persistence: "run" },
{ id: "checkpoint-defenders", kind: "set-checkpoint", checkpointId: "sunken-temple-defenders", persistence: "checkpoint" },
],
});
rules.push(
{
id: "atal-alarion-defeated",
trigger: { kind: "entities-dead", entityIds: [atalAlarionSpawn.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["atal-alarion-active"] }],
actions: [
{ id: "despawn-atal-alarion", kind: "deactivate-spawn-group", spawnGroupId: atalAlarionGroup.id },
{ id: "complete-atal-alarion", kind: "complete-objective", objectiveId: atalAlarionObjective.id, persistence: "run" },
{ id: "checkpoint-atal-alarion", kind: "set-checkpoint", checkpointId: "sunken-temple-atal-alarion", persistence: "checkpoint" },
{ id: "leave-atal-alarion", kind: "set-phase", phase: "idle" },
],
},
{
id: "start-hakkar-ritual",
trigger: { kind: "interact", objectId: altar.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "world-object-state", objectId: altar.id, states: ["ready"] },
{ kind: "objective-complete", objectiveId: avatarObjective.id, equals: false },
],
actions: [
{ id: "set-hakkar-altar-active", kind: "set-world-object-state", objectId: altar.id, state: "active" },
{ id: "spawn-hakkar-pack-1", kind: "activate-spawn-group", spawnGroupId: ritualGroups[0].id },
{ id: "enter-hakkar-pack-1", kind: "set-phase", phase: "hakkar-pack-1" },
],
},
);
ritualGroups.forEach((group, index) => {
const number = index + 1;
const flame = flames[index];
rules.push({
id: `clear-hakkar-pack-${number}`,
trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: [`hakkar-pack-${number}`] }],
actions: [
{ id: `despawn-hakkar-pack-${number}`, kind: "deactivate-spawn-group", spawnGroupId: group.id },
{ id: `ready-eternal-flame-${number}`, kind: "set-world-object-state", objectId: flame.id, state: "ready" },
{ id: `enter-eternal-flame-${number}`, kind: "set-phase", phase: `hakkar-flame-${number}` },
],
});
rules.push({
id: `douse-eternal-flame-${number}`,
trigger: { kind: "interact", objectId: flame.id },
guards: [
{ kind: "phase", phases: [`hakkar-flame-${number}`] },
{ kind: "world-object-state", objectId: flame.id, states: ["ready"] },
],
actions: [
{ id: `douse-eternal-flame-${number}-state`, kind: "set-world-object-state", objectId: flame.id, state: "doused" },
...(number < ritualGroups.length ? [
{
id: `spawn-hakkar-pack-${number + 1}`,
kind: "activate-spawn-group" as const,
spawnGroupId: ritualGroups[index + 1].id,
},
{ id: `enter-hakkar-pack-${number + 1}`, kind: "set-phase" as const, phase: `hakkar-pack-${number + 1}` },
] : [
{ id: "spawn-avatar-of-hakkar", kind: "activate-spawn-group" as const, spawnGroupId: avatarGroup.id },
{ id: "enter-avatar-of-hakkar", kind: "set-phase" as const, phase: "avatar-active" },
]),
],
});
});
rules.push(
{
id: "avatar-of-hakkar-defeated",
trigger: { kind: "entities-dead", entityIds: [avatarSpawn.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["avatar-active"] }],
actions: [
{ id: "despawn-avatar-of-hakkar", kind: "deactivate-spawn-group", spawnGroupId: avatarGroup.id },
{ id: "complete-avatar-of-hakkar", kind: "complete-objective", objectiveId: avatarObjective.id, persistence: "run" },
{ id: "complete-hakkar-altar", kind: "set-world-object-state", objectId: altar.id, state: "complete", persistence: "run" },
...flames.map((flame, index) => ({
id: `persist-eternal-flame-${index + 1}`,
kind: "set-world-object-state" as const,
objectId: flame.id,
state: "doused",
persistence: "run" as const,
})),
{ id: "checkpoint-avatar-of-hakkar", kind: "set-checkpoint", checkpointId: "sunken-temple-avatar", persistence: "checkpoint" },
{ id: "leave-avatar-of-hakkar", kind: "set-phase", phase: "idle" },
],
},
{
id: "jammalan-releases-eranikus",
trigger: { kind: "entity-death", entityId: jammalanSpawn.id },
guards: [{ kind: "flag", flagId: "eranikus-released", equals: false }],
actions: [
{ id: "record-eranikus-release", kind: "set-flag", flagId: "eranikus-released", value: true, persistence: "run" },
{ id: "spawn-shade-of-eranikus", kind: "activate-spawn-group", spawnGroupId: eranikusGroup.id },
{ id: "enter-shade-of-eranikus", kind: "set-phase", phase: "eranikus-active" },
],
},
{
id: "shade-of-eranikus-defeated",
trigger: { kind: "entities-dead", entityIds: [eranikusSpawn.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["eranikus-active"] }],
actions: [
{ id: "despawn-shade-of-eranikus", kind: "deactivate-spawn-group", spawnGroupId: eranikusGroup.id },
{ id: "complete-shade-of-eranikus", kind: "complete-objective", objectiveId: eranikusObjective.id, persistence: "run" },
{ id: "checkpoint-shade-of-eranikus", kind: "set-checkpoint", checkpointId: "sunken-temple-eranikus", persistence: "checkpoint" },
{ id: "leave-shade-of-eranikus", kind: "set-phase", phase: "idle" },
],
},
{
id: "recover-atal-alarion",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["recovery"] },
{ kind: "counter", counterId: "statue-progress", comparison: "at-least", value: 6 },
{ kind: "objective-complete", objectiveId: atalAlarionObjective.id, equals: false },
{ kind: "spawn-group-active", spawnGroupId: atalAlarionGroup.id, equals: false },
],
actions: [
{ id: "recover-spawn-atal-alarion", kind: "activate-spawn-group", spawnGroupId: atalAlarionGroup.id },
{ id: "recover-enter-atal-alarion", kind: "set-phase", phase: "atal-alarion-active" },
],
priority: 20,
},
{
id: "recover-shade-of-eranikus",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["recovery"] },
{ kind: "flag", flagId: "eranikus-released", equals: true },
{ kind: "objective-complete", objectiveId: eranikusObjective.id, equals: false },
{ kind: "spawn-group-active", spawnGroupId: eranikusGroup.id, equals: false },
],
actions: [
{ id: "recover-spawn-shade-of-eranikus", kind: "activate-spawn-group", spawnGroupId: eranikusGroup.id },
{ id: "recover-enter-shade-of-eranikus", kind: "set-phase", phase: "eranikus-active" },
],
priority: 10,
},
{
id: "finish-sunken-temple-recovery",
trigger: { kind: "tick" },
guards: [{ kind: "phase", phases: ["recovery"] }],
actions: [{ id: "return-sunken-temple-idle", kind: "set-phase", phase: "idle" }],
priority: -10,
},
);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "sunken-temple:classic-batch-two",
dungeonId: definition.id,
initialPhase: "idle",
initialState: {
flags: { "defenders-cleared": false, "eranikus-released": false },
counters: { "statue-progress": 0 },
worldObjectStates: Object.fromEntries([
...statues.map((statue) => [statue.id, "ready"] as const),
...flames.map((flame) => [flame.id, "locked"] as const),
[altar.id, "ready"],
[forcefield.id, "closed"],
]),
},
resetPolicy: { bossWipePhase: "recovery", checkpointPhase: "recovery" },
source: sourceReview(definition),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
...eventObjectives,
staticSpawns: removeStaticSpawnIds(definition, new Set([
atalAlarionSpawn.id,
avatarSpawn.id,
eranikusSpawn.id,
])),
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []),
atalAlarionGroup,
...ritualGroups,
avatarGroup,
eranikusGroup,
]),
serverGameObjects: replaceObjects(definition, [...statues, altar, ...flames, forcefield]),
validation: appendValidationWarnings(definition, [
"Sunken Temple now executes the six-statue Atal'alarion gate, six-defender forcefield, four-pack Hakkar ritual, and Jammal'an-to-Eranikus release. Item, spell, dialogue, repeated timer, and native visual differences remain documented adaptations.",
]),
});
}
function buildBlackrockDepths(definition: DungeonDefinition): DungeonDefinition {
const { entities, idsByEntry } = ensureAdaptedMobs(definition, [
{ entry: 8_925, name: "Dredge Worm", level: 50, role: "melee" },
{ entry: 9_027, name: "Gorosh the Dervish", level: 54, role: "melee" },
{ entry: 9_476, name: "Watchman Doomgrip", level: 55, role: "melee" },
{ entry: 9_035, name: "Anger'rel", level: 55, role: "melee" },
{ entry: 9_038, name: "Seeth'rel", level: 55, role: "caster" },
{ entry: 9_040, name: "Dope'rel", level: 55, role: "caster" },
{ entry: 9_037, name: "Gloom'rel", level: 55, role: "caster" },
{ entry: 9_036, name: "Vile'rel", level: 55, role: "caster" },
{ entry: 9_034, name: "Hate'rel", level: 55, role: "melee" },
{ entry: 9_039, name: "Doom'rel", level: 56, role: "caster" },
]);
const arenaBossPosition = sourcePosition([644.3, -175.989, -53.739]);
const watchmanPosition = sourcePosition([812.15, -348.91, -50.579]);
const tombSourceRows = [
{ entry: 9_035, spawnId: 91_025, position: sourcePosition([1215.05, -220.656, -85.5903]), yaw: 0.034907 },
{ entry: 9_038, spawnId: 91_026, position: sourcePosition([1286.56, -253.331, -78.0612]), yaw: 3.00197 },
{ entry: 9_040, spawnId: 91_023, position: sourcePosition([1236.55, -291.534, -78.1351]), yaw: 0.750492 },
{ entry: 9_037, spawnId: 91_022, position: sourcePosition([1226.69, -246.124, -85.5903]), yaw: 0.820305 },
{ entry: 9_036, spawnId: 91_024, position: sourcePosition([1272.32, -199.817, -85.5905]), yaw: 3.92699 },
{ entry: 9_034, spawnId: 91_021, position: sourcePosition([1246.18, -189.969, -85.5895]), yaw: 4.66003 },
{ entry: 9_039, spawnId: 91_020, position: sourcePosition([1281.14, -282.151, -78.1359]), yaw: 2.25148 },
] as const;
const ringObjective: DungeonBossObjective = Object.freeze({
id: "blackrock-depths:ring-of-law-gorosh",
name: "Gorosh the Dervish",
position: arenaBossPosition,
optional: true,
eventOnly: true,
});
const watchmanObjective: DungeonBossObjective = Object.freeze({
id: "blackrock-depths:watchman-doomgrip",
name: "Watchman Doomgrip",
position: watchmanPosition,
optional: true,
eventOnly: true,
});
const tombObjective: DungeonBossObjective = Object.freeze({
id: "blackrock-depths:tomb-of-seven",
name: "The Seven",
position: tombSourceRows[tombSourceRows.length - 1].position,
optional: true,
eventOnly: true,
});
const eventObjectives = addEventObjectives(definition, [ringObjective, watchmanObjective, tombObjective]);
const arenaGate3 = reviewedDoor(sourceObjectBySpawnId(definition, 15_295), "closed");
const arenaGate4Source = sourceObjectBySpawnId(definition, 15_309);
const arenaGate4 = reviewedObject(arenaGate4Source, {
kind: "gate",
initialState: "ready",
states: [
{ id: "ready", label: "Ring of Law entrance", visible: true, blocksMovement: false, interactionEnabled: true },
{ 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: "#6b5840",
emissive: "#1a1108",
interaction: {
prompt: "Enter the Ring of Law",
range: 4,
signalId: "blackrock-depths:ring-of-law",
allowedStates: ["ready"],
oneShot: false,
},
note: "The exact Arena Gate 4 spawn is the executable substitute for the source area trigger; the ready proxy is intentionally visible while non-blocking.",
});
const arenaGate2 = reviewedDoor(sourceObjectBySpawnId(definition, 15_332), "closed");
const arenaGate1 = reviewedDoor(sourceObjectBySpawnId(definition, 18_280), "closed");
const tombExit = reviewedDoor(sourceObjectBySpawnId(definition, 15_334), "closed");
const tombEntrance = reviewedDoor(sourceObjectBySpawnId(definition, 15_551), "open");
const tombChest = reviewedObject(sourceObjectBySpawnId(definition, 67_872), {
kind: "chest",
initialState: "locked",
states: [
{ id: "locked", label: "Unavailable", visible: false, blocksMovement: false, interactionEnabled: false },
{ id: "available", label: "Available", visible: true, blocksMovement: false, interactionEnabled: false },
],
color: "#8c6b35",
emissive: "#2b1b08",
note: "The exact source Chest of The Seven spawn appears on completion; native loot is intentionally not fabricated.",
});
const spectralChalice = reviewedObject(sourceObjectBySpawnId(definition, 67_871), {
kind: "ritual",
initialState: "visible",
states: [
{ id: "visible", label: "Spectral Chalice", visible: true, blocksMovement: false, interactionEnabled: false },
],
color: "#7d7aa5",
emissive: "#302c58",
note: "The exact source Spectral Chalice spawn remains present as reviewed scenery; its class quest and gossip path are unsupported.",
});
const cofferSpawnIds = [
15_306, 15_329, 15_330, 15_331, 15_363, 15_364,
15_365, 15_536, 17_904, 35_801, 35_864, 39_924,
] as const;
const cofferDoors = cofferSpawnIds.map((spawnId, index) => reviewedObject(
sourceObjectBySpawnId(definition, spawnId),
{
kind: "door",
initialState: "closed",
states: [
{ id: "closed", label: "Locked coffer", visible: true, blocksMovement: true, interactionEnabled: true },
{ id: "open", label: "Opened", visible: false, blocksMovement: false, interactionEnabled: false },
],
color: "#70522f",
emissive: "#1e1207",
interaction: {
prompt: `Open Relic Coffer ${index + 1}`,
range: 3.2,
signalId: `blackrock-depths:coffer-${index + 1}`,
allowedStates: ["closed"],
oneShot: false,
},
note: "This is one exact reviewed source coffer-door spawn. Direct one-time use adapts unavailable Relic Coffer Key spell 13478.",
},
));
const secretDoor = reviewedDoor(sourceObjectBySpawnId(definition, 43_094), "closed");
const secretSafe = reviewedObject(sourceObjectBySpawnId(definition, 43_137), {
kind: "chest",
initialState: "locked",
states: [
{ id: "locked", label: "Locked", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "available", label: "Available", visible: true, blocksMovement: false, interactionEnabled: false },
],
color: "#78613d",
emissive: "#21160a",
note: "The exact reviewed secret-safe spawn becomes available after Doomgrip; native loot is intentionally unsupported.",
});
const doomrelMarker = scriptedNpcObject({
id: "blackrock-depths:doomrel-marker",
name: "Doom'rel",
position: tombSourceRows[tombSourceRows.length - 1].position,
prompt: "Challenge The Seven",
signalId: "blackrock-depths:tomb-of-seven-start",
sourceFiles: BLACKROCK_FILES,
note: "A serializable interaction marker at Doom'rel's exact imported position substitutes for unsupported gossip while the seven combat actors remain source-backed.",
});
const arenaWavePosition = sourcePosition([608.96, -235.322, -53.907]);
const arenaWaveGroups = Object.freeze([1, 2].map((waveNumber) => eventGroup(
`blackrock-depths:ring-wave-${waveNumber}`,
Array.from({ length: 3 }, (_, index) => eventSpawn(
`blackrock-depths:ring-wave-${waveNumber}-worm-${index + 1}`,
idsByEntry.get(8_925)!,
[arenaWavePosition[0] - index * 0.4, arenaWavePosition[1], arenaWavePosition[2]],
1.857,
)),
)));
const arenaBossGroup = eventGroup("blackrock-depths:ring-boss", [
eventSpawn(ringObjective.id, idsByEntry.get(9_027)!, arenaBossPosition, 3.418),
]);
const watchmanGroup = eventGroup("blackrock-depths:vault-watchman", [
eventSpawn(watchmanObjective.id, idsByEntry.get(9_476)!, watchmanPosition, 0.7),
]);
const tombGroups = Object.freeze(tombSourceRows.map((row, index) => eventGroup(
`blackrock-depths:tomb-spectral-${index + 1}`,
[eventSpawn(`creature-${row.spawnId}`, idsByEntry.get(row.entry)!, row.position, row.yaw)],
)));
const rules: DungeonEncounterRule[] = [
{
id: "start-ring-of-law",
trigger: { kind: "interact", objectId: arenaGate4.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "world-object-state", objectId: arenaGate4.id, states: ["ready"] },
{ kind: "objective-complete", objectiveId: ringObjective.id, equals: false },
],
actions: [
{ id: "close-ring-gate-4", kind: "set-world-object-state", objectId: arenaGate4.id, state: "closed" },
{ id: "enter-ring-intro", kind: "set-phase", phase: "ring-intro" },
],
},
{
id: "spawn-ring-wave-1",
trigger: { kind: "elapsed", afterMs: 5_000 },
guards: [{ kind: "phase", phases: ["ring-intro"] }],
actions: [
{ id: "open-ring-gate-1", kind: "set-world-object-state", objectId: arenaGate1.id, state: "open" },
{ id: "activate-ring-wave-1", kind: "activate-spawn-group", spawnGroupId: arenaWaveGroups[0].id },
{ id: "enter-ring-wave-1", kind: "set-phase", phase: "ring-wave-1" },
],
},
{
id: "clear-ring-wave-1",
trigger: { kind: "entities-dead", entityIds: arenaWaveGroups[0].spawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["ring-wave-1"] }],
actions: [
{ id: "deactivate-ring-wave-1", kind: "deactivate-spawn-group", spawnGroupId: arenaWaveGroups[0].id },
{ id: "enter-ring-wave-2-delay", kind: "set-phase", phase: "ring-wave-2-delay" },
],
},
{
id: "spawn-ring-wave-2",
trigger: { kind: "elapsed", afterMs: 5_000 },
guards: [{ kind: "phase", phases: ["ring-wave-2-delay"] }],
actions: [
{ id: "activate-ring-wave-2", kind: "activate-spawn-group", spawnGroupId: arenaWaveGroups[1].id },
{ id: "enter-ring-wave-2", kind: "set-phase", phase: "ring-wave-2" },
],
},
{
id: "clear-ring-wave-2",
trigger: { kind: "entities-dead", entityIds: arenaWaveGroups[1].spawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["ring-wave-2"] }],
actions: [
{ id: "deactivate-ring-wave-2", kind: "deactivate-spawn-group", spawnGroupId: arenaWaveGroups[1].id },
{ id: "close-ring-gate-1-for-boss", kind: "set-world-object-state", objectId: arenaGate1.id, state: "closed" },
{ id: "open-ring-gate-2", kind: "set-world-object-state", objectId: arenaGate2.id, state: "open" },
{ id: "enter-ring-boss-delay", kind: "set-phase", phase: "ring-boss-delay" },
],
},
{
id: "spawn-ring-boss",
trigger: { kind: "elapsed", afterMs: 5_000 },
guards: [{ kind: "phase", phases: ["ring-boss-delay"] }],
actions: [
{ id: "activate-ring-boss", kind: "activate-spawn-group", spawnGroupId: arenaBossGroup.id },
{ id: "enter-ring-boss", kind: "set-phase", phase: "ring-boss" },
],
},
{
id: "complete-ring-of-law",
trigger: { kind: "entities-dead", entityIds: [ringObjective.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["ring-boss"] }],
actions: [
{ id: "deactivate-ring-boss", kind: "deactivate-spawn-group", spawnGroupId: arenaBossGroup.id },
{ id: "record-ring-objective", kind: "complete-objective", objectiveId: ringObjective.id, persistence: "run" },
{ id: "persist-ring-gate-3-open", kind: "set-world-object-state", objectId: arenaGate3.id, state: "open", persistence: "run" },
{ id: "persist-ring-gate-4-open", kind: "set-world-object-state", objectId: arenaGate4.id, state: "open", persistence: "run" },
{ id: "persist-ring-gate-2-closed", kind: "set-world-object-state", objectId: arenaGate2.id, state: "closed", persistence: "run" },
{ id: "checkpoint-ring-of-law", kind: "set-checkpoint", checkpointId: "blackrock-depths-ring", persistence: "checkpoint" },
{ id: "leave-ring-of-law", kind: "set-phase", phase: "idle" },
],
},
];
cofferDoors.forEach((door, index) => {
const number = index + 1;
rules.push({
id: `open-relic-coffer-${number}`,
trigger: { kind: "interact", objectId: door.id },
guards: [{ kind: "world-object-state", objectId: door.id, states: ["closed"] }],
actions: [
{ id: `mark-relic-coffer-${number}-open`, kind: "set-world-object-state", objectId: door.id, state: "open", persistence: "run" },
{ id: `count-relic-coffer-${number}`, kind: "increment-counter", counterId: "opened-coffers", persistence: "run" },
],
});
});
rules.push(
{
id: "summon-watchman-doomgrip",
trigger: { kind: "tick" },
guards: [
{ kind: "counter", counterId: "opened-coffers", comparison: "at-least", value: 12 },
{ kind: "objective-complete", objectiveId: watchmanObjective.id, equals: false },
{ kind: "spawn-group-active", spawnGroupId: watchmanGroup.id, equals: false },
],
actions: [{ id: "activate-watchman-doomgrip", kind: "activate-spawn-group", spawnGroupId: watchmanGroup.id }],
},
{
id: "watchman-doomgrip-defeated",
trigger: { kind: "entities-dead", entityIds: [watchmanObjective.id], timeoutMs: 900_000 },
guards: [{ kind: "objective-complete", objectiveId: watchmanObjective.id, equals: false }],
actions: [
{ id: "deactivate-watchman-doomgrip", kind: "deactivate-spawn-group", spawnGroupId: watchmanGroup.id },
{ id: "record-watchman-doomgrip", kind: "complete-objective", objectiveId: watchmanObjective.id, persistence: "run" },
{ id: "open-doomgrip-secret-door", kind: "set-world-object-state", objectId: secretDoor.id, state: "open", persistence: "run" },
{ id: "unlock-doomgrip-secret-safe", kind: "set-world-object-state", objectId: secretSafe.id, state: "available", persistence: "run" },
{ id: "checkpoint-watchman-doomgrip", kind: "set-checkpoint", checkpointId: "blackrock-depths-vault", persistence: "checkpoint" },
],
},
{
id: "start-tomb-of-seven",
trigger: { kind: "interact", objectId: doomrelMarker.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "world-object-state", objectId: doomrelMarker.id, states: ["ready"] },
{ kind: "objective-complete", objectiveId: tombObjective.id, equals: false },
],
actions: [
{ id: "activate-doomrel-marker", kind: "set-world-object-state", objectId: doomrelMarker.id, state: "active" },
{ id: "close-tomb-entrance", kind: "set-world-object-state", objectId: tombEntrance.id, state: "closed" },
{ id: "close-tomb-exit", kind: "set-world-object-state", objectId: tombExit.id, state: "closed" },
{ id: "reset-tomb-kills", kind: "set-counter", counterId: "tomb-kills", value: 0 },
{ id: "enter-tomb-intro", kind: "set-phase", phase: "tomb-intro" },
],
},
{
id: "release-spectral-1",
trigger: { kind: "elapsed", afterMs: 1_000 },
guards: [{ kind: "phase", phases: ["tomb-intro"] }],
actions: [
{ id: "activate-spectral-1", kind: "activate-spawn-group", spawnGroupId: tombGroups[0].id },
{ id: "enter-spectral-timer-1", kind: "set-phase", phase: "tomb-timer-1" },
],
},
);
for (let index = 1; index < tombGroups.length; index += 1) {
const number = index + 1;
rules.push({
id: `release-spectral-${number}`,
trigger: { kind: "elapsed", afterMs: 30_000 },
guards: [{ kind: "phase", phases: [`tomb-timer-${number - 1}`] }],
actions: [
{ id: `activate-spectral-${number}`, kind: "activate-spawn-group", spawnGroupId: tombGroups[index].id },
{ id: `enter-spectral-timer-${number}`, kind: "set-phase", phase: `tomb-timer-${number}` },
],
});
}
tombGroups.forEach((group, index) => {
const number = index + 1;
rules.push({
id: `spectral-${number}-defeated`,
trigger: { kind: "entity-death", entityId: group.spawns[0].id },
guards: [{ kind: "objective-complete", objectiveId: tombObjective.id, equals: false }],
actions: [
{ id: `deactivate-spectral-${number}`, kind: "deactivate-spawn-group", spawnGroupId: group.id },
{ id: `count-spectral-${number}`, kind: "increment-counter", counterId: "tomb-kills" },
],
});
});
rules.push({
id: "complete-tomb-of-seven",
trigger: { kind: "tick" },
guards: [
{ kind: "counter", counterId: "tomb-kills", comparison: "at-least", value: 7 },
{ kind: "objective-complete", objectiveId: tombObjective.id, equals: false },
],
actions: [
...tombGroups.map((group, index) => ({
id: `finish-deactivate-spectral-${index + 1}`,
kind: "deactivate-spawn-group" as const,
spawnGroupId: group.id,
})),
{ id: "record-tomb-of-seven", kind: "complete-objective", objectiveId: tombObjective.id, persistence: "run" },
{ id: "complete-doomrel-marker", kind: "set-world-object-state", objectId: doomrelMarker.id, state: "complete", persistence: "run" },
{ id: "persist-tomb-entrance-open", kind: "set-world-object-state", objectId: tombEntrance.id, state: "open", persistence: "run" },
{ id: "persist-tomb-exit-open", kind: "set-world-object-state", objectId: tombExit.id, state: "open", persistence: "run" },
{ id: "unlock-tomb-chest", kind: "set-world-object-state", objectId: tombChest.id, state: "available", persistence: "run" },
{ id: "checkpoint-tomb-of-seven", kind: "set-checkpoint", checkpointId: "blackrock-depths-tomb", persistence: "checkpoint" },
{ id: "leave-tomb-of-seven", kind: "set-phase", phase: "idle" },
],
});
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "blackrock-depths:classic-batch-two",
dungeonId: definition.id,
initialPhase: "idle",
initialState: {
counters: { "opened-coffers": 0, "tomb-kills": 0 },
worldObjectStates: Object.fromEntries([
[arenaGate1.id, "closed"],
[arenaGate2.id, "closed"],
[arenaGate3.id, "closed"],
[arenaGate4.id, "ready"],
[tombEntrance.id, "open"],
[tombExit.id, "closed"],
[tombChest.id, "locked"],
[spectralChalice.id, "visible"],
[doomrelMarker.id, "ready"],
...cofferDoors.map((door) => [door.id, "closed"] as const),
[secretDoor.id, "closed"],
[secretSafe.id, "locked"],
]),
},
resetPolicy: { bossWipePhase: "idle", checkpointPhase: "idle" },
source: sourceReview(definition),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
...eventObjectives,
staticSpawns: definition.staticSpawns,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []),
...arenaWaveGroups,
arenaBossGroup,
watchmanGroup,
...tombGroups,
]),
serverGameObjects: replaceObjects(
definition,
[
arenaGate1,
arenaGate2,
arenaGate3,
arenaGate4,
tombEntrance,
tombExit,
tombChest,
spectralChalice,
...cofferDoors,
secretDoor,
secretSafe,
],
[doomrelMarker],
),
validation: appendValidationWarnings(definition, [
"Blackrock Depths now has finite wipe-safe Ring of Law, twelve-coffer Doomgrip, and timed Tomb of Seven events. Random alternatives, keys, escort movement/dialogue, loot, and native presentation remain explicit adaptations.",
]),
});
}
function buildScholomance(definition: DungeonDefinition): DungeonDefinition {
const { entities, idsByEntry } = ensureAdaptedMobs(definition, [
{ entry: 10_506, name: "Kirtonos the Herald", level: 60, role: "caster" },
]);
const gandlingSpawn = staticSpawnForEntry(definition, 1_853);
const miniBossEntries = [10_505, 11_261, 10_901, 10_504, 10_502, 10_507] as const;
const miniBossSpawns = miniBossEntries.map((entry) => staticSpawnForEntry(definition, entry));
const kirtonosPosition = sourcePosition([314.8673, 90.3021, 101.6459]);
const kirtonosObjective: DungeonBossObjective = Object.freeze({
id: "scholomance:kirtonos-the-herald",
name: "Kirtonos the Herald",
position: kirtonosPosition,
optional: true,
eventOnly: true,
});
const gandlingObjective: DungeonBossObjective = Object.freeze({
id: gandlingSpawn.id,
name: "Darkmaster Gandling",
position: gandlingSpawn.position,
eventOnly: true,
});
const eventObjectives = addEventObjectives(definition, [kirtonosObjective, gandlingObjective]);
const kirtonosGate = reviewedDoor(
sourceObjectBySpawnId(definition, 18_892),
"open",
"The exact source Kirtonos gate closes for the Brazier event and reopens on death or wipe; procedural collision replaces native animation.",
);
const gandlingGateSpawnIds = [35_798, 43_184, 43_187, 43_188, 43_196, 43_201, 43_203] as const;
const gandlingGates = gandlingGateSpawnIds.map((spawnId) => reviewedDoor(
sourceObjectBySpawnId(definition, spawnId),
"open",
"This exact reviewed Gandling-room gate retains source placement. Only the entrance gate participates in combat collision; room teleport mechanics remain unsupported.",
));
const gandlingEntrance = gandlingGates[gandlingGateSpawnIds.indexOf(43_201)];
const brazier = reviewedSwitch(sourceObjectBySpawnId(definition, 43_208), {
initialState: "ready",
states: [
{ id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "active", label: "Herald summoned", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "complete", label: "Herald defeated", visible: true, blocksMovement: false, interactionEnabled: false },
],
prompt: "Use the Brazier of the Herald",
allowedStates: ["ready"],
signalId: "scholomance:brazier-of-the-herald",
color: "#99602e",
note: "The exact source Brazier spawn directly starts Kirtonos because Blood of Innocents inventory and spell handling are not represented.",
});
const kirtonosGroup = eventGroup("scholomance:kirtonos", [
eventSpawn(kirtonosObjective.id, idsByEntry.get(10_506)!, kirtonosPosition, 0.01745329),
]);
const gandlingGroup = eventGroup("scholomance:gandling", [gandlingSpawn]);
const originalRequiredObjectives = definition.bosses.filter((objective) => !objective.optional);
const rules: DungeonEncounterRule[] = originalRequiredObjectives.map((objective, index) => ({
id: `record-fleet-objective-${index + 1}`,
trigger: { kind: "entity-death", entityId: objective.id },
actions: [{
id: `complete-fleet-objective-${index + 1}`,
kind: "complete-objective",
objectiveId: objective.id,
persistence: "run",
}],
}));
rules.push(
{
id: "start-kirtonos",
trigger: { kind: "interact", objectId: brazier.id },
guards: [
{ kind: "phase", phases: ["idle"] },
{ kind: "world-object-state", objectId: brazier.id, states: ["ready"] },
{ kind: "objective-complete", objectiveId: kirtonosObjective.id, equals: false },
],
actions: [
{ id: "activate-kirtonos-brazier", kind: "set-world-object-state", objectId: brazier.id, state: "active" },
{ id: "close-kirtonos-gate", kind: "set-world-object-state", objectId: kirtonosGate.id, state: "closed" },
{ id: "enter-kirtonos-intro", kind: "set-phase", phase: "kirtonos-intro" },
],
},
{
id: "kirtonos-lands",
trigger: { kind: "elapsed", afterMs: 5_000 },
guards: [{ kind: "phase", phases: ["kirtonos-intro"] }],
actions: [
{ id: "activate-kirtonos", kind: "activate-spawn-group", spawnGroupId: kirtonosGroup.id },
{ id: "enter-kirtonos-active", kind: "set-phase", phase: "kirtonos-active" },
],
},
{
id: "kirtonos-defeated",
trigger: { kind: "entity-death", entityId: kirtonosObjective.id },
guards: [{ kind: "phase", phases: ["kirtonos-active"] }],
actions: [
{ id: "deactivate-kirtonos", kind: "deactivate-spawn-group", spawnGroupId: kirtonosGroup.id },
{ id: "complete-kirtonos", kind: "complete-objective", objectiveId: kirtonosObjective.id, persistence: "run" },
{ id: "persist-kirtonos-gate-open", kind: "set-world-object-state", objectId: kirtonosGate.id, state: "open", persistence: "run" },
{ id: "persist-kirtonos-brazier-complete", kind: "set-world-object-state", objectId: brazier.id, state: "complete", persistence: "run" },
{ id: "checkpoint-kirtonos", kind: "set-checkpoint", checkpointId: kirtonosObjective.id, persistence: "checkpoint" },
{ id: "leave-kirtonos", kind: "set-phase", phase: "idle" },
],
},
{
id: "six-mini-bosses-reveal-gandling",
trigger: { kind: "entities-dead", entityIds: miniBossSpawns.map((spawn) => spawn.id) },
guards: [{ kind: "flag", flagId: "gandling-unlocked", equals: false }],
actions: [
{ id: "record-gandling-unlocked", kind: "set-flag", flagId: "gandling-unlocked", value: true, persistence: "run" },
{ id: "checkpoint-six-mini-bosses", kind: "set-checkpoint", checkpointId: "scholomance-six-mini-bosses", persistence: "checkpoint" },
{ id: "enter-gandling-reveal", kind: "set-phase", phase: "gandling-reveal" },
],
},
{
id: "reveal-gandling",
trigger: { kind: "elapsed", afterMs: 5_000 },
guards: [{ kind: "phase", phases: ["gandling-reveal"] }],
actions: [
{ id: "activate-gandling", kind: "activate-spawn-group", spawnGroupId: gandlingGroup.id },
{ id: "enter-gandling-ready", kind: "set-phase", phase: "gandling-ready" },
],
},
{
id: "gandling-engaged",
trigger: { kind: "entity-engaged", entityId: gandlingSpawn.id },
guards: [{ kind: "phase", phases: ["gandling-ready"] }],
actions: [
{ id: "close-gandling-entrance", kind: "set-world-object-state", objectId: gandlingEntrance.id, state: "closed" },
{ id: "enter-gandling-active", kind: "set-phase", phase: "gandling-active" },
],
},
{
id: "gandling-defeated",
trigger: { kind: "entity-death", entityId: gandlingSpawn.id },
guards: [{ kind: "phase", phases: ["gandling-ready", "gandling-active"] }],
actions: [
{ id: "deactivate-gandling", kind: "deactivate-spawn-group", spawnGroupId: gandlingGroup.id },
{ id: "complete-gandling", kind: "complete-objective", objectiveId: gandlingObjective.id, persistence: "run" },
{ id: "persist-gandling-entrance-open", kind: "set-world-object-state", objectId: gandlingEntrance.id, state: "open", persistence: "run" },
{ id: "checkpoint-gandling", kind: "set-checkpoint", checkpointId: gandlingObjective.id, persistence: "checkpoint" },
{ id: "leave-gandling", kind: "set-phase", phase: "idle" },
],
},
{
id: "complete-scholomance",
trigger: { kind: "tick" },
guards: [
...originalRequiredObjectives.map((objective) => ({
kind: "objective-complete" as const,
objectiveId: objective.id,
equals: true,
})),
{ kind: "objective-complete", objectiveId: gandlingObjective.id, equals: true },
],
actions: [
{ id: "mark-scholomance-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "enter-scholomance-complete", kind: "set-phase", phase: "complete", persistence: "run" },
],
},
{
id: "recover-gandling",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["recovery"] },
{ kind: "flag", flagId: "gandling-unlocked", equals: true },
{ kind: "objective-complete", objectiveId: gandlingObjective.id, equals: false },
{ kind: "spawn-group-active", spawnGroupId: gandlingGroup.id, equals: false },
],
actions: [
{ id: "recover-activate-gandling", kind: "activate-spawn-group", spawnGroupId: gandlingGroup.id },
{ id: "recover-enter-gandling-ready", kind: "set-phase", phase: "gandling-ready" },
],
priority: 20,
},
{
id: "finish-scholomance-recovery",
trigger: { kind: "tick" },
guards: [{ kind: "phase", phases: ["recovery"] }],
actions: [{ id: "return-scholomance-idle", kind: "set-phase", phase: "idle" }],
priority: -10,
},
);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "scholomance:classic-batch-two",
dungeonId: definition.id,
initialPhase: "idle",
initialState: {
flags: { "gandling-unlocked": false, "dungeon-complete": false },
worldObjectStates: Object.fromEntries([
[kirtonosGate.id, "open"],
...gandlingGates.map((gate) => [gate.id, "open"] as const),
[brazier.id, "ready"],
]),
},
resetPolicy: { bossWipePhase: "recovery", checkpointPhase: "recovery" },
source: sourceReview(definition),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
...eventObjectives,
staticSpawns: removeStaticSpawnIds(definition, new Set([gandlingSpawn.id])),
encounters: Object.freeze([
...withoutFleetCompletionRule(definition.encounters, "scholomance:boss-progression"),
encounter,
]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []),
kirtonosGroup,
gandlingGroup,
]),
serverGameObjects: replaceObjects(definition, [kirtonosGate, ...gandlingGates, brazier]),
validation: appendValidationWarnings(definition, [
"Scholomance now executes the Brazier/Kirtonos gate and requires all six source minibosses before Gandling; the generic completion rule is replaced so Gandling is the real final objective. Flight, item use, dialogue, and room teleports remain adapted.",
]),
});
}
function buildStratholme(definition: DungeonDefinition): DungeonDefinition {
const { entities, idsByEntry } = ensureAdaptedMobs(definition, [
{ entry: 10_415, name: "Thuzadin Acolyte", level: 58, role: "caster" },
{ entry: 10_439, name: "Ramstein the Gorger", level: 60, role: "melee" },
{ entry: 11_030, name: "Mindless Undead", level: 57, role: "melee" },
{ entry: 10_394, name: "Black Guard", level: 60, role: "melee" },
]);
const rivendareSpawn = staticSpawnForEntry(definition, 10_440);
const rivendareObjective: DungeonBossObjective = Object.freeze({
id: rivendareSpawn.id,
name: "Baron Rivendare",
position: rivendareSpawn.position,
optional: true,
eventOnly: true,
});
const eventObjectives = addEventObjectives(definition, [rivendareObjective]);
const zigguratBossEntries = [10_436, 10_437, 10_438] as const;
const zigguratBossSpawns = zigguratBossEntries.map((entry) => staticSpawnForEntry(definition, entry));
const zigguratDoors = [11_415, 11_414, 11_416].map((spawnId) => reviewedDoor(
sourceObjectBySpawnId(definition, spawnId),
"closed",
"This exact source ziggurat door opens on its bound boss death and remains open across checkpoint recovery.",
));
const gauntletGate = reviewedDoor(sourceObjectBySpawnId(definition, 6_911), "closed");
const slaughterGate = reviewedDoor(sourceObjectBySpawnId(definition, 6_908), "closed");
const slaughterDoor4 = reviewedDoor(sourceObjectBySpawnId(definition, 11_808), "closed");
const baronDoor = reviewedDoor(sourceObjectBySpawnId(definition, 35_848), "closed");
const slaughterSideGate = reviewedDoor(sourceObjectBySpawnId(definition, 49_594), "closed");
const acolytePositions = [
stratholmeSourcePosition([3847.58, -3748.46, 195.809]),
stratholmeSourcePosition([3838.85, -3500.02, 192.029]),
stratholmeSourcePosition([4057.59, -3665.95, 183.078]),
] as const;
const acolyteGroups = Object.freeze(acolytePositions.map((position, index) => eventGroup(
`stratholme:ziggurat-${index + 1}-acolyte`,
[eventSpawn(
`stratholme:ziggurat-${index + 1}-acolyte-actor`,
idsByEntry.get(10_415)!,
position,
)],
)));
const ramsteinGroup = eventGroup("stratholme:ramstein", [
eventSpawn(
"stratholme:ramstein-the-gorger",
idsByEntry.get(10_439)!,
stratholmeSourcePosition([4032.2, -3378.06, 119.75]),
4.67,
),
]);
const mindlessCenter = stratholmeSourcePosition([3941.75, -3393.06, 119.7]);
const mindlessGroup = eventGroup("stratholme:mindless-undead", Array.from({ length: 33 }, (_, index) => {
const ring = Math.floor(index / 11) + 1;
const angle = (index % 11) * ((Math.PI * 2) / 11) + ring * 0.23;
const radius = ring * 2.25;
return eventSpawn(
`stratholme:mindless-undead-${index + 1}`,
idsByEntry.get(11_030)!,
[
mindlessCenter[0] + Math.cos(angle) * radius,
mindlessCenter[1],
mindlessCenter[2] + Math.sin(angle) * radius,
],
);
}));
const blackGuardDestinations = [
[4032.73, -3407.38, 115.56],
[4034.73, -3405.38, 115.56],
[4034.73, -3409.38, 115.56],
[4030.73, -3405.38, 115.56],
[4030.73, -3409.38, 115.56],
] as const;
const blackGuardGroup = eventGroup("stratholme:black-guards", blackGuardDestinations.map((position, index) => eventSpawn(
`stratholme:black-guard-${index + 1}`,
idsByEntry.get(10_394)!,
stratholmeSourcePosition(position),
)));
const rivendareGroup = eventGroup("stratholme:baron-rivendare", [rivendareSpawn]);
const slaughterStaticIds = [
"creature-53969", "creature-54002", "creature-54018", "creature-54019",
"creature-54020", "creature-54021", "creature-54022", "creature-54026", "creature-54027",
"creature-54039", "creature-54040", "creature-54041", "creature-54050",
] as const;
for (const spawnId of slaughterStaticIds) {
if (!definition.staticSpawns.some((spawn) => spawn.id === spawnId)) {
throw new Error(`${definition.id} is missing reviewed slaughter spawn ${spawnId}`);
}
}
const rules: DungeonEncounterRule[] = [];
zigguratBossSpawns.forEach((bossSpawn, index) => {
const number = index + 1;
const group = acolyteGroups[index];
rules.push({
id: `ziggurat-${number}-boss-defeated`,
trigger: { kind: "entity-death", entityId: bossSpawn.id },
guards: [{ kind: "flag", flagId: `ziggurat-${number}-started`, equals: false }],
actions: [
{ id: `record-ziggurat-${number}-started`, kind: "set-flag", flagId: `ziggurat-${number}-started`, value: true, persistence: "run" },
{ id: `open-ziggurat-${number}-door`, kind: "set-world-object-state", objectId: zigguratDoors[index].id, state: "open", persistence: "run" },
{ id: `activate-ziggurat-${number}-acolyte`, kind: "activate-spawn-group", spawnGroupId: group.id },
],
});
rules.push({
id: `ziggurat-${number}-acolyte-defeated`,
trigger: { kind: "entity-death", entityId: group.spawns[0].id },
guards: [{ kind: "flag", flagId: `ziggurat-${number}-complete`, equals: false }],
actions: [
{ id: `deactivate-ziggurat-${number}-acolyte`, kind: "deactivate-spawn-group", spawnGroupId: group.id },
{ id: `record-ziggurat-${number}-complete`, kind: "set-flag", flagId: `ziggurat-${number}-complete`, value: true, persistence: "run" },
{ id: `checkpoint-ziggurat-${number}`, kind: "set-checkpoint", checkpointId: `stratholme-ziggurat-${number}`, persistence: "checkpoint" },
],
});
});
rules.push(
{
id: "open-slaughter-square",
trigger: { kind: "tick" },
guards: [
{ kind: "flag", flagId: "ziggurat-1-complete", equals: true },
{ kind: "flag", flagId: "ziggurat-2-complete", equals: true },
{ kind: "flag", flagId: "ziggurat-3-complete", equals: true },
{ kind: "flag", flagId: "ziggurats-complete", equals: false },
],
actions: [
{ id: "record-ziggurats-complete", kind: "set-flag", flagId: "ziggurats-complete", value: true, persistence: "run" },
{ id: "open-gauntlet-gate", kind: "set-world-object-state", objectId: gauntletGate.id, state: "open", persistence: "run" },
{ id: "open-slaughter-gate", kind: "set-world-object-state", objectId: slaughterGate.id, state: "open", persistence: "run" },
{ id: "checkpoint-ziggurats-complete", kind: "set-checkpoint", checkpointId: "stratholme-ziggurats", persistence: "checkpoint" },
],
},
{
id: "slaughter-pack-defeated",
trigger: { kind: "entities-dead", entityIds: slaughterStaticIds },
guards: [
{ kind: "flag", flagId: "ziggurats-complete", equals: true },
{ kind: "flag", flagId: "slaughter-started", equals: false },
],
actions: [
{ id: "record-slaughter-started", kind: "set-flag", flagId: "slaughter-started", value: true, persistence: "run" },
{ id: "activate-ramstein", kind: "activate-spawn-group", spawnGroupId: ramsteinGroup.id },
{ id: "enter-ramstein", kind: "set-phase", phase: "ramstein-active" },
],
},
{
id: "ramstein-defeated",
trigger: { kind: "entity-death", entityId: ramsteinGroup.spawns[0].id },
guards: [{ kind: "flag", flagId: "ramstein-defeated", equals: false }],
actions: [
{ id: "deactivate-ramstein", kind: "deactivate-spawn-group", spawnGroupId: ramsteinGroup.id },
{ id: "record-ramstein-defeated", kind: "set-flag", flagId: "ramstein-defeated", value: true, persistence: "run" },
{ id: "open-slaughter-side-gate", kind: "set-world-object-state", objectId: slaughterSideGate.id, state: "open", persistence: "run" },
{ id: "checkpoint-ramstein", kind: "set-checkpoint", checkpointId: "stratholme-ramstein", persistence: "checkpoint" },
{ id: "enter-mindless-delay", kind: "set-phase", phase: "mindless-delay" },
],
},
{
id: "spawn-mindless-undead",
trigger: { kind: "elapsed", afterMs: 5_000 },
guards: [{ kind: "phase", phases: ["mindless-delay"] }],
actions: [
{ id: "activate-mindless-undead", kind: "activate-spawn-group", spawnGroupId: mindlessGroup.id },
{ id: "enter-mindless-undead", kind: "set-phase", phase: "mindless-active" },
],
},
{
id: "mindless-undead-defeated",
trigger: { kind: "entities-dead", entityIds: mindlessGroup.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [
{ kind: "phase", phases: ["mindless-active"] },
{ kind: "flag", flagId: "mindless-defeated", equals: false },
],
actions: [
{ id: "deactivate-mindless-undead", kind: "deactivate-spawn-group", spawnGroupId: mindlessGroup.id },
{ id: "record-mindless-defeated", kind: "set-flag", flagId: "mindless-defeated", value: true, persistence: "run" },
{ id: "checkpoint-mindless-undead", kind: "set-checkpoint", checkpointId: "stratholme-mindless", persistence: "checkpoint" },
{ id: "enter-black-guard-delay", kind: "set-phase", phase: "black-guard-delay" },
],
},
{
id: "spawn-black-guards",
trigger: { kind: "elapsed", afterMs: 20_000 },
guards: [{ kind: "phase", phases: ["black-guard-delay"] }],
actions: [
{ id: "open-slaughter-door-4", kind: "set-world-object-state", objectId: slaughterDoor4.id, state: "open", persistence: "run" },
{ id: "activate-black-guards", kind: "activate-spawn-group", spawnGroupId: blackGuardGroup.id },
{ id: "enter-black-guards", kind: "set-phase", phase: "black-guards-active" },
],
},
{
id: "black-guards-defeated",
trigger: { kind: "entities-dead", entityIds: blackGuardGroup.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [
{ kind: "phase", phases: ["black-guards-active"] },
{ kind: "flag", flagId: "guards-defeated", equals: false },
],
actions: [
{ id: "deactivate-black-guards", kind: "deactivate-spawn-group", spawnGroupId: blackGuardGroup.id },
{ id: "record-black-guards-defeated", kind: "set-flag", flagId: "guards-defeated", value: true, persistence: "run" },
{ id: "open-baron-door", kind: "set-world-object-state", objectId: baronDoor.id, state: "open", persistence: "run" },
{ id: "activate-baron-rivendare", kind: "activate-spawn-group", spawnGroupId: rivendareGroup.id },
{ id: "checkpoint-black-guards", kind: "set-checkpoint", checkpointId: "stratholme-black-guards", persistence: "checkpoint" },
{ id: "enter-baron-ready", kind: "set-phase", phase: "baron-ready" },
],
},
{
id: "baron-rivendare-engaged",
trigger: { kind: "entity-engaged", entityId: rivendareSpawn.id },
guards: [{ kind: "phase", phases: ["baron-ready"] }],
actions: [
{ id: "close-baron-door", kind: "set-world-object-state", objectId: baronDoor.id, state: "closed" },
{ id: "enter-baron-active", kind: "set-phase", phase: "baron-active" },
],
},
{
id: "baron-rivendare-defeated",
trigger: { kind: "entity-death", entityId: rivendareSpawn.id },
guards: [{ kind: "phase", phases: ["baron-ready", "baron-active"] }],
actions: [
{ id: "deactivate-baron-rivendare", kind: "deactivate-spawn-group", spawnGroupId: rivendareGroup.id },
{ id: "complete-baron-rivendare", kind: "complete-objective", objectiveId: rivendareObjective.id, persistence: "run" },
{ id: "persist-baron-door-open", kind: "set-world-object-state", objectId: baronDoor.id, state: "open", persistence: "run" },
{ id: "checkpoint-baron-rivendare", kind: "set-checkpoint", checkpointId: rivendareObjective.id, persistence: "checkpoint" },
{ id: "leave-baron-rivendare", kind: "set-phase", phase: "idle" },
],
},
);
rules.push({
id: "recover-baron-rivendare",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["recovery"] },
{ kind: "flag", flagId: "guards-defeated", equals: true },
{ kind: "objective-complete", objectiveId: rivendareObjective.id, equals: false },
{ kind: "spawn-group-active", spawnGroupId: rivendareGroup.id, equals: false },
],
actions: [
{ id: "recover-open-baron-door", kind: "set-world-object-state", objectId: baronDoor.id, state: "open" },
{ id: "recover-activate-baron-rivendare", kind: "activate-spawn-group", spawnGroupId: rivendareGroup.id },
{ id: "recover-enter-baron-ready", kind: "set-phase", phase: "baron-ready" },
],
priority: 50,
});
rules.push({
id: "recover-black-guards",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["recovery"] },
{ kind: "flag", flagId: "mindless-defeated", equals: true },
{ kind: "flag", flagId: "guards-defeated", equals: false },
],
actions: [{ id: "recover-enter-black-guard-delay", kind: "set-phase", phase: "black-guard-delay" }],
priority: 40,
});
rules.push({
id: "recover-mindless-undead",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["recovery"] },
{ kind: "flag", flagId: "ramstein-defeated", equals: true },
{ kind: "flag", flagId: "mindless-defeated", equals: false },
],
actions: [{ id: "recover-enter-mindless-delay", kind: "set-phase", phase: "mindless-delay" }],
priority: 30,
});
rules.push({
id: "recover-ramstein",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["recovery"] },
{ kind: "flag", flagId: "slaughter-started", equals: true },
{ kind: "flag", flagId: "ramstein-defeated", equals: false },
{ kind: "spawn-group-active", spawnGroupId: ramsteinGroup.id, equals: false },
],
actions: [
{ id: "recover-activate-ramstein", kind: "activate-spawn-group", spawnGroupId: ramsteinGroup.id },
{ id: "recover-enter-ramstein", kind: "set-phase", phase: "ramstein-active" },
],
priority: 20,
});
acolyteGroups.forEach((group, index) => {
const number = index + 1;
rules.push({
id: `recover-ziggurat-${number}-acolyte`,
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["recovery"] },
{ kind: "flag", flagId: `ziggurat-${number}-started`, equals: true },
{ kind: "flag", flagId: `ziggurat-${number}-complete`, equals: false },
{ kind: "spawn-group-active", spawnGroupId: group.id, equals: false },
],
actions: [{ id: `recover-activate-ziggurat-${number}-acolyte`, kind: "activate-spawn-group", spawnGroupId: group.id }],
priority: 10,
});
});
rules.push({
id: "finish-stratholme-recovery",
trigger: { kind: "tick" },
guards: [{ kind: "phase", phases: ["recovery"] }],
actions: [{ id: "return-stratholme-idle", kind: "set-phase", phase: "idle" }],
priority: -10,
});
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "stratholme:classic-batch-two",
dungeonId: definition.id,
initialPhase: "idle",
initialState: {
flags: {
"ziggurat-1-started": false,
"ziggurat-2-started": false,
"ziggurat-3-started": false,
"ziggurat-1-complete": false,
"ziggurat-2-complete": false,
"ziggurat-3-complete": false,
"ziggurats-complete": false,
"slaughter-started": false,
"ramstein-defeated": false,
"mindless-defeated": false,
"guards-defeated": false,
},
worldObjectStates: Object.fromEntries([
...zigguratDoors.map((door) => [door.id, "closed"] as const),
[gauntletGate.id, "closed"],
[slaughterGate.id, "closed"],
[slaughterDoor4.id, "closed"],
[baronDoor.id, "closed"],
[slaughterSideGate.id, "closed"],
]),
},
resetPolicy: { bossWipePhase: "recovery", checkpointPhase: "recovery" },
source: sourceReview(definition),
rules: Object.freeze(rules),
});
return Object.freeze({
...definition,
entities,
...eventObjectives,
staticSpawns: removeStaticSpawnIds(definition, new Set([rivendareSpawn.id])),
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []),
...acolyteGroups,
ramsteinGroup,
mindlessGroup,
blackGuardGroup,
rivendareGroup,
]),
serverGameObjects: replaceObjects(definition, [
...zigguratDoors,
gauntletGate,
slaughterGate,
slaughterDoor4,
baronDoor,
slaughterSideGate,
]),
validation: appendValidationWarnings(definition, [
"Stratholme now runs three source-bound ziggurat acolytes and the complete 13-to-Ramstein-to-33-to-5-to-Rivendare slaughter chain with checkpoint recovery. Timed run, movement, dialogue, traps, loot, and wing-level completion semantics remain documented gaps.",
]),
});
}
export function withWowClassicEncounterBatchTwo(definition: DungeonDefinition): DungeonDefinition {
const encounterId = `${definition.id}:classic-batch-two`;
if (definition.encounters?.some((encounter) => encounter.id === encounterId)) return definition;
if (definition.id === "sunken-temple") return buildSunkenTemple(definition);
if (definition.id === "blackrock-depths") return buildBlackrockDepths(definition);
if (definition.id === "scholomance") return buildScholomance(definition);
if (definition.id === "stratholme") return buildStratholme(definition);
return definition;
}