2155 lines
98 KiB
TypeScript
2155 lines
98 KiB
TypeScript
import type {
|
|
DungeonBossObjective,
|
|
DungeonDefinition,
|
|
DungeonEncounterSpawnGroupDefinition,
|
|
Vector3Tuple,
|
|
} from "./dungeonTypes";
|
|
import type {
|
|
DungeonEncounterAction,
|
|
DungeonEncounterDefinition,
|
|
DungeonEncounterRule,
|
|
} from "./dungeonEncounterTypes";
|
|
import type { DungeonWorldObjectDefinition } from "./dungeonWorldObjects";
|
|
import type {
|
|
PopulationDefinitionMap,
|
|
PopulationEntityDefinition,
|
|
StaticMobSpawnDefinition,
|
|
} from "./mobPopulation";
|
|
|
|
export interface WowEncounterSourceFile {
|
|
readonly path: string;
|
|
readonly sha256: string;
|
|
readonly anchors: readonly string[];
|
|
}
|
|
|
|
export interface WowEncounterBossMapping {
|
|
readonly entry: number;
|
|
readonly name: string;
|
|
readonly sourceSemantics: string;
|
|
readonly healerManSemantics: string;
|
|
}
|
|
|
|
export interface WowEncounterContentReview {
|
|
readonly dungeonId: string;
|
|
readonly fidelity: "source-backed-adapted";
|
|
readonly sourceFiles: readonly WowEncounterSourceFile[];
|
|
readonly bossMappings: readonly WowEncounterBossMapping[];
|
|
readonly reviewedBehavior: string;
|
|
readonly adaptations: readonly string[];
|
|
readonly remainingGaps: readonly string[];
|
|
}
|
|
|
|
export interface WowEncounterDeferredAdapter {
|
|
readonly dungeonId: string;
|
|
readonly sourceFiles: readonly string[];
|
|
readonly nextAdapter: string;
|
|
readonly runtimeGap: 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 BLACK_MORASS_FILES = Object.freeze([
|
|
`${SOURCE_ROOT}/Kalimdor/CavernsOfTime/TheBlackMorass/instance_the_black_morass.cpp`,
|
|
`${SOURCE_ROOT}/Kalimdor/CavernsOfTime/TheBlackMorass/the_black_morass.cpp`,
|
|
`${SOURCE_ROOT}/Kalimdor/CavernsOfTime/TheBlackMorass/the_black_morass.h`,
|
|
]);
|
|
|
|
const VIOLET_HOLD_FILES = Object.freeze([
|
|
`${SOURCE_ROOT}/Northrend/VioletHold/instance_violet_hold.cpp`,
|
|
`${SOURCE_ROOT}/Northrend/VioletHold/violet_hold.cpp`,
|
|
`${SOURCE_ROOT}/Northrend/VioletHold/violet_hold.h`,
|
|
]);
|
|
|
|
const CULLING_FILES = Object.freeze([
|
|
`${SOURCE_ROOT}/Kalimdor/CavernsOfTime/CullingOfStratholme/culling_of_stratholme.cpp`,
|
|
`${SOURCE_ROOT}/Kalimdor/CavernsOfTime/CullingOfStratholme/culling_of_stratholme.h`,
|
|
]);
|
|
|
|
const DURNHOLDE_FILES = Object.freeze([
|
|
`${SOURCE_ROOT}/Kalimdor/CavernsOfTime/EscapeFromDurnholdeKeep/instance_old_hillsbrad.cpp`,
|
|
`${SOURCE_ROOT}/Kalimdor/CavernsOfTime/EscapeFromDurnholdeKeep/old_hillsbrad.cpp`,
|
|
`${SOURCE_ROOT}/Kalimdor/CavernsOfTime/EscapeFromDurnholdeKeep/old_hillsbrad.h`,
|
|
]);
|
|
|
|
const ARCATRAZ_FILES = Object.freeze([
|
|
`${SOURCE_ROOT}/Outland/TempestKeep/arcatraz/arcatraz.cpp`,
|
|
`${SOURCE_ROOT}/Outland/TempestKeep/arcatraz/arcatraz.h`,
|
|
]);
|
|
|
|
const BLACKROCK_SPIRE_FILES = Object.freeze([
|
|
`${SOURCE_ROOT}/EasternKingdoms/BlackrockMountain/BlackrockSpire/instance_blackrock_spire.cpp`,
|
|
`${SOURCE_ROOT}/EasternKingdoms/BlackrockMountain/BlackrockSpire/blackrock_spire.h`,
|
|
]);
|
|
|
|
/**
|
|
* The original deferred trio now lives in wowWrathEncounterContentCatalog.
|
|
* Keep this explicit empty ledger so a stale "next batch" cannot contradict
|
|
* the executable registry or the functionality audit.
|
|
*/
|
|
export const WOW_ENCOUNTER_NEXT_BATCH: readonly WowEncounterDeferredAdapter[] = Object.freeze([]);
|
|
|
|
export const WOW_ENCOUNTER_CONTENT_REVIEWS: readonly WowEncounterContentReview[] = Object.freeze([
|
|
{
|
|
dungeonId: "black-morass",
|
|
fidelity: "source-backed-adapted",
|
|
sourceFiles: [
|
|
{
|
|
path: BLACK_MORASS_FILES[0],
|
|
sha256: "a89db0207748acb461198d3b6539767880b15aef96a7a7f457ab42b3c37fbf37",
|
|
anchors: [
|
|
"PortalLocation (lines 24-30)",
|
|
"SetBossState boss pause (lines 95-175)",
|
|
"ScheduleNextPortal (lines 189-235)",
|
|
"OnCreatureRemove next-rift scheduling (lines 267-286)",
|
|
"DATA_MEDIVH starts the event (lines 303-316)",
|
|
],
|
|
},
|
|
{
|
|
path: BLACK_MORASS_FILES[1],
|
|
sha256: "25287098851d05ff713fecf8e554f34583b36039a59be67486fd8b71ee34479e",
|
|
anchors: [
|
|
"firstWave/secondWave/thirdWave (lines 48-50)",
|
|
"npc_time_rift reset and add schedule (lines 271-292)",
|
|
"DoSelectSummon (lines 313-348)",
|
|
"boss selection at rifts 6/12/18 (lines 367-391)",
|
|
],
|
|
},
|
|
{
|
|
path: BLACK_MORASS_FILES[2],
|
|
sha256: "9d25d617b19fa01aa39a65ed071d9cd149d40ba346e64079d8a0a9cef40b9985",
|
|
anchors: ["CreatureIds (lines 55-85)"],
|
|
},
|
|
],
|
|
bossMappings: [
|
|
{
|
|
entry: 17_879,
|
|
name: "Chrono Lord Deja",
|
|
sourceSemantics: "Summoned by the sixth time rift; the next rift is held for 150 seconds after victory.",
|
|
healerManSemantics: "Removed from static population and spawned only in deterministic rift wave 6, followed by the source 150-second pause.",
|
|
},
|
|
{
|
|
entry: 17_880,
|
|
name: "Temporus",
|
|
sourceSemantics: "Summoned by the twelfth time rift; the next rift is held for 150 seconds after victory.",
|
|
healerManSemantics: "Removed from static population and spawned only in deterministic rift wave 12, followed by the source 150-second pause.",
|
|
},
|
|
{
|
|
entry: 17_881,
|
|
name: "Aeonus",
|
|
sourceSemantics: "Summoned by the eighteenth time rift and ends the defense when defeated.",
|
|
healerManSemantics: "Removed from static population and spawned only in deterministic rift wave 18; victory completes the dungeon objective.",
|
|
},
|
|
],
|
|
reviewedBehavior: "An explicit start begins 18 ordered rifts. Source creature entries and portal centers are preserved, and bosses are gated to rifts 6, 12, and 18.",
|
|
adaptations: [
|
|
"A deterministic round-robin portal order replaces AzerothCore random selection while remaining a valid no-immediate-repeat source outcome.",
|
|
"Each rift is a finite keeper/add pack. AzerothCore's repeating 15-second add stream is bounded so the current static spawn-group runtime cannot create an unbounded lifecycle.",
|
|
"Random ten-metre summon points are fixed offsets around the exact four source portal centers.",
|
|
"Added source creature entries use conservative HealerMan melee/caster placeholders until their template stats, spells, and client models are imported.",
|
|
],
|
|
remainingGaps: [
|
|
"Medivh is not yet a friendly damageable actor, so corruption, shield percentage, shield failure, and attacker pathing to Medivh are not executable.",
|
|
"Time-rift channels, repeated add lifetimes, heroic already-defeated boss substitutes, dialogue, quest credit, and the outro remain unported.",
|
|
],
|
|
},
|
|
{
|
|
dungeonId: "violet-hold",
|
|
fidelity: "source-backed-adapted",
|
|
sourceFiles: [
|
|
{
|
|
path: VIOLET_HOLD_FILES[0],
|
|
sha256: "10baaf7673fc819a912892a956a9e2864127a11ef4064fb54db39a75f495a0a1",
|
|
anchors: [
|
|
"SetBossState boss-wave continuation (lines 138-188)",
|
|
"ACTION_START_INSTANCE/ACTION_PORTAL_DEFEATED (lines 200-219)",
|
|
"StartBossEncounter cell release (lines 278-345)",
|
|
"EVENT_SUMMON_PORTAL and boss selection (lines 405-437)",
|
|
"Cyanigosa transform/attack gate (lines 438-455)",
|
|
],
|
|
},
|
|
{
|
|
path: VIOLET_HOLD_FILES[1],
|
|
sha256: "432cb8b8bbe17bf43ff47c3b5e54142148f1b2de273daf3138c6af976e0f7153",
|
|
anchors: [
|
|
"npc_vh_teleportation_portal deterministic candidates (lines 28-128)",
|
|
"portal trash escort and seal attack (lines 145-280)",
|
|
"saboteur releases selected boss (lines 915-936)",
|
|
],
|
|
},
|
|
{
|
|
path: VIOLET_HOLD_FILES[2],
|
|
sha256: "f83b0145e6be2a6ce85d7ef15ef346d7b72b33ddb6817c73a7c3757997a85d4a",
|
|
anchors: [
|
|
"VHCreatures and six-boss pool (lines 60-135)",
|
|
"boss and portal positions (lines 187-219)",
|
|
],
|
|
},
|
|
],
|
|
bossMappings: [
|
|
...[
|
|
[29_316, "Moragg"],
|
|
[29_315, "Erekem"],
|
|
[29_313, "Ichoron"],
|
|
[29_312, "Lavanthor"],
|
|
[29_266, "Xevozz"],
|
|
[29_314, "Zuramat the Obliterator"],
|
|
].map(([entry, name]) => ({
|
|
entry: entry as number,
|
|
name: name as string,
|
|
sourceSemantics: "One of six static, non-attackable prisoners selected without replacement for wave 6 or 12, then released by a saboteur.",
|
|
healerManSemantics: entry === 29_315
|
|
? "Selected deterministically for wave 6 and released with both Erekem guards."
|
|
: entry === 29_316
|
|
? "Selected deterministically for wave 12."
|
|
: "Kept out of the deterministic baseline until its entity/template asset is imported; it is not fabricated as a static boss.",
|
|
})),
|
|
{
|
|
entry: 31_134,
|
|
name: "Cyanigosa",
|
|
sourceSemantics: "Summoned at wave 18, transformed after ten seconds, and made attackable 2.5 seconds later.",
|
|
healerManSemantics: "Removed from static population and spawned only at wave 18; the non-combat transformation presentation is shortened.",
|
|
},
|
|
],
|
|
reviewedBehavior: "An explicit Sinclari start drives 18 ordered waves, deterministic source trash choices, two distinct prisoner releases at waves 6/12, and Cyanigosa at wave 18.",
|
|
adaptations: [
|
|
"Erekem then Moragg is a deterministic valid selection from the source six-boss pool; the other four prisoners remain documented and are never fabricated as static objectives.",
|
|
"Portal locations and source trash entries are deterministic. Portal incubation and saboteur travel are collapsed into the outer 3/35-second wave pauses.",
|
|
"Keeper portals receive one bounded add pack rather than repeating adds every 20 seconds.",
|
|
"Reviewed main and prison doors use visible procedural geometry that blocks movement/sight while closed and becomes nonblocking when opened.",
|
|
"Added source creature entries use conservative HealerMan melee/caster placeholders until their template stats, spells, paths, and client models are imported.",
|
|
],
|
|
remainingGaps: [
|
|
"The prison seal is not yet a friendly damageable objective, so trash pathing, periodic seal damage, seal health, and the zero-health failure are not executable.",
|
|
"Defense crystals, portal channels, boss-specific saboteur routes, Cyanigosa's transformation presentation, dialogue, achievements, and full six-boss seeded selection remain unported.",
|
|
],
|
|
},
|
|
{
|
|
dungeonId: "culling-of-stratholme",
|
|
fidelity: "source-backed-adapted",
|
|
sourceFiles: [
|
|
{
|
|
path: CULLING_FILES[0],
|
|
sha256: "0a3194683ae2f6780165dba00451a7401a208dd34ff8de5f062f6fdd312dbaec",
|
|
anchors: [
|
|
"eight city-wave tables and five town-hall rifts (lines 163-293)",
|
|
"Arthas gossip progression (lines 295-335)",
|
|
"Epoch and final Mal'ganis attack gates (lines 1095-1153)",
|
|
"SummonNextWave/SendNextWave boss gates (lines 1229-1344)",
|
|
],
|
|
},
|
|
{
|
|
path: CULLING_FILES[1],
|
|
sha256: "403d8576076ebc7e043f40bfbf21305e6bb693c3033b273f4e95afce93e82904",
|
|
anchors: [
|
|
"boss creature entries (lines 49-61)",
|
|
"scripted boss and event positions (lines 145-180)",
|
|
],
|
|
},
|
|
],
|
|
bossMappings: [
|
|
[26_529, "Meathook", "Summoned after city trash wave 4.", "Spawned only as event wave 5."],
|
|
[26_530, "Salramm the Fleshcrafter", "Summoned after city trash wave 9.", "Spawned only as event wave 10."],
|
|
[26_532, "Chrono-Lord Epoch", "Summoned after five rifts across three town-hall stops, then made attackable after his intro.", "Spawned after three finite groups containing the five exact source rift packs."],
|
|
[26_533, "Mal'Ganis", "Activated after Arthas reaches the final escort leg.", "Spawned only after Epoch and the adapted final-leg pause."],
|
|
].map(([entry, name, sourceSemantics, healerManSemantics]) => ({
|
|
entry: entry as number,
|
|
name: name as string,
|
|
sourceSemantics: sourceSemantics as string,
|
|
healerManSemantics: healerManSemantics as string,
|
|
})),
|
|
reviewedBehavior: "The source city-wave compositions gate Meathook/Salramm, five town-hall rifts gate Epoch, and Epoch gates the final Mal'Ganis fight.",
|
|
adaptations: [
|
|
"Arthas gossip starts one deterministic ordered state machine; escort travel and scene pauses are represented by finite waits between exact source combat gates.",
|
|
"Five town-hall rifts are grouped at the source's one/two/two waypoint cadence, with their combatants preserved and the cosmetic rift creatures represented as world foci.",
|
|
"Event-only bosses use their scripted encounter positions rather than the compiler's fabricated static positions.",
|
|
],
|
|
remainingGaps: [
|
|
"Arthas is not yet a protected friendly escort, so waypoint travel, combat participation, gossip pauses between every act, escort death, and checkpoint relocation are approximated.",
|
|
"Civilian infection scenes, the timed Infinite Corruptor challenge, dialogue, chest/quest credit, and Mal'Ganis escape presentation remain unported.",
|
|
],
|
|
},
|
|
{
|
|
dungeonId: "escape-from-durnholde",
|
|
fidelity: "source-backed-adapted",
|
|
sourceFiles: [
|
|
{
|
|
path: DURNHOLDE_FILES[0],
|
|
sha256: "2fd7f2572e9759044ed9077d99824b7673f135a98db42710d6b01cc83e6220b3",
|
|
anchors: [
|
|
"Drake summon position (lines 25-39)",
|
|
"five-barrel threshold and 12-second finale (lines 154-161)",
|
|
"Lieutenant Drake summon (lines 217-246)",
|
|
],
|
|
},
|
|
{
|
|
path: DURNHOLDE_FILES[1],
|
|
sha256: "41f74fc2fc3ce9146cb70419d7ec645a9aeb0f4d88ed9a5723074cc531bcbe0c",
|
|
anchors: [
|
|
"Skarloc summon at Thrall waypoint 28 (lines 358-359)",
|
|
"Tarren Mill guard packs (lines 700-711)",
|
|
"Epoch summon, three add waves, and activation (lines 721-815)",
|
|
],
|
|
},
|
|
{
|
|
path: DURNHOLDE_FILES[2],
|
|
sha256: "ad56a3f77a49ee8c1ef3270cc1ab80b763e21eb12008b0d1880535e0b116b6cb",
|
|
anchors: ["boss, escort, and barrel entries (lines 27-67)"],
|
|
},
|
|
],
|
|
bossMappings: [
|
|
{
|
|
entry: 17_848,
|
|
name: "Lieutenant Drake",
|
|
sourceSemantics: "Summoned twelve seconds after the fifth incendiary barrel is planted.",
|
|
healerManSemantics: "Removed from static population and spawned at the exact source point after any five imported barrels are used.",
|
|
},
|
|
{
|
|
entry: 17_862,
|
|
name: "Captain Skarloc",
|
|
sourceSemantics: "Summoned when Thrall reaches escort waypoint 28.",
|
|
healerManSemantics: "Spawned only after Drake and an explicit Thrall escort interaction, at the exact source summon point.",
|
|
},
|
|
{
|
|
entry: 18_096,
|
|
name: "Epoch Hunter",
|
|
sourceSemantics: "Held immune while three add waves fight, then activated against Thrall's party.",
|
|
healerManSemantics: "Spawned only after the exact deterministic add-wave compositions are cleared.",
|
|
},
|
|
],
|
|
reviewedBehavior: "Any five source barrels gate Drake; Drake unlocks Thrall, Thrall gates Skarloc, and the Tarren Mill/Epoch add sequence gates Epoch Hunter.",
|
|
adaptations: [
|
|
"A Thrall interaction stands in for the waypoint escort segment between Drake and Skarloc.",
|
|
"Source add compositions and summon coordinates are retained as finite fight groups; travel time between escort stops is shortened.",
|
|
],
|
|
remainingGaps: [
|
|
"Thrall and Taretha are not protected friendly actors, so escort pathing, ambush motion, friendly combat, death/failure, and the source attempt counter remain unported.",
|
|
"Barrel flame chains, mounted travel, dialogue, disguises, quest credit, and Epoch's immunity/intro presentation remain unported.",
|
|
],
|
|
},
|
|
{
|
|
dungeonId: "arcatraz",
|
|
fidelity: "source-backed-adapted",
|
|
sourceFiles: [
|
|
{
|
|
path: ARCATRAZ_FILES[0],
|
|
sha256: "c462f5780463b0f837d79c0855c4ff856e13de0abf4e392e4c98d38d4cb746ec",
|
|
anchors: [
|
|
"deterministic candidate entries (lines 272-280)",
|
|
"pod release sequence and waits (lines 437-543)",
|
|
"Skyriss summon and attackability gate (lines 545-577)",
|
|
],
|
|
},
|
|
{
|
|
path: ARCATRAZ_FILES[1],
|
|
sha256: "845bd2d050ff15577f07a03794a13b57f5b4770452ce363cdbe130cc693720c3",
|
|
anchors: ["Warden data, creature ids, and five pod templates (lines 27-63)"],
|
|
},
|
|
],
|
|
bossMappings: [{
|
|
entry: 20_912,
|
|
name: "Harbinger Skyriss",
|
|
sourceSemantics: "Summoned only after Warden Mellichar releases four earlier pods, including three hostile prisoners and Millhouse Manastorm.",
|
|
healerManSemantics: "Removed from static population and spawned after three deterministic hostile prisoner fights plus the timed Millhouse presentation step.",
|
|
}],
|
|
reviewedBehavior: "Warden Mellichar opens all five imported pod objects in source order and gates Skyriss behind the three hostile prisoner deaths.",
|
|
adaptations: [
|
|
"The first/third/fourth random pods choose Trickster, Akkiris, and Twilight Drakonaar respectively, each a valid deterministic source branch.",
|
|
"Millhouse's friendly dialogue/combat-support segment is a timed pod transition because the runtime has no friendly encounter actor primitive.",
|
|
"Long non-combat speeches are shortened while preserving the source pod order and death gates.",
|
|
"Reviewed pods and Warden's Shield use visible procedural geometry with closed blocking/open nonblocking states.",
|
|
"Draft map provenance prevents a provably exact raw-coordinate transform, so imported pod and boss gameplay coordinates are retained.",
|
|
],
|
|
remainingGaps: [
|
|
"Millhouse support, Warden feign death, Skyriss' long intro/mind rend presentation, randomized alternate prisoners, dialogue, and quest credit remain unported.",
|
|
],
|
|
},
|
|
{
|
|
dungeonId: "blackrock-spire",
|
|
fidelity: "source-backed-adapted",
|
|
sourceFiles: [
|
|
{
|
|
path: BLACKROCK_SPIRE_FILES[0],
|
|
sha256: "0b3b2f51fa7acd6e17dfa5ec4fe4f50c9f582b730bdb2e781e8b3c44ee996ee1",
|
|
anchors: [
|
|
"five-wave count, 30-second cadence, source positions (lines 35-52)",
|
|
"wave cleanup/reset (lines 342-355)",
|
|
"guardian/hatcher and Solakar summons (lines 402-420)",
|
|
"Father Flame start hook (lines 714-727)",
|
|
],
|
|
},
|
|
{
|
|
path: BLACKROCK_SPIRE_FILES[1],
|
|
sha256: "40ae38809110acd1cf0e1f848e62cd81c0c3a1958a3c7c40c0b7afa7f475f8c1",
|
|
anchors: ["Solakar, Rookery Guardian, and Rookery Hatcher entries (lines 55, 86-88)"],
|
|
},
|
|
],
|
|
bossMappings: [{
|
|
entry: 10_264,
|
|
name: "Solakar Flamewreath",
|
|
sourceSemantics: "Father Flame starts five overlapping guardian/hatcher waves at 30-second intervals, then summons Solakar on the sixth event tick.",
|
|
healerManSemantics: "Removed from static population and spawned after the exact five-pair cadence; killing Solakar despawns any surviving event adds.",
|
|
}],
|
|
reviewedBehavior: "The imported Father Flame object starts five timed guardian/hatcher pairs and then the event-only Solakar fight.",
|
|
adaptations: [
|
|
"Draft map provenance prevents a provably exact raw-coordinate transform, so the imported Father Flame and objective-space coordinates anchor the event.",
|
|
"Source entries use conservative combat placeholders until their creature templates and client models are imported.",
|
|
],
|
|
remainingGaps: [
|
|
"Hatcher egg behavior, first-hatcher dialogue, original movement paths, and Solakar-specific spell AI remain unported.",
|
|
],
|
|
},
|
|
]);
|
|
|
|
const REVIEW_BY_DUNGEON = new Map(
|
|
WOW_ENCOUNTER_CONTENT_REVIEWS.map((review) => [review.dungeonId, review] as const),
|
|
);
|
|
|
|
function sourceMapPosition(source: readonly [x: number, y: number, z: number]): Vector3Tuple {
|
|
// The imported AzerothCore packages record this map-space transform in provenance.
|
|
return [source[1], source[2], source[0]];
|
|
}
|
|
|
|
function assertSourceMapTransform(definition: DungeonDefinition): void {
|
|
if (definition.provenance.coordinateTransform !== "three(x,y,z)=(wow.y,wow.z,wow.x)") {
|
|
throw new Error(
|
|
`WoW encounter content for ${definition.id} requires three(x,y,z)=(wow.y,wow.z,wow.x), got ${definition.provenance.coordinateTransform}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function adaptedEntityId(entry: number): string {
|
|
return `wow-event-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.08,
|
|
damageMultiplier: 0.92,
|
|
moveSpeed: 3.65,
|
|
aggroRange: 32,
|
|
leashRange: 85,
|
|
attacks: [
|
|
{
|
|
id: "basic-attack",
|
|
name: caster ? "Source Spell Proxy" : "Basic Attack",
|
|
delivery: caster ? "projectile" : "melee",
|
|
target: "primary",
|
|
school: caster ? "arcane" : "physical",
|
|
animation: caster ? "cast" : "attack",
|
|
range: caster ? 25 : 4.5,
|
|
cooldownMs: caster ? 3_000 : 2_000,
|
|
damageMultiplier: caster ? 0.8 : 1,
|
|
},
|
|
],
|
|
},
|
|
visual: {
|
|
primaryColor: caster ? "#4d5f82" : "#61434d",
|
|
accentColor: caster ? "#98c9ef" : "#d49a88",
|
|
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 adapted entity ${entity.id}`);
|
|
entities[entity.id] = entity;
|
|
idsByEntry.set(spec.entry, entity.id);
|
|
}
|
|
return { entities: Object.freeze(entities), idsByEntry };
|
|
}
|
|
|
|
function entityEntry(definition: DungeonDefinition, spawn: StaticMobSpawnDefinition): number | undefined {
|
|
return definition.entities[spawn.entityId]?.combat?.serverEntry;
|
|
}
|
|
|
|
function objectiveForEntry(
|
|
definition: DungeonDefinition,
|
|
entry: number,
|
|
names: readonly string[],
|
|
): DungeonBossObjective {
|
|
const spawnIds = new Set(
|
|
definition.staticSpawns
|
|
.filter((spawn) => entityEntry(definition, spawn) === entry)
|
|
.map((spawn) => spawn.id),
|
|
);
|
|
const normalizedNames = new Set(names.map((name) => name.toLowerCase()));
|
|
const objective = definition.bosses.find((boss) => (
|
|
spawnIds.has(boss.id) || normalizedNames.has(boss.name.toLowerCase())
|
|
));
|
|
if (!objective) throw new Error(`${definition.id} is missing the boss objective for creature entry ${entry}`);
|
|
return objective;
|
|
}
|
|
|
|
function spawnAt(
|
|
id: string,
|
|
entityId: string,
|
|
center: Vector3Tuple,
|
|
offset: readonly [x: number, z: number],
|
|
): StaticMobSpawnDefinition {
|
|
const spawn: StaticMobSpawnDefinition = {
|
|
id,
|
|
entityId,
|
|
position: [center[0] + offset[0], center[1], center[2] + offset[1]],
|
|
yaw: 0,
|
|
spawnMask: 3,
|
|
};
|
|
return Object.freeze(spawn);
|
|
}
|
|
|
|
const PACK_OFFSETS = Object.freeze([
|
|
[6, 0],
|
|
[-3, 5.2],
|
|
[-3, -5.2],
|
|
[0, 0],
|
|
[4.5, 4.5],
|
|
] as const);
|
|
|
|
function eventFocusObject(
|
|
id: string,
|
|
name: string,
|
|
position: Vector3Tuple,
|
|
sourceFiles: readonly string[],
|
|
color: string,
|
|
): DungeonWorldObjectDefinition {
|
|
const object: DungeonWorldObjectDefinition = {
|
|
id,
|
|
name,
|
|
kind: "trigger",
|
|
position,
|
|
halfExtents: [1.4, 2, 1.4],
|
|
initialState: "hidden",
|
|
states: [
|
|
{ id: "hidden", label: "Dormant", visible: false, blocksMovement: false, interactionEnabled: false },
|
|
{ id: "active", label: "Active", visible: true, blocksMovement: false, interactionEnabled: false },
|
|
],
|
|
visual: { color, emissive: color, scale: [1.4, 2.4, 1.4] },
|
|
source: {
|
|
fidelity: "source-backed-adapted",
|
|
files: sourceFiles,
|
|
note: "The source position/state is retained; HealerMan uses a procedural focus until the client effect and dynamic creature lifecycle are available.",
|
|
},
|
|
};
|
|
return Object.freeze(object);
|
|
}
|
|
|
|
function startObject(
|
|
id: string,
|
|
name: string,
|
|
prompt: string,
|
|
position: Vector3Tuple,
|
|
signalId: string,
|
|
sourceFiles: readonly string[],
|
|
): DungeonWorldObjectDefinition {
|
|
const object: DungeonWorldObjectDefinition = {
|
|
id,
|
|
name,
|
|
kind: "npc",
|
|
position,
|
|
halfExtents: [0.9, 1.8, 0.9],
|
|
initialState: "ready",
|
|
states: [
|
|
{ id: "hidden", label: "Unavailable", visible: false, blocksMovement: false, interactionEnabled: false },
|
|
{ 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 },
|
|
],
|
|
interaction: {
|
|
prompt,
|
|
range: 3,
|
|
signalId,
|
|
allowedStates: ["ready"],
|
|
oneShot: true,
|
|
},
|
|
visual: { color: "#a98bd4", emissive: "#302044", scale: [0.9, 1.8, 0.9] },
|
|
source: {
|
|
fidelity: "source-backed-adapted",
|
|
files: sourceFiles,
|
|
note: "The interaction represents the source proximity/gossip start; its compact procedural body is a HealerMan presentation adapter.",
|
|
},
|
|
};
|
|
return Object.freeze(object);
|
|
}
|
|
|
|
function sourceReview(
|
|
review: WowEncounterContentReview,
|
|
scope: "boss-progression" | "instance-orchestration" = "instance-orchestration",
|
|
): DungeonEncounterDefinition["source"] {
|
|
return {
|
|
fidelity: review.fidelity,
|
|
scope,
|
|
files: review.sourceFiles.map((file) => `${file.path} (sha256 ${file.sha256})`),
|
|
reviewedBehavior: review.reviewedBehavior,
|
|
adaptations: review.adaptations,
|
|
remainingGaps: review.remainingGaps,
|
|
};
|
|
}
|
|
|
|
function withoutBaselineBossProgression(
|
|
definition: DungeonDefinition,
|
|
): readonly DungeonEncounterDefinition[] {
|
|
return (definition.encounters ?? []).filter((encounter) => (
|
|
encounter.source?.scope !== "boss-progression"
|
|
&& encounter.id !== `${definition.id}:boss-progression`
|
|
));
|
|
}
|
|
|
|
function appendValidationWarnings(
|
|
definition: DungeonDefinition,
|
|
warnings: readonly string[],
|
|
): DungeonDefinition["validation"] {
|
|
return Object.freeze({
|
|
...definition.validation,
|
|
warnings: Object.freeze([...new Set([...definition.validation.warnings, ...warnings])]),
|
|
});
|
|
}
|
|
|
|
const BLACK_MORASS_PORTALS = Object.freeze([
|
|
sourceMapPosition([-2030.8318, 7024.9443, 23.071817]),
|
|
sourceMapPosition([-1961.7335, 7029.528, 21.811401]),
|
|
sourceMapPosition([-1887.695, 7106.557, 22.0495]),
|
|
sourceMapPosition([-1930.9106, 7183.597, 23.007639]),
|
|
]);
|
|
|
|
const BLACK_MORASS_MOBS = Object.freeze([
|
|
{ entry: 21_104, name: "Rift Keeper (Warlock)", level: 70, role: "caster" },
|
|
{ entry: 21_148, name: "Rift Keeper (Mage)", level: 70, role: "caster" },
|
|
{ entry: 17_839, name: "Rift Lord", level: 70, role: "melee" },
|
|
{ entry: 21_140, name: "Rift Lord", level: 70, role: "melee" },
|
|
{ entry: 17_835, name: "Infinite Assassin", level: 70, role: "melee" },
|
|
{ entry: 21_818, name: "Infinite Whelp", level: 68, role: "melee" },
|
|
{ entry: 17_892, name: "Infinite Chronomancer", level: 70, role: "caster" },
|
|
{ entry: 18_994, name: "Infinite Executioner", level: 70, role: "melee" },
|
|
{ entry: 18_995, name: "Infinite Vanquisher", level: 70, role: "melee" },
|
|
] as const satisfies readonly AdaptedMobSpec[]);
|
|
|
|
const BLACK_MORASS_KEEPERS = Object.freeze([21_104, 21_148, 17_839, 21_140] as const);
|
|
const BLACK_MORASS_PHASE_ADDS = Object.freeze([
|
|
[17_835, 21_818, 17_892],
|
|
[18_994, 17_892, 21_818, 17_835],
|
|
[18_994, 18_995, 17_892, 17_835],
|
|
] as const);
|
|
|
|
function blackMorassAddEntries(rift: number): readonly number[] {
|
|
const phase = rift < 6 ? 0 : rift < 12 ? 1 : 2;
|
|
const sequence = BLACK_MORASS_PHASE_ADDS[phase];
|
|
const first = sequence[(rift - 1) % sequence.length];
|
|
if (first === 21_818) return [first, first, first];
|
|
return [first, sequence[rift % sequence.length]];
|
|
}
|
|
|
|
function buildBlackMorass(definition: DungeonDefinition): DungeonDefinition {
|
|
assertSourceMapTransform(definition);
|
|
const review = REVIEW_BY_DUNGEON.get(definition.id)!;
|
|
const { entities, idsByEntry } = ensureAdaptedMobs(definition, BLACK_MORASS_MOBS);
|
|
const bossEntries = [17_879, 17_880, 17_881] as const;
|
|
const bossRifts = new Map<number, number>([[6, 17_879], [12, 17_880], [18, 17_881]]);
|
|
const objectiveByEntry = new Map<number, DungeonBossObjective>(bossEntries.map((entry) => [
|
|
entry,
|
|
objectiveForEntry(definition, entry, [
|
|
entry === 17_879 ? "Chrono Lord Deja" : entry === 17_880 ? "Temporus" : "Aeonus",
|
|
]),
|
|
] as const));
|
|
const bossEntityByEntry = new Map<number, string>(bossEntries.map((entry) => {
|
|
const id = findEntityIdByEntry(entities, entry);
|
|
if (!id) throw new Error(`${definition.id} is missing creature entry ${entry}`);
|
|
return [entry, id] as const;
|
|
}));
|
|
const portalObjects = BLACK_MORASS_PORTALS.map((position, index) => eventFocusObject(
|
|
`black-morass:rift-focus-${index + 1}`,
|
|
`Time Rift ${index + 1}`,
|
|
position,
|
|
BLACK_MORASS_FILES,
|
|
"#7553b8",
|
|
));
|
|
const startId = "black-morass:medivh-defense-start";
|
|
const start = startObject(
|
|
startId,
|
|
"Medivh Defense",
|
|
"Begin the Black Morass defense",
|
|
[7086.156575, 22.485089, -1952.792725],
|
|
"black-morass:start",
|
|
BLACK_MORASS_FILES,
|
|
);
|
|
const groups: DungeonEncounterSpawnGroupDefinition[] = [];
|
|
const rules: DungeonEncounterRule[] = [
|
|
{
|
|
id: "start-defense",
|
|
trigger: { kind: "interact", objectId: startId },
|
|
guards: [{ kind: "phase", phases: ["ready"] }],
|
|
actions: [
|
|
{ id: "mark-defense-active", kind: "set-world-object-state", objectId: startId, state: "active" },
|
|
{ id: "initialize-rift-counter", kind: "set-counter", counterId: "rift", value: 0 },
|
|
{ id: "wait-for-rift-01", kind: "set-phase", phase: "rift-01-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "resume-completed-defense",
|
|
trigger: { kind: "tick" },
|
|
guards: [
|
|
{ kind: "phase", phases: ["resume"] },
|
|
{ kind: "flag", flagId: "dungeon-complete", equals: true },
|
|
],
|
|
once: false,
|
|
priority: 20,
|
|
actions: [{ id: "resume-black-morass-complete", kind: "set-phase", phase: "complete" }],
|
|
},
|
|
{
|
|
id: "resume-after-temporus",
|
|
trigger: { kind: "tick" },
|
|
guards: [
|
|
{ kind: "phase", phases: ["resume"] },
|
|
{ kind: "objective-complete", objectiveId: objectiveByEntry.get(17_880)!.id, equals: true },
|
|
{ kind: "objective-complete", objectiveId: objectiveByEntry.get(17_881)!.id, equals: false },
|
|
],
|
|
once: false,
|
|
actions: [
|
|
{ id: "resume-after-temporus-active", kind: "set-world-object-state", objectId: startId, state: "active" },
|
|
{ id: "resume-after-temporus-counter", kind: "set-counter", counterId: "rift", value: 12 },
|
|
{ id: "resume-at-rift-13", kind: "set-phase", phase: "rift-13-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "resume-after-deja",
|
|
trigger: { kind: "tick" },
|
|
guards: [
|
|
{ kind: "phase", phases: ["resume"] },
|
|
{ kind: "objective-complete", objectiveId: objectiveByEntry.get(17_879)!.id, equals: true },
|
|
{ kind: "objective-complete", objectiveId: objectiveByEntry.get(17_880)!.id, equals: false },
|
|
],
|
|
once: false,
|
|
actions: [
|
|
{ id: "resume-after-deja-active", kind: "set-world-object-state", objectId: startId, state: "active" },
|
|
{ id: "resume-after-deja-counter", kind: "set-counter", counterId: "rift", value: 6 },
|
|
{ id: "resume-at-rift-07", kind: "set-phase", phase: "rift-07-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "resume-fresh-defense",
|
|
trigger: { kind: "tick" },
|
|
guards: [
|
|
{ kind: "phase", phases: ["resume"] },
|
|
{ kind: "objective-complete", objectiveId: objectiveByEntry.get(17_879)!.id, equals: false },
|
|
],
|
|
once: false,
|
|
actions: [{ id: "resume-black-morass-ready", kind: "set-phase", phase: "ready" }],
|
|
},
|
|
];
|
|
|
|
for (let rift = 1; rift <= 18; rift += 1) {
|
|
const suffix = String(rift).padStart(2, "0");
|
|
const groupId = `black-morass:rift-${suffix}`;
|
|
const portalIndex = (rift - 1) % BLACK_MORASS_PORTALS.length;
|
|
const portalId = portalObjects[portalIndex].id;
|
|
const center = BLACK_MORASS_PORTALS[portalIndex];
|
|
const bossEntry = bossRifts.get(rift);
|
|
const entries = bossEntry === 17_881
|
|
? []
|
|
: blackMorassAddEntries(rift);
|
|
const spawns: StaticMobSpawnDefinition[] = [];
|
|
if (bossEntry) {
|
|
const objective = objectiveByEntry.get(bossEntry)!;
|
|
spawns.push(spawnAt(objective.id, bossEntityByEntry.get(bossEntry)!, center, PACK_OFFSETS[0]));
|
|
} else {
|
|
const keeperEntry = BLACK_MORASS_KEEPERS[(rift - 1) % BLACK_MORASS_KEEPERS.length];
|
|
spawns.push(spawnAt(
|
|
`${groupId}:keeper`,
|
|
idsByEntry.get(keeperEntry)!,
|
|
center,
|
|
PACK_OFFSETS[0],
|
|
));
|
|
}
|
|
entries.forEach((entry, index) => spawns.push(spawnAt(
|
|
`${groupId}:add-${index + 1}`,
|
|
idsByEntry.get(entry)!,
|
|
center,
|
|
PACK_OFFSETS[(index + 1) % PACK_OFFSETS.length],
|
|
)));
|
|
groups.push(Object.freeze({ id: groupId, spawns: Object.freeze(spawns) }));
|
|
|
|
const priorWasBoss = rift === 7 || rift === 13;
|
|
const delayMs = rift === 1 ? 9_000 : priorWasBoss ? 156_000 : 7_000;
|
|
rules.push({
|
|
id: `open-rift-${suffix}`,
|
|
trigger: { kind: "elapsed", afterMs: delayMs },
|
|
guards: [{ kind: "phase", phases: [`rift-${suffix}-wait`] }],
|
|
actions: [
|
|
{ id: `show-rift-${suffix}`, kind: "set-world-object-state", objectId: portalId, state: "active" },
|
|
{ id: `activate-rift-${suffix}`, kind: "activate-spawn-group", spawnGroupId: groupId },
|
|
{ id: `record-rift-${suffix}`, kind: "set-counter", counterId: "rift", value: rift },
|
|
{ id: `enter-rift-${suffix}`, kind: "set-phase", phase: `rift-${suffix}-active` },
|
|
],
|
|
});
|
|
|
|
const clearActions: DungeonEncounterAction[] = [
|
|
{ id: `hide-rift-${suffix}`, kind: "set-world-object-state", objectId: portalId, state: "hidden" },
|
|
{ id: `deactivate-rift-${suffix}`, kind: "deactivate-spawn-group", spawnGroupId: groupId },
|
|
];
|
|
if (bossEntry) {
|
|
clearActions.push({
|
|
id: `complete-rift-boss-${suffix}`,
|
|
kind: "complete-objective",
|
|
objectiveId: objectiveByEntry.get(bossEntry)!.id,
|
|
persistence: "run",
|
|
});
|
|
if (rift === 6 || rift === 12) {
|
|
clearActions.push({
|
|
id: `checkpoint-after-rift-${suffix}`,
|
|
kind: "set-checkpoint",
|
|
checkpointId: rift === 6 ? "after-deja" : "after-temporus",
|
|
persistence: "run",
|
|
});
|
|
}
|
|
}
|
|
if (rift < 18) {
|
|
const next = String(rift + 1).padStart(2, "0");
|
|
clearActions.push({ id: `wait-for-rift-${next}`, kind: "set-phase", phase: `rift-${next}-wait` });
|
|
} else {
|
|
clearActions.push(
|
|
{ id: "mark-black-morass-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
|
{ id: "complete-medivh-defense", kind: "set-world-object-state", objectId: startId, state: "complete", persistence: "run" },
|
|
{ id: "enter-black-morass-complete", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
);
|
|
}
|
|
rules.push({
|
|
id: `clear-rift-${suffix}`,
|
|
trigger: { kind: "entities-dead", entityIds: spawns.map((spawn) => spawn.id) },
|
|
guards: [{ kind: "phase", phases: [`rift-${suffix}-active`] }],
|
|
actions: clearActions,
|
|
});
|
|
}
|
|
|
|
const encounter: DungeonEncounterDefinition = Object.freeze({
|
|
id: "black-morass:wow-instance",
|
|
dungeonId: definition.id,
|
|
initialPhase: "ready",
|
|
initialState: {
|
|
flags: { "dungeon-complete": false },
|
|
counters: { rift: 0 },
|
|
worldObjectStates: Object.fromEntries([
|
|
[startId, "ready"],
|
|
...portalObjects.map((object) => [object.id, "hidden"] as const),
|
|
]),
|
|
},
|
|
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
|
|
source: sourceReview(review, "instance-orchestration"),
|
|
rules: Object.freeze(rules),
|
|
});
|
|
const updatedBosses = definition.bosses.map((boss) => {
|
|
const match = [...objectiveByEntry.entries()].find(([, objective]) => objective.id === boss.id);
|
|
if (!match) return boss;
|
|
const [entry] = match;
|
|
const rift = entry === 17_879 ? 6 : entry === 17_880 ? 12 : 18;
|
|
const center = BLACK_MORASS_PORTALS[(rift - 1) % BLACK_MORASS_PORTALS.length];
|
|
return Object.freeze({ ...boss, position: spawnAt("position", "position", center, PACK_OFFSETS[0]).position, eventOnly: true });
|
|
});
|
|
const objectiveById = new Map(updatedBosses.map((boss) => [boss.id, boss] as const));
|
|
const gatedEntries = new Set<number>(bossEntries);
|
|
return Object.freeze({
|
|
...definition,
|
|
entities,
|
|
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => !gatedEntries.has(entityEntry(definition, spawn) ?? -1))),
|
|
bosses: Object.freeze(updatedBosses),
|
|
objectiveOrder: Object.freeze(definition.objectiveOrder.map((objective) => objectiveById.get(objective.id) ?? objective)),
|
|
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
|
|
encounterSpawnGroups: Object.freeze([
|
|
...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("black-morass:rift-")),
|
|
...groups,
|
|
]),
|
|
serverGameObjects: Object.freeze([
|
|
...definition.serverGameObjects,
|
|
start,
|
|
...portalObjects,
|
|
]),
|
|
validation: appendValidationWarnings(definition, [
|
|
"Black Morass bosses are source-gated to rifts 6/12/18; the bounded event is playable but Medivh shield failure is not yet implemented.",
|
|
]),
|
|
});
|
|
}
|
|
|
|
const VIOLET_HOLD_PORTALS = Object.freeze([
|
|
sourceMapPosition([1877.51, 850.104, 44.6599]),
|
|
sourceMapPosition([1918.37, 853.437, 47.1624]),
|
|
sourceMapPosition([1936.07, 803.198, 53.3749]),
|
|
sourceMapPosition([1927.61, 758.436, 51.4533]),
|
|
sourceMapPosition([1890.64, 753.471, 48.7224]),
|
|
sourceMapPosition([1908.31, 809.657, 38.7037]),
|
|
]);
|
|
const VIOLET_HOLD_CENTER_PORTAL = sourceMapPosition([1896.622925, 804.854126, 38.504772]);
|
|
const VIOLET_HOLD_MIDDLE = sourceMapPosition([1892.29126, 805.696838, 38.438862]);
|
|
const VIOLET_HOLD_BOSS_POSITIONS = Object.freeze({
|
|
erekem: sourceMapPosition([1875.17395, 860.832703, 43.333565]),
|
|
erekemGuard1: sourceMapPosition([1858.854614, 855.071411, 43.333565]),
|
|
erekemGuard2: sourceMapPosition([1891.926636, 863.388977, 43.333565]),
|
|
moragg: sourceMapPosition([1894.684448, 739.390503, 47.668003]),
|
|
});
|
|
const VIOLET_HOLD_PORTAL_SEQUENCE = Object.freeze([0, 2, 4, 1, 3, 5] as const);
|
|
|
|
const VIOLET_HOLD_MOBS = Object.freeze([
|
|
{ entry: 30_660, name: "Portal Guardian", level: 77, role: "melee" },
|
|
{ entry: 30_695, name: "Portal Keeper", level: 77, role: "caster" },
|
|
{ entry: 30_893, name: "Portal Keeper", level: 77, role: "caster" },
|
|
{ entry: 30_661, name: "Azure Invader", level: 76, role: "melee" },
|
|
{ entry: 30_961, name: "Azure Invader", level: 80, role: "melee" },
|
|
{ entry: 30_662, name: "Azure Spellbreaker", level: 76, role: "melee" },
|
|
{ entry: 30_962, name: "Azure Spellbreaker", level: 80, role: "melee" },
|
|
{ entry: 30_663, name: "Azure Binder", level: 76, role: "caster" },
|
|
{ entry: 30_918, name: "Azure Binder", level: 80, role: "caster" },
|
|
{ entry: 30_664, name: "Azure Mage Slayer", level: 76, role: "melee" },
|
|
{ entry: 30_963, name: "Azure Mage Slayer", level: 80, role: "melee" },
|
|
{ entry: 30_666, name: "Azure Captain", level: 77, role: "melee" },
|
|
{ entry: 30_667, name: "Azure Sorceror", level: 77, role: "caster" },
|
|
{ entry: 30_668, name: "Azure Raider", level: 77, role: "melee" },
|
|
{ entry: 32_191, name: "Azure Stalker", level: 77, role: "melee" },
|
|
] as const satisfies readonly AdaptedMobSpec[]);
|
|
|
|
function objectIdsForTemplate(definition: DungeonDefinition, templateId: number): readonly string[] {
|
|
return definition.serverGameObjects
|
|
.filter((object) => object.source?.templateId === templateId)
|
|
.map((object) => object.id);
|
|
}
|
|
|
|
function objectStateActions(
|
|
actionPrefix: string,
|
|
objectIds: readonly string[],
|
|
state: string,
|
|
): DungeonEncounterAction[] {
|
|
return objectIds.map((objectId, index) => ({
|
|
id: `${actionPrefix}-${index + 1}`,
|
|
kind: "set-world-object-state",
|
|
objectId,
|
|
state,
|
|
}));
|
|
}
|
|
|
|
function reviewedGateFallback(
|
|
object: DungeonWorldObjectDefinition,
|
|
sourceFiles: readonly string[],
|
|
note: string,
|
|
): DungeonWorldObjectDefinition {
|
|
const states = object.states.map((state) => {
|
|
if (state.id === "closed") {
|
|
return Object.freeze({
|
|
...state,
|
|
visible: true,
|
|
blocksMovement: true,
|
|
blocksSight: true,
|
|
interactionEnabled: false,
|
|
});
|
|
}
|
|
if (state.id === "open") {
|
|
return Object.freeze({
|
|
...state,
|
|
visible: true,
|
|
blocksMovement: false,
|
|
blocksSight: false,
|
|
interactionEnabled: false,
|
|
});
|
|
}
|
|
return state;
|
|
});
|
|
return Object.freeze({
|
|
...object,
|
|
states: Object.freeze(states),
|
|
visual: Object.freeze({
|
|
color: "#6f7180",
|
|
emissive: "#171923",
|
|
scale: object.visual?.scale ?? ([1, 1, 1] as const),
|
|
}),
|
|
source: Object.freeze({
|
|
...object.source,
|
|
fidelity: "source-backed-adapted" as const,
|
|
files: sourceFiles,
|
|
note,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function withReviewedGateFallbacks(
|
|
objects: readonly DungeonWorldObjectDefinition[],
|
|
objectIds: ReadonlySet<string>,
|
|
sourceFiles: readonly string[],
|
|
note: string,
|
|
): readonly DungeonWorldObjectDefinition[] {
|
|
return Object.freeze(objects.map((object) => (
|
|
objectIds.has(object.id) ? reviewedGateFallback(object, sourceFiles, note) : object
|
|
)));
|
|
}
|
|
|
|
function reviewedInteractableFallback(
|
|
object: DungeonWorldObjectDefinition,
|
|
sourceFiles: readonly string[],
|
|
prompt: string,
|
|
signalId: string,
|
|
allowedState: string,
|
|
inactiveState: string,
|
|
note: string,
|
|
): DungeonWorldObjectDefinition {
|
|
return Object.freeze({
|
|
...object,
|
|
states: Object.freeze(object.states.map((state) => Object.freeze({
|
|
...state,
|
|
visible: state.id !== inactiveState,
|
|
blocksMovement: false,
|
|
blocksSight: false,
|
|
interactionEnabled: state.id === allowedState,
|
|
}))),
|
|
interaction: Object.freeze({
|
|
prompt,
|
|
range: 3,
|
|
signalId,
|
|
allowedStates: [allowedState],
|
|
oneShot: true,
|
|
}),
|
|
visual: Object.freeze({ color: "#8a6642", emissive: "#24180e", scale: object.visual?.scale ?? ([1, 1, 1] as const) }),
|
|
source: Object.freeze({
|
|
...object.source,
|
|
fidelity: "source-backed-adapted" as const,
|
|
files: sourceFiles,
|
|
note,
|
|
}),
|
|
});
|
|
}
|
|
|
|
interface OrderedEventStage {
|
|
readonly id: string;
|
|
readonly group: DungeonEncounterSpawnGroupDefinition;
|
|
readonly delayMs: number;
|
|
readonly objective?: DungeonBossObjective;
|
|
readonly checkpointId?: string;
|
|
readonly openActions?: readonly DungeonEncounterAction[];
|
|
readonly clearActions?: readonly DungeonEncounterAction[];
|
|
readonly nextPhase?: string;
|
|
}
|
|
|
|
interface PositionedEventSpawn {
|
|
readonly entry: number;
|
|
readonly position: Vector3Tuple;
|
|
readonly id?: string;
|
|
}
|
|
|
|
function eventSpawnGroup(
|
|
id: string,
|
|
idsByEntry: ReadonlyMap<number, string>,
|
|
specs: readonly PositionedEventSpawn[],
|
|
): DungeonEncounterSpawnGroupDefinition {
|
|
return Object.freeze({
|
|
id,
|
|
spawns: Object.freeze(specs.map((spec, index) => {
|
|
const entityId = idsByEntry.get(spec.entry);
|
|
if (!entityId) throw new Error(`${id} is missing creature entry ${spec.entry}`);
|
|
return spawnAt(spec.id ?? `${id}:mob-${index + 1}`, entityId, spec.position, [0, 0]);
|
|
})),
|
|
});
|
|
}
|
|
|
|
function appendOrderedEventStages(
|
|
rules: DungeonEncounterRule[],
|
|
prefix: string,
|
|
stages: readonly OrderedEventStage[],
|
|
finishActions: readonly DungeonEncounterAction[],
|
|
): void {
|
|
stages.forEach((stage, index) => {
|
|
rules.push({
|
|
id: `${prefix}-open-${stage.id}`,
|
|
trigger: { kind: "elapsed", afterMs: stage.delayMs },
|
|
guards: [{ kind: "phase", phases: [`${stage.id}-wait`] }],
|
|
actions: [
|
|
...(stage.openActions ?? []),
|
|
{ id: `${prefix}-activate-${stage.id}`, kind: "activate-spawn-group", spawnGroupId: stage.group.id },
|
|
{ id: `${prefix}-record-${stage.id}`, kind: "set-counter", counterId: "event-stage", value: index + 1 },
|
|
{ id: `${prefix}-enter-${stage.id}`, kind: "set-phase", phase: `${stage.id}-active` },
|
|
],
|
|
});
|
|
const actions: DungeonEncounterAction[] = [
|
|
{ id: `${prefix}-deactivate-${stage.id}`, kind: "deactivate-spawn-group", spawnGroupId: stage.group.id },
|
|
...(stage.clearActions ?? []),
|
|
];
|
|
if (stage.objective) {
|
|
actions.push({
|
|
id: `${prefix}-complete-${stage.id}`,
|
|
kind: "complete-objective",
|
|
objectiveId: stage.objective.id,
|
|
persistence: "run",
|
|
});
|
|
}
|
|
if (stage.checkpointId) {
|
|
actions.push({
|
|
id: `${prefix}-checkpoint-${stage.id}`,
|
|
kind: "set-checkpoint",
|
|
checkpointId: stage.checkpointId,
|
|
persistence: "run",
|
|
});
|
|
}
|
|
const nextPhase = stage.nextPhase ?? (index + 1 < stages.length ? `${stages[index + 1].id}-wait` : undefined);
|
|
if (nextPhase) {
|
|
actions.push({ id: `${prefix}-next-${stage.id}`, kind: "set-phase", phase: nextPhase });
|
|
} else {
|
|
actions.push(...finishActions);
|
|
}
|
|
rules.push({
|
|
id: `${prefix}-clear-${stage.id}`,
|
|
trigger: { kind: "entities-dead", entityIds: stage.group.spawns.map((spawn) => spawn.id) },
|
|
guards: [{ kind: "phase", phases: [`${stage.id}-active`] }],
|
|
actions,
|
|
});
|
|
});
|
|
}
|
|
|
|
function eventBossObjectives(
|
|
definition: DungeonDefinition,
|
|
specs: readonly { readonly entry: number; readonly names: readonly string[]; readonly position: Vector3Tuple; readonly name?: string }[],
|
|
): {
|
|
readonly byEntry: ReadonlyMap<number, DungeonBossObjective>;
|
|
readonly bosses: readonly DungeonBossObjective[];
|
|
readonly objectiveOrder: readonly DungeonBossObjective[];
|
|
} {
|
|
const replacements = new Map<string, DungeonBossObjective>();
|
|
const byEntry = new Map<number, DungeonBossObjective>();
|
|
for (const spec of specs) {
|
|
const original = objectiveForEntry(definition, spec.entry, spec.names);
|
|
const replacement = Object.freeze({
|
|
...original,
|
|
...(spec.name ? { name: spec.name } : {}),
|
|
position: spec.position,
|
|
eventOnly: true,
|
|
});
|
|
replacements.set(original.id, replacement);
|
|
byEntry.set(spec.entry, replacement);
|
|
}
|
|
return {
|
|
byEntry,
|
|
bosses: Object.freeze(definition.bosses.map((boss) => replacements.get(boss.id) ?? boss)),
|
|
objectiveOrder: Object.freeze(definition.objectiveOrder.map((objective) => replacements.get(objective.id) ?? objective)),
|
|
};
|
|
}
|
|
|
|
function eventBossEntityIds(
|
|
entities: PopulationDefinitionMap,
|
|
entries: readonly number[],
|
|
): ReadonlyMap<number, string> {
|
|
const result = new Map<number, string>();
|
|
for (const entry of entries) {
|
|
const id = findEntityIdByEntry(entities, entry);
|
|
if (!id) throw new Error(`Missing event boss creature entry ${entry}`);
|
|
result.set(entry, id);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function buildVioletHold(definition: DungeonDefinition): DungeonDefinition {
|
|
assertSourceMapTransform(definition);
|
|
const review = REVIEW_BY_DUNGEON.get(definition.id)!;
|
|
const { entities, idsByEntry } = ensureAdaptedMobs(definition, VIOLET_HOLD_MOBS);
|
|
const erekem = objectiveForEntry(definition, 29_315, ["First Prisoner", "Erekem"]);
|
|
const moragg = objectiveForEntry(definition, 29_316, ["Second Prisoner", "Moragg"]);
|
|
const cyanigosa = objectiveForEntry(definition, 31_134, ["Cyanigosa"]);
|
|
const bossEntityIds = new Map([29_315, 29_316, 31_134].map((entry) => {
|
|
const id = findEntityIdByEntry(entities, entry);
|
|
if (!id) throw new Error(`${definition.id} is missing creature entry ${entry}`);
|
|
return [entry, id] as const;
|
|
}));
|
|
const guardSpawns = definition.staticSpawns
|
|
.filter((spawn) => entityEntry(definition, spawn) === 29_395)
|
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
const guardEntityId = findEntityIdByEntry(entities, 29_395);
|
|
if (!guardEntityId || guardSpawns.length < 2) {
|
|
throw new Error("violet-hold requires both source Erekem guards");
|
|
}
|
|
|
|
const portalObjects = VIOLET_HOLD_PORTALS.map((position, index) => eventFocusObject(
|
|
`violet-hold:portal-focus-${index + 1}`,
|
|
`Teleportation Portal ${index + 1}`,
|
|
position,
|
|
VIOLET_HOLD_FILES,
|
|
"#6a82d7",
|
|
));
|
|
const centerPortal = eventFocusObject(
|
|
"violet-hold:portal-focus-center",
|
|
"Saboteur Portal",
|
|
VIOLET_HOLD_CENTER_PORTAL,
|
|
VIOLET_HOLD_FILES,
|
|
"#6a82d7",
|
|
);
|
|
const startId = "violet-hold:sinclari-start";
|
|
const start = startObject(
|
|
startId,
|
|
"Lieutenant Sinclari",
|
|
"Begin the Violet Hold defense",
|
|
sourceMapPosition([1817.315674, 804.060608, 44.363998]),
|
|
"violet-hold:start",
|
|
VIOLET_HOLD_FILES,
|
|
);
|
|
const mainDoorIds = objectIdsForTemplate(definition, 191_723);
|
|
const erekemDoorIds = [191_564, 191_563, 191_562].flatMap((entry) => objectIdsForTemplate(definition, entry));
|
|
const moraggDoorIds = objectIdsForTemplate(definition, 191_606);
|
|
const reviewedDoorIds = new Set([...mainDoorIds, ...erekemDoorIds, ...moraggDoorIds]);
|
|
const reviewedObjects = withReviewedGateFallbacks(
|
|
definition.serverGameObjects,
|
|
reviewedDoorIds,
|
|
VIOLET_HOLD_FILES,
|
|
"Reviewed Violet Hold main/prison door binding. Procedural visible geometry is blocking while closed and nonblocking while open until its client model is approved.",
|
|
);
|
|
const groups: DungeonEncounterSpawnGroupDefinition[] = [];
|
|
const rules: DungeonEncounterRule[] = [
|
|
{
|
|
id: "start-defense",
|
|
trigger: { kind: "interact", objectId: startId },
|
|
guards: [{ kind: "phase", phases: ["ready"] }],
|
|
actions: [
|
|
{ id: "mark-violet-defense-active", kind: "set-world-object-state", objectId: startId, state: "active" },
|
|
...objectStateActions("close-violet-main-door", mainDoorIds, "closed"),
|
|
{ id: "initialize-violet-wave", kind: "set-counter", counterId: "wave", value: 0 },
|
|
{ id: "wait-for-violet-wave-01", kind: "set-phase", phase: "wave-01-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "resume-fresh-attempt",
|
|
trigger: { kind: "tick" },
|
|
guards: [
|
|
{ kind: "phase", phases: ["resume"] },
|
|
{ kind: "objective-complete", objectiveId: erekem.id, equals: false },
|
|
{ kind: "objective-complete", objectiveId: moragg.id, equals: false },
|
|
],
|
|
actions: [{ id: "resume-violet-ready", kind: "set-phase", phase: "ready" }],
|
|
},
|
|
{
|
|
id: "resume-after-first-prisoner",
|
|
trigger: { kind: "tick" },
|
|
guards: [
|
|
{ kind: "phase", phases: ["resume"] },
|
|
{ kind: "objective-complete", objectiveId: erekem.id, equals: true },
|
|
{ kind: "objective-complete", objectiveId: moragg.id, equals: false },
|
|
],
|
|
actions: [
|
|
{ id: "resume-first-mark-active", kind: "set-world-object-state", objectId: startId, state: "active" },
|
|
...objectStateActions("resume-first-close-main-door", mainDoorIds, "closed"),
|
|
{ id: "resume-at-violet-wave-07", kind: "set-phase", phase: "wave-07-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "resume-after-second-prisoner",
|
|
trigger: { kind: "tick" },
|
|
guards: [
|
|
{ kind: "phase", phases: ["resume"] },
|
|
{ kind: "objective-complete", objectiveId: moragg.id, equals: true },
|
|
],
|
|
actions: [
|
|
{ id: "resume-second-mark-active", kind: "set-world-object-state", objectId: startId, state: "active" },
|
|
...objectStateActions("resume-second-close-main-door", mainDoorIds, "closed"),
|
|
{ id: "resume-at-violet-wave-13", kind: "set-phase", phase: "wave-13-wait" },
|
|
],
|
|
},
|
|
];
|
|
|
|
for (let wave = 1; wave <= 18; wave += 1) {
|
|
const suffix = String(wave).padStart(2, "0");
|
|
const groupId = `violet-hold:wave-${suffix}`;
|
|
const bossWave = wave === 6 || wave === 12 || wave === 18;
|
|
const portal = bossWave ? centerPortal : portalObjects[VIOLET_HOLD_PORTAL_SEQUENCE[(wave - 1) % 6]];
|
|
const center = bossWave ? VIOLET_HOLD_CENTER_PORTAL : portal.position;
|
|
const spawns: StaticMobSpawnDefinition[] = [];
|
|
if (wave === 6) {
|
|
spawns.push(
|
|
spawnAt(erekem.id, bossEntityIds.get(29_315)!, VIOLET_HOLD_BOSS_POSITIONS.erekem, [0, 0]),
|
|
spawnAt(guardSpawns[0].id, guardEntityId, VIOLET_HOLD_BOSS_POSITIONS.erekemGuard1, [0, 0]),
|
|
spawnAt(guardSpawns[1].id, guardEntityId, VIOLET_HOLD_BOSS_POSITIONS.erekemGuard2, [0, 0]),
|
|
);
|
|
} else if (wave === 12) {
|
|
spawns.push(spawnAt(moragg.id, bossEntityIds.get(29_316)!, VIOLET_HOLD_BOSS_POSITIONS.moragg, [0, 0]));
|
|
} else if (wave === 18) {
|
|
spawns.push(spawnAt(cyanigosa.id, bossEntityIds.get(31_134)!, VIOLET_HOLD_MIDDLE, [0, 0]));
|
|
} else {
|
|
const late = wave >= 12;
|
|
if (wave % 2 === 1) {
|
|
const keeperEntries = [30_660, 30_695, 30_893] as const;
|
|
const addEntries = late
|
|
? [30_961, 30_962, 30_918, 30_963]
|
|
: [30_661, 30_662, 30_663];
|
|
const entries = [keeperEntries[wave % keeperEntries.length], ...addEntries];
|
|
entries.forEach((entry, index) => spawns.push(spawnAt(
|
|
`${groupId}:mob-${index + 1}`,
|
|
idsByEntry.get(entry)!,
|
|
center,
|
|
PACK_OFFSETS[index % PACK_OFFSETS.length],
|
|
)));
|
|
} else {
|
|
const eliteEntries = [30_666, 30_667, 30_668, 32_191] as const;
|
|
const count = late ? 3 : 2;
|
|
for (let index = 0; index < count; index += 1) {
|
|
const entry = eliteEntries[(wave + index) % eliteEntries.length];
|
|
spawns.push(spawnAt(
|
|
`${groupId}:mob-${index + 1}`,
|
|
idsByEntry.get(entry)!,
|
|
center,
|
|
PACK_OFFSETS[index % PACK_OFFSETS.length],
|
|
));
|
|
}
|
|
}
|
|
}
|
|
groups.push(Object.freeze({ id: groupId, spawns: Object.freeze(spawns) }));
|
|
|
|
const priorWasBoss = wave === 7 || wave === 13;
|
|
const delayMs = wave === 1 ? 19_000 : priorWasBoss ? 35_000 : 3_000;
|
|
const openActions: DungeonEncounterAction[] = [
|
|
{ id: `show-violet-portal-${suffix}`, kind: "set-world-object-state", objectId: portal.id, state: "active" },
|
|
{ id: `activate-violet-wave-${suffix}`, kind: "activate-spawn-group", spawnGroupId: groupId },
|
|
{ id: `record-violet-wave-${suffix}`, kind: "set-counter", counterId: "wave", value: wave },
|
|
];
|
|
if (wave === 6) openActions.push(...objectStateActions("open-erekem-cell", erekemDoorIds, "open"));
|
|
if (wave === 12) openActions.push(...objectStateActions("open-moragg-cell", moraggDoorIds, "open"));
|
|
openActions.push({ id: `enter-violet-wave-${suffix}`, kind: "set-phase", phase: `wave-${suffix}-active` });
|
|
rules.push({
|
|
id: `open-violet-wave-${suffix}`,
|
|
trigger: { kind: "elapsed", afterMs: delayMs },
|
|
guards: [{ kind: "phase", phases: [`wave-${suffix}-wait`] }],
|
|
actions: openActions,
|
|
});
|
|
|
|
const clearActions: DungeonEncounterAction[] = [
|
|
{ id: `hide-violet-portal-${suffix}`, kind: "set-world-object-state", objectId: portal.id, state: "hidden" },
|
|
{ id: `deactivate-violet-wave-${suffix}`, kind: "deactivate-spawn-group", spawnGroupId: groupId },
|
|
];
|
|
const objective = wave === 6 ? erekem : wave === 12 ? moragg : wave === 18 ? cyanigosa : undefined;
|
|
if (objective) clearActions.push(
|
|
{
|
|
id: `complete-violet-objective-${suffix}`,
|
|
kind: "complete-objective",
|
|
objectiveId: objective.id,
|
|
persistence: "run",
|
|
},
|
|
{
|
|
id: `save-violet-checkpoint-${suffix}`,
|
|
kind: "set-checkpoint",
|
|
checkpointId: objective.id,
|
|
persistence: "checkpoint",
|
|
},
|
|
);
|
|
if (wave < 18) {
|
|
const next = String(wave + 1).padStart(2, "0");
|
|
clearActions.push({ id: `wait-for-violet-wave-${next}`, kind: "set-phase", phase: `wave-${next}-wait` });
|
|
} else {
|
|
clearActions.push(
|
|
...objectStateActions("open-violet-main-door-complete", mainDoorIds, "open").map((action) => ({ ...action, persistence: "run" as const })),
|
|
{ id: "mark-violet-hold-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
|
{ id: "complete-violet-defense", kind: "set-world-object-state", objectId: startId, state: "complete", persistence: "run" },
|
|
{ id: "enter-violet-hold-complete", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
);
|
|
}
|
|
rules.push({
|
|
id: `clear-violet-wave-${suffix}`,
|
|
trigger: { kind: "entities-dead", entityIds: spawns.map((spawn) => spawn.id) },
|
|
guards: [{ kind: "phase", phases: [`wave-${suffix}-active`] }],
|
|
actions: clearActions,
|
|
});
|
|
}
|
|
|
|
const encounter: DungeonEncounterDefinition = Object.freeze({
|
|
id: "violet-hold:wow-instance",
|
|
dungeonId: definition.id,
|
|
initialPhase: "ready",
|
|
initialState: {
|
|
flags: { "dungeon-complete": false },
|
|
counters: { wave: 0 },
|
|
worldObjectStates: Object.fromEntries([
|
|
[startId, "ready"],
|
|
...portalObjects.map((object) => [object.id, "hidden"] as const),
|
|
[centerPortal.id, "hidden"],
|
|
]),
|
|
},
|
|
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
|
|
source: sourceReview(review, "instance-orchestration"),
|
|
rules: Object.freeze(rules),
|
|
});
|
|
const replacements = new Map<string, DungeonBossObjective>([
|
|
[erekem.id, Object.freeze({ ...erekem, name: "Erekem", position: VIOLET_HOLD_BOSS_POSITIONS.erekem, eventOnly: true })],
|
|
[moragg.id, Object.freeze({ ...moragg, name: "Moragg", position: VIOLET_HOLD_BOSS_POSITIONS.moragg, eventOnly: true })],
|
|
[cyanigosa.id, Object.freeze({ ...cyanigosa, position: VIOLET_HOLD_MIDDLE, eventOnly: true })],
|
|
]);
|
|
const updatedBosses = definition.bosses.map((boss) => replacements.get(boss.id) ?? boss);
|
|
const gatedEntries = new Set([29_315, 29_316, 29_395, 31_134]);
|
|
return Object.freeze({
|
|
...definition,
|
|
entities,
|
|
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => !gatedEntries.has(entityEntry(definition, spawn) ?? -1))),
|
|
bosses: Object.freeze(updatedBosses),
|
|
objectiveOrder: Object.freeze(definition.objectiveOrder.map((objective) => replacements.get(objective.id) ?? objective)),
|
|
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
|
|
encounterSpawnGroups: Object.freeze([
|
|
...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("violet-hold:wave-")),
|
|
...groups,
|
|
]),
|
|
serverGameObjects: Object.freeze([
|
|
...reviewedObjects,
|
|
start,
|
|
...portalObjects,
|
|
centerPortal,
|
|
]),
|
|
validation: appendValidationWarnings(definition, [
|
|
"Violet Hold now has a playable 18-wave source-backed adaptation; prison-seal damage/failure and the remaining four random prisoners still require runtime support and imported entities.",
|
|
]),
|
|
});
|
|
}
|
|
|
|
const CULLING_MOBS = Object.freeze([
|
|
{ entry: 27_737, name: "Risen Zombie", level: 78, role: "melee" },
|
|
{ entry: 28_249, name: "Devouring Ghoul", level: 78, role: "melee" },
|
|
{ entry: 28_200, name: "Dark Necromancer", level: 79, role: "caster" },
|
|
{ entry: 28_199, name: "Tomb Stalker", level: 79, role: "melee" },
|
|
{ entry: 27_734, name: "Crypt Fiend", level: 78, role: "melee" },
|
|
{ entry: 28_201, name: "Bile Golem", level: 79, role: "melee" },
|
|
{ entry: 27_729, name: "Enraging Ghoul", level: 78, role: "melee" },
|
|
{ entry: 27_736, name: "Patchwork Construct", level: 79, role: "melee" },
|
|
{ entry: 27_742, name: "Infinite Adversary", level: 79, role: "melee" },
|
|
{ entry: 27_743, name: "Infinite Hunter", level: 79, role: "caster" },
|
|
{ entry: 27_744, name: "Infinite Agent", level: 79, role: "caster" },
|
|
] as const satisfies readonly AdaptedMobSpec[]);
|
|
|
|
const CULLING_CITY_WAVES: readonly (readonly PositionedEventSpawn[])[] = Object.freeze([
|
|
[[27_737, 2164.698975, 1255.392944, 135.040878], [27_737, 2183.501465, 1263.079102, 134.859055], [28_249, 2177.512939, 1247.313843, 135.846695], [28_249, 2171.991943, 1246.615845, 135.745026]],
|
|
[[28_249, 2254.434326, 1163.427612, 138.055038], [28_249, 2254.703613, 1158.867798, 138.212234], [28_249, 2257.615723, 1162.310913, 138.091202], [28_200, 2258.258057, 1157.250732, 138.272873]],
|
|
[[28_199, 2348.120117, 1202.30249, 130.491104], [28_249, 2352.863525, 1207.819092, 130.424271], [28_249, 2343.59375, 1207.915039, 130.781311], [28_200, 2348.257324, 1212.202515, 130.670135]],
|
|
[[28_199, 2139.825195, 1356.2771, 132.199615], [28_249, 2137.073486, 1362.464844, 132.271637], [28_249, 2134.075684, 1354.148071, 131.885864], [28_200, 2133.302246, 1358.907837, 132.037689]],
|
|
[[28_200, 2164.698975, 1255.392944, 135.040878], [28_249, 2183.501465, 1263.079102, 134.859055], [28_199, 2177.512939, 1247.313843, 135.846695], [27_734, 2171.991943, 1246.615845, 135.745026]],
|
|
[[28_201, 2349.70166, 1188.436646, 130.428864], [28_249, 2349.90918, 1194.582642, 130.417816], [27_729, 2354.662598, 1185.692017, 130.552032], [27_729, 2354.716797, 1191.61438, 130.53981]],
|
|
[[27_736, 2145.212891, 1355.288086, 132.288773], [28_200, 2137.078613, 1357.612671, 132.17334], [27_729, 2139.4021, 1352.541626, 132.127518], [27_729, 2142.408447, 1360.76062, 132.321564]],
|
|
[[28_249, 2172.686279, 1259.618164, 134.391754], [27_734, 2177.64917, 1256.061157, 135.096512], [27_736, 2170.782959, 1253.594849, 134.973022], [28_200, 2175.595703, 1249.041992, 135.603531]],
|
|
].map((wave) => Object.freeze(wave.map(([entry, x, y, z]) => Object.freeze({
|
|
entry,
|
|
position: sourceMapPosition([x, y, z]),
|
|
})))));
|
|
|
|
const CULLING_RIFT_PACKS: readonly (readonly PositionedEventSpawn[])[] = Object.freeze([
|
|
[[27_742, 2433.85791, 1185.612061, 148.075974], [27_742, 2437.577881, 1188.241089, 148.075974], [27_744, 2437.165527, 1192.294922, 148.075974], [27_743, 2434.98999, 1197.679565, 148.075974]],
|
|
[[27_744, 2403.676758, 1171.49585, 148.075607], [27_743, 2407.691162, 1172.16272, 148.075607]],
|
|
[[27_742, 2416.024658, 1139.456177, 148.076431], [27_743, 2410.866699, 1139.680542, 148.076431]],
|
|
[[27_742, 2426.947998, 1107.47168, 148.076019], [27_743, 2432.94458, 1108.896362, 148.20816]],
|
|
[[27_742, 2438.190674, 1118.368164, 148.076172], [27_744, 2435.861328, 1113.402954, 148.169327]],
|
|
].map((wave) => Object.freeze(wave.map(([entry, x, y, z]) => Object.freeze({
|
|
entry,
|
|
position: sourceMapPosition([x, y, z]),
|
|
})))));
|
|
|
|
const CULLING_RIFT_POSITIONS = Object.freeze([
|
|
[2431.790039, 1190.670044, 148.076004],
|
|
[2403.954834, 1178.81543, 148.075943],
|
|
[2414.217041, 1133.446167, 148.07605],
|
|
[2433.081543, 1099.869751, 148.076157],
|
|
[2444.077637, 1114.366089, 148.076157],
|
|
].map(([x, y, z]) => sourceMapPosition([x, y, z])));
|
|
|
|
function buildCullingOfStratholme(definition: DungeonDefinition): DungeonDefinition {
|
|
assertSourceMapTransform(definition);
|
|
const review = REVIEW_BY_DUNGEON.get(definition.id)!;
|
|
const { entities, idsByEntry } = ensureAdaptedMobs(definition, CULLING_MOBS);
|
|
const bossEntries = [26_529, 26_530, 26_532, 26_533] as const;
|
|
const objectiveState = eventBossObjectives(definition, [
|
|
{ entry: 26_529, names: ["Meathook"], position: sourceMapPosition([2351.45, 1197.81, 130.45]) },
|
|
{ entry: 26_530, names: ["Salram the Fleshcrafter", "Salramm the Fleshcrafter"], name: "Salramm the Fleshcrafter", position: sourceMapPosition([2351.45, 1197.81, 130.45]) },
|
|
{ entry: 26_532, names: ["Chrono-Lord Epoch"], position: sourceMapPosition([2451.809, 1112.901, 149.22]) },
|
|
{ entry: 26_533, names: ["Mal'ganis", "Mal'Ganis"], name: "Mal'Ganis", position: sourceMapPosition([2298.25, 1500.56, 128.37]) },
|
|
]);
|
|
const allIds = new Map([...idsByEntry, ...eventBossEntityIds(entities, bossEntries)]);
|
|
const meathook = objectiveState.byEntry.get(26_529)!;
|
|
const salramm = objectiveState.byEntry.get(26_530)!;
|
|
const epoch = objectiveState.byEntry.get(26_532)!;
|
|
const malganis = objectiveState.byEntry.get(26_533)!;
|
|
const startId = "culling-of-stratholme:arthas-start";
|
|
const start = startObject(
|
|
startId,
|
|
"Prince Arthas",
|
|
"Begin the Culling of Stratholme",
|
|
[1287.12, 142.935, 1920.87],
|
|
"culling-of-stratholme:start",
|
|
CULLING_FILES,
|
|
);
|
|
const riftObjects = CULLING_RIFT_POSITIONS.map((position, index) => eventFocusObject(
|
|
`culling-of-stratholme:time-rift-${index + 1}`,
|
|
`Town Hall Time Rift ${index + 1}`,
|
|
position,
|
|
CULLING_FILES,
|
|
"#6e67c7",
|
|
));
|
|
const groups: DungeonEncounterSpawnGroupDefinition[] = [];
|
|
const stages: OrderedEventStage[] = [];
|
|
let cityTrashIndex = 0;
|
|
for (let wave = 1; wave <= 10; wave += 1) {
|
|
const id = `culling-city-${String(wave).padStart(2, "0")}`;
|
|
let group: DungeonEncounterSpawnGroupDefinition;
|
|
let objective: DungeonBossObjective | undefined;
|
|
if (wave === 5 || wave === 10) {
|
|
objective = wave === 5 ? meathook : salramm;
|
|
const entry = wave === 5 ? 26_529 : 26_530;
|
|
group = eventSpawnGroup(id, allIds, [{ entry, position: objective.position, id: objective.id }]);
|
|
} else {
|
|
group = eventSpawnGroup(id, allIds, CULLING_CITY_WAVES[cityTrashIndex]);
|
|
cityTrashIndex += 1;
|
|
}
|
|
groups.push(group);
|
|
stages.push({
|
|
id,
|
|
group,
|
|
delayMs: wave === 1 ? 10_000 : 0,
|
|
...(objective ? { objective, checkpointId: objective.id } : {}),
|
|
});
|
|
}
|
|
const townPackIndexes = [[0], [1, 2], [3, 4]] as const;
|
|
townPackIndexes.forEach((indexes, index) => {
|
|
const id = `culling-town-${String(index + 1).padStart(2, "0")}`;
|
|
const group = eventSpawnGroup(id, allIds, indexes.flatMap((rift) => CULLING_RIFT_PACKS[rift]));
|
|
groups.push(group);
|
|
stages.push({
|
|
id,
|
|
group,
|
|
delayMs: index === 0 ? 5_000 : 0,
|
|
openActions: indexes.map((rift) => ({
|
|
id: `culling-show-town-rift-${rift + 1}`,
|
|
kind: "set-world-object-state" as const,
|
|
objectId: riftObjects[rift].id,
|
|
state: "active",
|
|
})),
|
|
clearActions: indexes.map((rift) => ({
|
|
id: `culling-hide-town-rift-${rift + 1}`,
|
|
kind: "set-world-object-state" as const,
|
|
objectId: riftObjects[rift].id,
|
|
state: "hidden",
|
|
})),
|
|
});
|
|
});
|
|
const epochGroup = eventSpawnGroup("culling-epoch", allIds, [{ entry: 26_532, position: epoch.position, id: epoch.id }]);
|
|
const malganisGroup = eventSpawnGroup("culling-malganis", allIds, [{ entry: 26_533, position: malganis.position, id: malganis.id }]);
|
|
groups.push(epochGroup, malganisGroup);
|
|
stages.push(
|
|
{ id: "culling-epoch", group: epochGroup, delayMs: 32_000, objective: epoch, checkpointId: epoch.id },
|
|
{ id: "culling-malganis", group: malganisGroup, delayMs: 5_000, objective: malganis, checkpointId: malganis.id },
|
|
);
|
|
const rules: DungeonEncounterRule[] = [
|
|
{
|
|
id: "culling-start",
|
|
trigger: { kind: "interact", objectId: startId },
|
|
guards: [{ kind: "phase", phases: ["ready"] }],
|
|
actions: [
|
|
{ id: "culling-mark-start-active", kind: "set-world-object-state", objectId: startId, state: "active" },
|
|
{ id: "culling-enter-city-01", kind: "set-phase", phase: "culling-city-01-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "culling-resume-complete", trigger: { kind: "tick" }, once: false, priority: 20,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "flag", flagId: "dungeon-complete", equals: true }],
|
|
actions: [{ id: "culling-recover-complete", kind: "set-phase", phase: "complete" }],
|
|
},
|
|
{
|
|
id: "culling-resume-after-epoch", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: epoch.id, equals: true }, { kind: "objective-complete", objectiveId: malganis.id, equals: false }],
|
|
actions: [{ id: "culling-recover-epoch-active", kind: "set-world-object-state", objectId: startId, state: "active" }, { id: "culling-recover-at-malganis", kind: "set-phase", phase: "culling-malganis-wait" }],
|
|
},
|
|
{
|
|
id: "culling-resume-after-salramm", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: salramm.id, equals: true }, { kind: "objective-complete", objectiveId: epoch.id, equals: false }],
|
|
actions: [{ id: "culling-recover-salramm-active", kind: "set-world-object-state", objectId: startId, state: "active" }, { id: "culling-recover-at-town", kind: "set-phase", phase: "culling-town-01-wait" }],
|
|
},
|
|
{
|
|
id: "culling-resume-after-meathook", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: meathook.id, equals: true }, { kind: "objective-complete", objectiveId: salramm.id, equals: false }],
|
|
actions: [{ id: "culling-recover-meathook-active", kind: "set-world-object-state", objectId: startId, state: "active" }, { id: "culling-recover-at-city-06", kind: "set-phase", phase: "culling-city-06-wait" }],
|
|
},
|
|
{
|
|
id: "culling-resume-fresh", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: meathook.id, equals: false }],
|
|
actions: [{ id: "culling-recover-ready", kind: "set-phase", phase: "ready" }],
|
|
},
|
|
];
|
|
appendOrderedEventStages(rules, "culling", stages, [
|
|
{ id: "culling-mark-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
|
{ id: "culling-complete-start", kind: "set-world-object-state", objectId: startId, state: "complete", persistence: "run" },
|
|
{ id: "culling-enter-complete", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
]);
|
|
const encounter: DungeonEncounterDefinition = Object.freeze({
|
|
id: "culling-of-stratholme:wow-instance",
|
|
dungeonId: definition.id,
|
|
initialPhase: "ready",
|
|
initialState: {
|
|
flags: { "dungeon-complete": false },
|
|
counters: { "event-stage": 0 },
|
|
worldObjectStates: Object.fromEntries([[startId, "ready"], ...riftObjects.map((object) => [object.id, "hidden"] as const)]),
|
|
},
|
|
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
|
|
source: sourceReview(review, "instance-orchestration"),
|
|
rules: Object.freeze(rules),
|
|
});
|
|
const gated = new Set<number>(bossEntries);
|
|
return Object.freeze({
|
|
...definition,
|
|
entities,
|
|
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => !gated.has(entityEntry(definition, spawn) ?? -1))),
|
|
bosses: objectiveState.bosses,
|
|
objectiveOrder: objectiveState.objectiveOrder,
|
|
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
|
|
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("culling-")), ...groups]),
|
|
serverGameObjects: Object.freeze([...definition.serverGameObjects, start, ...riftObjects]),
|
|
validation: appendValidationWarnings(definition, [
|
|
"Culling bosses are now source-gated behind the city/town-hall sequence; the playable adapter intentionally abbreviates Arthas escort travel and friendly-actor failure.",
|
|
]),
|
|
});
|
|
}
|
|
|
|
const DURNHOLDE_MOBS = Object.freeze([
|
|
{ entry: 18_092, name: "Tarren Mill Guardsman", level: 66, role: "melee" },
|
|
{ entry: 18_093, name: "Tarren Mill Protector", level: 66, role: "melee" },
|
|
{ entry: 18_094, name: "Tarren Mill Lookout", level: 66, role: "caster" },
|
|
{ entry: 23_175, name: "Epoch Guardsman", level: 67, role: "melee" },
|
|
{ entry: 23_179, name: "Epoch Protector", level: 67, role: "melee" },
|
|
{ entry: 23_177, name: "Epoch Lookout", level: 67, role: "caster" },
|
|
{ entry: 18_171, name: "Infinite Defiler", level: 67, role: "caster" },
|
|
{ entry: 18_172, name: "Infinite Saboteur", level: 67, role: "melee" },
|
|
{ entry: 18_170, name: "Infinite Slayer", level: 67, role: "melee" },
|
|
] as const satisfies readonly AdaptedMobSpec[]);
|
|
|
|
function sourceFightPack(rows: readonly (readonly [number, number, number, number])[]): readonly PositionedEventSpawn[] {
|
|
return Object.freeze(rows.map(([entry, x, y, z]) => Object.freeze({
|
|
entry,
|
|
position: sourceMapPosition([x, y, z]),
|
|
})));
|
|
}
|
|
|
|
function buildEscapeFromDurnholde(definition: DungeonDefinition): DungeonDefinition {
|
|
assertSourceMapTransform(definition);
|
|
const review = REVIEW_BY_DUNGEON.get(definition.id)!;
|
|
const { entities, idsByEntry } = ensureAdaptedMobs(definition, DURNHOLDE_MOBS);
|
|
const bossEntries = [17_848, 17_862, 18_096] as const;
|
|
const objectiveState = eventBossObjectives(definition, [
|
|
{ entry: 17_848, names: ["Lieutenant Drake"], position: sourceMapPosition([2172.76, 149.54, 87.981]) },
|
|
{ entry: 17_862, names: ["Captain Skarloc"], position: sourceMapPosition([1995.78, 277.46, 66.64]) },
|
|
{ entry: 18_096, names: ["Epoch Hunter"], position: sourceMapPosition([2634.79, 672.964, 54.8577]) },
|
|
]);
|
|
const allIds = new Map([...idsByEntry, ...eventBossEntityIds(entities, bossEntries)]);
|
|
const drake = objectiveState.byEntry.get(17_848)!;
|
|
const skarloc = objectiveState.byEntry.get(17_862)!;
|
|
const epoch = objectiveState.byEntry.get(18_096)!;
|
|
const barrelIds = objectIdsForTemplate(definition, 182_589);
|
|
if (barrelIds.length < 5) throw new Error("escape-from-durnholde requires at least five source barrels");
|
|
const barrelSet = new Set(barrelIds);
|
|
const prisonDoorIds = objectIdsForTemplate(definition, 184_393);
|
|
const thrallStartId = "escape-from-durnholde:thrall-escort";
|
|
const thrallStart = startObject(
|
|
thrallStartId,
|
|
"Thrall",
|
|
"Continue Thrall's escape",
|
|
[119.956, 82.2979, 2231.89],
|
|
"escape-from-durnholde:thrall-start",
|
|
DURNHOLDE_FILES,
|
|
);
|
|
const reviewedBarrels = definition.serverGameObjects.map((object) => (
|
|
barrelSet.has(object.id)
|
|
? reviewedInteractableFallback(
|
|
object,
|
|
DURNHOLDE_FILES,
|
|
"Plant an incendiary bomb",
|
|
`escape-from-durnholde:barrel:${object.id}`,
|
|
"available",
|
|
"inactive",
|
|
"Reviewed source barrel. Visible procedural geometry and a one-shot interaction implement the source five-barrel threshold.",
|
|
)
|
|
: object
|
|
));
|
|
const reviewedObjects = withReviewedGateFallbacks(
|
|
reviewedBarrels,
|
|
new Set(prisonDoorIds),
|
|
DURNHOLDE_FILES,
|
|
"Reviewed Durnholde prison-door binding. Procedural visible geometry is blocking while closed and nonblocking while open.",
|
|
);
|
|
const groups: DungeonEncounterSpawnGroupDefinition[] = [];
|
|
const drakeGroup = eventSpawnGroup("durnholde-drake", allIds, [{ entry: 17_848, position: drake.position, id: drake.id }]);
|
|
const skarlocGroup = eventSpawnGroup("durnholde-skarloc", allIds, [{ entry: 17_862, position: skarloc.position, id: skarloc.id }]);
|
|
const barnGroup = eventSpawnGroup("durnholde-barn", allIds, sourceFightPack([
|
|
[18_093, 2652.71, 660.31, 61.93], [18_094, 2648.96, 662.59, 61.93],
|
|
[18_092, 2657.36, 662.34, 61.93], [18_092, 2656.39, 659.77, 61.93],
|
|
]));
|
|
const churchGroup = eventSpawnGroup("durnholde-church", allIds, sourceFightPack([
|
|
[18_093, 2630.75, 664.8, 54.28], [18_094, 2632.2, 661.98, 54.3],
|
|
[18_092, 2630.02, 662.75, 54.28], [18_092, 2632.86, 664.05, 54.31],
|
|
]));
|
|
const epochWave1 = eventSpawnGroup("durnholde-epoch-wave-1", allIds, sourceFightPack([
|
|
[23_177, 2647.57, 701.17, 56.215], [23_175, 2629.46, 704.76, 56.286], [23_179, 2640.14, 709.44, 56.135],
|
|
]));
|
|
const epochWave2 = eventSpawnGroup("durnholde-epoch-wave-2", allIds, sourceFightPack([
|
|
[18_172, 2599.57, 683.72, 55.975], [18_170, 2599.57, 677, 55.975], [18_171, 2592.57, 680, 55.975],
|
|
]));
|
|
const epochWave3 = eventSpawnGroup("durnholde-epoch-wave-3", allIds, sourceFightPack([
|
|
[18_170, 2642.62, 701.43, 55.965], [18_170, 2638.62, 701.43, 55.965],
|
|
[18_172, 2638.62, 705.43, 55.965], [18_171, 2642.62, 705.43, 55.965],
|
|
]));
|
|
const epochGroup = eventSpawnGroup("durnholde-epoch", allIds, [{ entry: 18_096, position: epoch.position, id: epoch.id }]);
|
|
groups.push(drakeGroup, skarlocGroup, barnGroup, churchGroup, epochWave1, epochWave2, epochWave3, epochGroup);
|
|
const stages: OrderedEventStage[] = [
|
|
{
|
|
id: "durnholde-drake", group: drakeGroup, delayMs: 12_000, objective: drake, checkpointId: drake.id, nextPhase: "durnholde-await-thrall",
|
|
clearActions: [
|
|
...objectStateActions("durnholde-open-prison", prisonDoorIds, "open").map((action) => ({ ...action, persistence: "run" as const })),
|
|
{ id: "durnholde-enable-thrall", kind: "set-world-object-state", objectId: thrallStartId, state: "ready" },
|
|
],
|
|
},
|
|
{ id: "durnholde-skarloc", group: skarlocGroup, delayMs: 0, objective: skarloc, checkpointId: skarloc.id },
|
|
{ id: "durnholde-barn", group: barnGroup, delayMs: 3_000 },
|
|
{ id: "durnholde-church", group: churchGroup, delayMs: 3_000 },
|
|
{ id: "durnholde-epoch-wave-1", group: epochWave1, delayMs: 3_000 },
|
|
{ id: "durnholde-epoch-wave-2", group: epochWave2, delayMs: 0 },
|
|
{ id: "durnholde-epoch-wave-3", group: epochWave3, delayMs: 0 },
|
|
{ id: "durnholde-epoch", group: epochGroup, delayMs: 8_000, objective: epoch, checkpointId: epoch.id },
|
|
];
|
|
const rules: DungeonEncounterRule[] = barrelIds.map((objectId, index) => ({
|
|
id: `durnholde-plant-barrel-${index + 1}`,
|
|
trigger: { kind: "interact", objectId },
|
|
guards: [
|
|
{ kind: "phase", phases: ["durnholde-barrels"] },
|
|
{ kind: "counter", counterId: "barrels", comparison: "at-most", value: 4 },
|
|
],
|
|
actions: [
|
|
{ id: `durnholde-use-barrel-${index + 1}`, kind: "set-world-object-state", objectId, state: "inactive" },
|
|
{ id: `durnholde-count-barrel-${index + 1}`, kind: "increment-counter", counterId: "barrels" },
|
|
],
|
|
}));
|
|
rules.push(
|
|
{
|
|
id: "durnholde-start-after-five-barrels",
|
|
trigger: { kind: "tick" },
|
|
guards: [{ kind: "phase", phases: ["durnholde-barrels"] }, { kind: "counter", counterId: "barrels", comparison: "at-least", value: 5 }],
|
|
actions: [
|
|
...objectStateActions("durnholde-disable-unused-barrels", barrelIds, "inactive"),
|
|
{ id: "durnholde-wait-for-drake", kind: "set-phase", phase: "durnholde-drake-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "durnholde-continue-with-thrall",
|
|
trigger: { kind: "interact", objectId: thrallStartId },
|
|
guards: [{ kind: "phase", phases: ["durnholde-await-thrall"] }],
|
|
actions: [
|
|
{ id: "durnholde-mark-thrall-active", kind: "set-world-object-state", objectId: thrallStartId, state: "active" },
|
|
{ id: "durnholde-wait-for-skarloc", kind: "set-phase", phase: "durnholde-skarloc-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "durnholde-resume-complete", trigger: { kind: "tick" }, once: false, priority: 20,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "flag", flagId: "dungeon-complete", equals: true }],
|
|
actions: [{ id: "durnholde-recover-complete", kind: "set-phase", phase: "complete" }],
|
|
},
|
|
{
|
|
id: "durnholde-resume-after-skarloc", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: skarloc.id, equals: true }, { kind: "objective-complete", objectiveId: epoch.id, equals: false }],
|
|
actions: [
|
|
{ id: "durnholde-recover-skarloc-thrall", kind: "set-world-object-state", objectId: thrallStartId, state: "active" },
|
|
{ id: "durnholde-recover-at-barn", kind: "set-phase", phase: "durnholde-barn-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "durnholde-resume-after-drake", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: drake.id, equals: true }, { kind: "objective-complete", objectiveId: skarloc.id, equals: false }],
|
|
actions: [
|
|
...objectStateActions("durnholde-recover-open-prison", prisonDoorIds, "open"),
|
|
{ id: "durnholde-recover-enable-thrall", kind: "set-world-object-state", objectId: thrallStartId, state: "ready" },
|
|
{ id: "durnholde-recover-await-thrall", kind: "set-phase", phase: "durnholde-await-thrall" },
|
|
],
|
|
},
|
|
{
|
|
id: "durnholde-resume-fresh", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: drake.id, equals: false }],
|
|
actions: [{ id: "durnholde-recover-barrels", kind: "set-phase", phase: "durnholde-barrels" }],
|
|
},
|
|
);
|
|
appendOrderedEventStages(rules, "durnholde", stages, [
|
|
{ id: "durnholde-mark-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
|
{ id: "durnholde-complete-thrall", kind: "set-world-object-state", objectId: thrallStartId, state: "complete", persistence: "run" },
|
|
{ id: "durnholde-enter-complete", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
]);
|
|
const encounter: DungeonEncounterDefinition = Object.freeze({
|
|
id: "escape-from-durnholde:wow-instance",
|
|
dungeonId: definition.id,
|
|
initialPhase: "durnholde-barrels",
|
|
initialState: {
|
|
flags: { "dungeon-complete": false },
|
|
counters: { barrels: 0, "event-stage": 0 },
|
|
worldObjectStates: Object.fromEntries([
|
|
...barrelIds.map((id) => [id, "available"] as const),
|
|
...prisonDoorIds.map((id) => [id, "closed"] as const),
|
|
[thrallStartId, "hidden"],
|
|
]),
|
|
},
|
|
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
|
|
source: sourceReview(review, "instance-orchestration"),
|
|
rules: Object.freeze(rules),
|
|
});
|
|
const gated = new Set<number>(bossEntries);
|
|
return Object.freeze({
|
|
...definition,
|
|
entities,
|
|
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => !gated.has(entityEntry(definition, spawn) ?? -1))),
|
|
bosses: objectiveState.bosses,
|
|
objectiveOrder: objectiveState.objectiveOrder,
|
|
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
|
|
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("durnholde-")), ...groups]),
|
|
serverGameObjects: Object.freeze([...reviewedObjects, thrallStart]),
|
|
validation: appendValidationWarnings(definition, [
|
|
"Durnholde bosses are source-gated by five barrels, Thrall, and the Epoch wave sequence; protected escort/failure behavior remains an explicit adaptation gap.",
|
|
]),
|
|
});
|
|
}
|
|
|
|
const ARCATRAZ_EVENT_MOBS = Object.freeze([
|
|
{ entry: 20_905, name: "Arcatraz Trickster", level: 70, role: "caster" },
|
|
{ entry: 20_908, name: "Akkiris Lightning-Waker", level: 70, role: "caster" },
|
|
{ entry: 20_910, name: "Twilight Drakonaar", level: 70, role: "melee" },
|
|
] as const satisfies readonly AdaptedMobSpec[]);
|
|
|
|
function buildArcatraz(definition: DungeonDefinition): DungeonDefinition {
|
|
const review = REVIEW_BY_DUNGEON.get(definition.id)!;
|
|
const { entities, idsByEntry } = ensureAdaptedMobs(definition, ARCATRAZ_EVENT_MOBS);
|
|
const originalSkyriss = objectiveForEntry(definition, 20_912, ["Harbinger Skyriss"]);
|
|
const objectiveState = eventBossObjectives(definition, [{
|
|
entry: 20_912,
|
|
names: ["Harbinger Skyriss"],
|
|
position: originalSkyriss.position,
|
|
}]);
|
|
const skyriss = objectiveState.byEntry.get(20_912)!;
|
|
const allIds = new Map([...idsByEntry, ...eventBossEntityIds(entities, [20_912])]);
|
|
const podTemplates = [183_961, 183_963, 183_964, 183_962, 183_965] as const;
|
|
const podIds = podTemplates.map((templateId) => {
|
|
const ids = objectIdsForTemplate(definition, templateId);
|
|
if (ids.length !== 1) throw new Error(`arcatraz requires one source pod ${templateId}`);
|
|
return ids[0];
|
|
});
|
|
const podObjects = podIds.map((id) => definition.serverGameObjects.find((object) => object.id === id)!);
|
|
const shieldIds = objectIdsForTemplate(definition, 184_802);
|
|
const reviewedIds = new Set([...podIds, ...shieldIds]);
|
|
const reviewedObjects = withReviewedGateFallbacks(
|
|
definition.serverGameObjects,
|
|
reviewedIds,
|
|
ARCATRAZ_FILES,
|
|
"Reviewed Arcatraz pod/shield binding. Visible procedural geometry blocks movement and sight while closed, then becomes nonblocking when the source sequence opens it.",
|
|
);
|
|
const startId = "arcatraz:warden-start";
|
|
const start = startObject(
|
|
startId,
|
|
"Warden Mellichar",
|
|
"Begin the containment release",
|
|
[-445.803, 43.6442, -169.007],
|
|
"arcatraz:warden-start",
|
|
ARCATRAZ_FILES,
|
|
);
|
|
const alphaGroup = eventSpawnGroup("arcatraz-pod-alpha", allIds, [{ entry: 20_905, position: podObjects[0].position }]);
|
|
const deltaGroup = eventSpawnGroup("arcatraz-pod-delta", allIds, [{ entry: 20_908, position: podObjects[2].position }]);
|
|
const gammaGroup = eventSpawnGroup("arcatraz-pod-gamma", allIds, [{ entry: 20_910, position: podObjects[3].position }]);
|
|
const skyrissGroup = eventSpawnGroup("arcatraz-skyriss", allIds, [{ entry: 20_912, position: skyriss.position, id: skyriss.id }]);
|
|
const groups = Object.freeze([alphaGroup, deltaGroup, gammaGroup, skyrissGroup]);
|
|
const rules: DungeonEncounterRule[] = [
|
|
{
|
|
id: "arcatraz-start",
|
|
trigger: { kind: "interact", objectId: startId },
|
|
guards: [{ kind: "phase", phases: ["ready"] }],
|
|
actions: [
|
|
{ id: "arcatraz-mark-warden-active", kind: "set-world-object-state", objectId: startId, state: "active" },
|
|
...objectStateActions("arcatraz-close-shield", shieldIds, "closed"),
|
|
{ id: "arcatraz-wait-alpha", kind: "set-phase", phase: "arcatraz-alpha-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-open-alpha", trigger: { kind: "elapsed", afterMs: 5_000 },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-alpha-wait"] }],
|
|
actions: [
|
|
{ id: "arcatraz-open-alpha-pod", kind: "set-world-object-state", objectId: podIds[0], state: "open" },
|
|
{ id: "arcatraz-activate-alpha", kind: "activate-spawn-group", spawnGroupId: alphaGroup.id },
|
|
{ id: "arcatraz-enter-alpha", kind: "set-phase", phase: "arcatraz-alpha-active" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-clear-alpha", trigger: { kind: "entities-dead", entityIds: alphaGroup.spawns.map((spawn) => spawn.id) },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-alpha-active"] }],
|
|
actions: [
|
|
{ id: "arcatraz-deactivate-alpha", kind: "deactivate-spawn-group", spawnGroupId: alphaGroup.id },
|
|
{ id: "arcatraz-wait-beta", kind: "set-phase", phase: "arcatraz-beta-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-release-millhouse", trigger: { kind: "elapsed", afterMs: 5_000 },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-beta-wait"] }],
|
|
actions: [
|
|
{ id: "arcatraz-open-beta-pod", kind: "set-world-object-state", objectId: podIds[1], state: "open" },
|
|
{ id: "arcatraz-wait-delta", kind: "set-phase", phase: "arcatraz-delta-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-open-delta", trigger: { kind: "elapsed", afterMs: 10_000 },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-delta-wait"] }],
|
|
actions: [
|
|
{ id: "arcatraz-open-delta-pod", kind: "set-world-object-state", objectId: podIds[2], state: "open" },
|
|
{ id: "arcatraz-activate-delta", kind: "activate-spawn-group", spawnGroupId: deltaGroup.id },
|
|
{ id: "arcatraz-enter-delta", kind: "set-phase", phase: "arcatraz-delta-active" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-clear-delta", trigger: { kind: "entities-dead", entityIds: deltaGroup.spawns.map((spawn) => spawn.id) },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-delta-active"] }],
|
|
actions: [
|
|
{ id: "arcatraz-deactivate-delta", kind: "deactivate-spawn-group", spawnGroupId: deltaGroup.id },
|
|
{ id: "arcatraz-wait-gamma", kind: "set-phase", phase: "arcatraz-gamma-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-open-gamma", trigger: { kind: "elapsed", afterMs: 8_000 },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-gamma-wait"] }],
|
|
actions: [
|
|
{ id: "arcatraz-open-gamma-pod", kind: "set-world-object-state", objectId: podIds[3], state: "open" },
|
|
{ id: "arcatraz-activate-gamma", kind: "activate-spawn-group", spawnGroupId: gammaGroup.id },
|
|
{ id: "arcatraz-enter-gamma", kind: "set-phase", phase: "arcatraz-gamma-active" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-clear-gamma", trigger: { kind: "entities-dead", entityIds: gammaGroup.spawns.map((spawn) => spawn.id) },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-gamma-active"] }],
|
|
actions: [
|
|
{ id: "arcatraz-deactivate-gamma", kind: "deactivate-spawn-group", spawnGroupId: gammaGroup.id },
|
|
{ id: "arcatraz-wait-omega", kind: "set-phase", phase: "arcatraz-omega-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-open-omega", trigger: { kind: "elapsed", afterMs: 12_000 },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-omega-wait"] }],
|
|
actions: [
|
|
{ id: "arcatraz-open-omega-pod", kind: "set-world-object-state", objectId: podIds[4], state: "open" },
|
|
...objectStateActions("arcatraz-open-shield", shieldIds, "open"),
|
|
{ id: "arcatraz-activate-skyriss", kind: "activate-spawn-group", spawnGroupId: skyrissGroup.id },
|
|
{ id: "arcatraz-enter-skyriss", kind: "set-phase", phase: "arcatraz-skyriss-active" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-clear-skyriss", trigger: { kind: "entities-dead", entityIds: [skyriss.id] },
|
|
guards: [{ kind: "phase", phases: ["arcatraz-skyriss-active"] }],
|
|
actions: [
|
|
{ id: "arcatraz-deactivate-skyriss", kind: "deactivate-spawn-group", spawnGroupId: skyrissGroup.id },
|
|
{ id: "arcatraz-complete-skyriss", kind: "complete-objective", objectiveId: skyriss.id, persistence: "run" },
|
|
{ id: "arcatraz-complete-warden", kind: "set-world-object-state", objectId: startId, state: "complete", persistence: "run" },
|
|
{ id: "arcatraz-enter-complete", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
],
|
|
},
|
|
{
|
|
id: "arcatraz-resume-complete", trigger: { kind: "tick" }, once: false, priority: 20,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: skyriss.id, equals: true }],
|
|
actions: [{ id: "arcatraz-recover-complete", kind: "set-phase", phase: "complete" }],
|
|
},
|
|
{
|
|
id: "arcatraz-resume-fresh", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: skyriss.id, equals: false }],
|
|
actions: [{ id: "arcatraz-recover-ready", kind: "set-phase", phase: "ready" }],
|
|
},
|
|
];
|
|
const encounter: DungeonEncounterDefinition = Object.freeze({
|
|
id: "arcatraz:wow-instance",
|
|
dungeonId: definition.id,
|
|
initialPhase: "ready",
|
|
initialState: {
|
|
worldObjectStates: Object.fromEntries([
|
|
[startId, "ready"], ...podIds.map((id) => [id, "closed"] as const), ...shieldIds.map((id) => [id, "open"] as const),
|
|
]),
|
|
},
|
|
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
|
|
source: sourceReview(review),
|
|
rules: Object.freeze(rules),
|
|
});
|
|
return Object.freeze({
|
|
...definition,
|
|
entities,
|
|
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => entityEntry(definition, spawn) !== 20_912)),
|
|
bosses: objectiveState.bosses,
|
|
objectiveOrder: objectiveState.objectiveOrder,
|
|
encounters: Object.freeze([...(definition.encounters ?? []).filter((value) => value.id !== encounter.id), encounter]),
|
|
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("arcatraz-")), ...groups]),
|
|
serverGameObjects: Object.freeze([...reviewedObjects, start]),
|
|
validation: appendValidationWarnings(definition, [
|
|
"Harbinger Skyriss is now source-gated behind all five visible, blocking Arcatraz pod transitions; friendly Millhouse behavior remains a timed presentation adapter.",
|
|
]),
|
|
});
|
|
}
|
|
|
|
const BLACKROCK_SPIRE_EVENT_MOBS = Object.freeze([
|
|
{ entry: 10_258, name: "Rookery Guardian", level: 57, role: "melee" },
|
|
{ entry: 10_683, name: "Rookery Hatcher", level: 57, role: "caster" },
|
|
] as const satisfies readonly AdaptedMobSpec[]);
|
|
|
|
function buildBlackrockSpire(definition: DungeonDefinition): DungeonDefinition {
|
|
const review = REVIEW_BY_DUNGEON.get(definition.id)!;
|
|
const { entities, idsByEntry } = ensureAdaptedMobs(definition, BLACKROCK_SPIRE_EVENT_MOBS);
|
|
const originalSolakar = objectiveForEntry(definition, 10_264, ["Solakar Flamewreath"]);
|
|
const fatherFlameIds = objectIdsForTemplate(definition, 175_245);
|
|
if (fatherFlameIds.length !== 1) throw new Error("blackrock-spire requires one imported Father Flame object");
|
|
const fatherFlameId = fatherFlameIds[0];
|
|
const fatherFlame = definition.serverGameObjects.find((object) => object.id === fatherFlameId)!;
|
|
const eventCenter: Vector3Tuple = [fatherFlame.position[0], fatherFlame.position[1], fatherFlame.position[2] + 8];
|
|
const objectiveState = eventBossObjectives(definition, [{
|
|
entry: 10_264,
|
|
names: ["Solakar Flamewreath"],
|
|
position: eventCenter,
|
|
}]);
|
|
const solakar = objectiveState.byEntry.get(10_264)!;
|
|
const allIds = new Map([...idsByEntry, ...eventBossEntityIds(entities, [10_264])]);
|
|
const reviewedObjects = definition.serverGameObjects.map((object) => (
|
|
object.id === fatherFlameId
|
|
? reviewedInteractableFallback(
|
|
object,
|
|
BLACKROCK_SPIRE_FILES,
|
|
"Activate Father Flame",
|
|
"blackrock-spire:father-flame-start",
|
|
"available",
|
|
"inactive",
|
|
"Reviewed Father Flame start hook. Visible procedural geometry exposes the source event interaction until its client display is approved.",
|
|
)
|
|
: object
|
|
));
|
|
const waveGroups = Array.from({ length: 5 }, (_, index) => eventSpawnGroup(
|
|
`blackrock-spire:solakar-wave-${index + 1}`,
|
|
allIds,
|
|
[
|
|
{ entry: 10_258, position: [eventCenter[0] - 6, eventCenter[1], eventCenter[2]], id: `blackrock-spire:solakar-wave-${index + 1}:guardian` },
|
|
{ entry: 10_683, position: [eventCenter[0] + 6, eventCenter[1], eventCenter[2]], id: `blackrock-spire:solakar-wave-${index + 1}:hatcher` },
|
|
],
|
|
));
|
|
const solakarGroup = eventSpawnGroup("blackrock-spire:solakar", allIds, [{ entry: 10_264, position: eventCenter, id: solakar.id }]);
|
|
const groups = Object.freeze([...waveGroups, solakarGroup]);
|
|
const rules: DungeonEncounterRule[] = [
|
|
{
|
|
id: "blackrock-spire-start-solakar",
|
|
trigger: { kind: "interact", objectId: fatherFlameId },
|
|
guards: [{ kind: "phase", phases: ["ready"] }],
|
|
actions: [
|
|
{ id: "blackrock-spire-disable-father-flame", kind: "set-world-object-state", objectId: fatherFlameId, state: "inactive" },
|
|
{ id: "blackrock-spire-wait-solakar-wave-1", kind: "set-phase", phase: "solakar-wave-1-wait" },
|
|
],
|
|
},
|
|
{
|
|
id: "blackrock-spire-resume-solakar-complete", trigger: { kind: "tick" }, once: false, priority: 20,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: solakar.id, equals: true }],
|
|
actions: [{ id: "blackrock-spire-recover-solakar-complete", kind: "set-phase", phase: "complete" }],
|
|
},
|
|
{
|
|
id: "blackrock-spire-resume-solakar-fresh", trigger: { kind: "tick" }, once: false,
|
|
guards: [{ kind: "phase", phases: ["resume"] }, { kind: "objective-complete", objectiveId: solakar.id, equals: false }],
|
|
actions: [{ id: "blackrock-spire-recover-solakar-ready", kind: "set-phase", phase: "ready" }],
|
|
},
|
|
];
|
|
waveGroups.forEach((group, index) => {
|
|
const wave = index + 1;
|
|
rules.push({
|
|
id: `blackrock-spire-open-solakar-wave-${wave}`,
|
|
trigger: { kind: "elapsed", afterMs: wave === 1 ? 500 : 30_000 },
|
|
guards: [{ kind: "phase", phases: [`solakar-wave-${wave}-wait`] }],
|
|
actions: [
|
|
{ id: `blackrock-spire-activate-solakar-wave-${wave}`, kind: "activate-spawn-group", spawnGroupId: group.id },
|
|
{ id: `blackrock-spire-record-solakar-wave-${wave}`, kind: "set-counter", counterId: "solakar-wave", value: wave },
|
|
{ id: `blackrock-spire-wait-after-solakar-wave-${wave}`, kind: "set-phase", phase: wave < 5 ? `solakar-wave-${wave + 1}-wait` : "solakar-boss-wait" },
|
|
],
|
|
});
|
|
});
|
|
rules.push(
|
|
{
|
|
id: "blackrock-spire-open-solakar-boss",
|
|
trigger: { kind: "elapsed", afterMs: 30_000 },
|
|
guards: [{ kind: "phase", phases: ["solakar-boss-wait"] }],
|
|
actions: [
|
|
{ id: "blackrock-spire-activate-solakar", kind: "activate-spawn-group", spawnGroupId: solakarGroup.id },
|
|
{ id: "blackrock-spire-enter-solakar", kind: "set-phase", phase: "solakar-active" },
|
|
],
|
|
},
|
|
{
|
|
id: "blackrock-spire-clear-solakar",
|
|
trigger: { kind: "entities-dead", entityIds: [solakar.id] },
|
|
guards: [{ kind: "phase", phases: ["solakar-active"] }],
|
|
actions: [
|
|
{ id: "blackrock-spire-deactivate-solakar", kind: "deactivate-spawn-group", spawnGroupId: solakarGroup.id },
|
|
...waveGroups.map((group, index) => ({
|
|
id: `blackrock-spire-cleanup-solakar-wave-${index + 1}`,
|
|
kind: "deactivate-spawn-group" as const,
|
|
spawnGroupId: group.id,
|
|
})),
|
|
{ id: "blackrock-spire-complete-solakar", kind: "complete-objective", objectiveId: solakar.id, persistence: "run" },
|
|
{ id: "blackrock-spire-complete-father-flame", kind: "set-world-object-state", objectId: fatherFlameId, state: "inactive", persistence: "run" },
|
|
{ id: "blackrock-spire-enter-solakar-complete", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
],
|
|
},
|
|
);
|
|
const encounter: DungeonEncounterDefinition = Object.freeze({
|
|
id: "blackrock-spire:wow-instance",
|
|
dungeonId: definition.id,
|
|
initialPhase: "ready",
|
|
initialState: {
|
|
counters: { "solakar-wave": 0 },
|
|
worldObjectStates: { [fatherFlameId]: "available" },
|
|
},
|
|
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
|
|
source: sourceReview(review),
|
|
rules: Object.freeze(rules),
|
|
});
|
|
return Object.freeze({
|
|
...definition,
|
|
entities,
|
|
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => entityEntry(definition, spawn) !== 10_264)),
|
|
bosses: objectiveState.bosses,
|
|
objectiveOrder: objectiveState.objectiveOrder,
|
|
encounters: Object.freeze([...(definition.encounters ?? []).filter((value) => value.id !== encounter.id), encounter]),
|
|
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("blackrock-spire:solakar")), ...groups]),
|
|
serverGameObjects: Object.freeze(reviewedObjects),
|
|
validation: appendValidationWarnings(definition, [
|
|
"Solakar is now source-gated behind five overlapping 30-second Rookery waves; killing him explicitly cleans up surviving event mobs.",
|
|
]),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replaces unsafe static event-boss fallbacks with reviewed playable WoW
|
|
* instance adapters. Generated package files remain immutable.
|
|
*/
|
|
export function withWowEncounterContent(definition: DungeonDefinition): DungeonDefinition {
|
|
if (definition.encounters?.some((encounter) => encounter.id === `${definition.id}:wow-instance`)) {
|
|
return definition;
|
|
}
|
|
if (definition.id === "black-morass") return buildBlackMorass(definition);
|
|
if (definition.id === "violet-hold") return buildVioletHold(definition);
|
|
if (definition.id === "culling-of-stratholme") return buildCullingOfStratholme(definition);
|
|
if (definition.id === "escape-from-durnholde") return buildEscapeFromDurnholde(definition);
|
|
if (definition.id === "arcatraz") return buildArcatraz(definition);
|
|
if (definition.id === "blackrock-spire") return buildBlackrockSpire(definition);
|
|
return definition;
|
|
}
|