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