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