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

1648 lines
80 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";
import type { EnemyAttackDefinition } from "./enemyAttacks";
export interface WowWrathEncounterSourceFile {
readonly path: string;
readonly sha256: string;
readonly anchors: readonly string[];
}
export interface WowWrathEncounterContentReview {
readonly dungeonId: "halls-of-reflection" | "the-oculus" | "trial-of-the-champion";
readonly fidelity: "source-backed-adapted";
readonly sourceFiles: readonly WowWrathEncounterSourceFile[];
readonly reviewedBehavior: string;
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
}
interface AdaptedEntitySpec {
readonly id?: string;
readonly entry: number;
readonly name: string;
readonly level: number;
readonly role: "melee" | "caster";
readonly kind?: "mob" | "boss";
readonly title?: string;
readonly hasLoot?: boolean;
readonly attacks?: readonly EnemyAttackDefinition[];
readonly basicAttackStyle?: "melee" | "spell-proxy";
}
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
const HALLS_OF_REFLECTION_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/FrozenHalls/HallsOfReflection/instance_halls_of_reflection.cpp`,
`${SOURCE_ROOT}/Northrend/FrozenHalls/HallsOfReflection/halls_of_reflection.cpp`,
`${SOURCE_ROOT}/Northrend/FrozenHalls/HallsOfReflection/halls_of_reflection.h`,
]);
const OCULUS_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/Nexus/Oculus/instance_oculus.cpp`,
`${SOURCE_ROOT}/Northrend/Nexus/Oculus/boss_urom.cpp`,
`${SOURCE_ROOT}/Northrend/Nexus/Oculus/boss_eregos.cpp`,
`${SOURCE_ROOT}/Northrend/Nexus/Oculus/oculus.h`,
]);
const TRIAL_OF_THE_CHAMPION_FILES = Object.freeze([
`${SOURCE_ROOT}/Northrend/CrusadersColiseum/TrialOfTheChampion/instance_trial_of_the_champion.cpp`,
`${SOURCE_ROOT}/Northrend/CrusadersColiseum/TrialOfTheChampion/boss_grand_champions.cpp`,
`${SOURCE_ROOT}/Northrend/CrusadersColiseum/TrialOfTheChampion/boss_argent_challenge.cpp`,
`${SOURCE_ROOT}/Northrend/CrusadersColiseum/TrialOfTheChampion/boss_black_knight.cpp`,
`${SOURCE_ROOT}/Northrend/CrusadersColiseum/TrialOfTheChampion/trial_of_the_champion.h`,
]);
export const WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS: readonly WowWrathEncounterContentReview[] = Object.freeze([
{
dungeonId: "halls-of-reflection",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: HALLS_OF_REFLECTION_FILES[0],
sha256: "4b274ef9a3042c478e54e2b2ed87bd7902e4304d2c6ade342ae7635916c00e66",
anchors: [
"OnGameObjectCreate front/Arthas doors (lines 317-331)",
"SetBossState Falric pause and Marwyn unlock (lines 337-376)",
"StartNextWave ten-wave ordering (lines 713-779)",
"HandleWaveWipe and resume (lines 781-817)",
],
},
{
path: HALLS_OF_REFLECTION_FILES[1],
sha256: "483de89e772ab41667ace738fc3ece73b1532aa0ca80a523a4350318e85b5dce",
anchors: [
"escape wall completion lifecycle (lines 1531-1543)",
"Lich King pursuit failure pressure (lines 1617-1653)",
"four ordered escape summon schedules (lines 1690-1721)",
],
},
{
path: HALLS_OF_REFLECTION_FILES[2],
sha256: "023932e8ced7532f857516c3d8688462ef961028ded128b52ae963a3cfa22452",
anchors: [
"wave creature and door ids (lines 55-106)",
"allowedCompositions (lines 316-325)",
"WP_STOP and PathWaypoints (lines 353-376)",
],
},
],
reviewedBehavior: "A finite event preserves the source 4 trash waves, Falric, 4 trash waves, Marwyn, Frostsworn General, and four ordered escape-wall stands before completion.",
adaptations: [
"The deterministic composition order is one valid outcome of AzerothCore's paired random selection and shuffle.",
"The Lich King chase is represented by four bounded combat stands and exact source wall-stop coordinates; clearing a stand advances the whole party because protected escort/path pursuit primitives are not available.",
"Source front/Arthas doors are cloned as visible procedural blockers and dynamic ice walls use source template 201385 at source stop coordinates; the boxes are explicit temporary geometry, not exported client models.",
"The Frostsworn General and five Spiritual Reflections fight together instead of copying current player appearances after engagement.",
],
remainingGaps: [
"Jaina/Sylvanas escort movement, Lich King distance pressure, leader health/failure, Remorseless Winter, and behind-the-pursuer instant death need protected-escort and moving-hazard support.",
"Faction-specific intro/outro, dialogue, gunship transport, quest credit, achievements, loot presentation, and native door/ice-wall models remain unported.",
],
},
{
dungeonId: "the-oculus",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: OCULUS_FILES[0],
sha256: "55329af928c05aecf8d1b03887651dea82812dd1ffb32f887eb85f0f5ae2bc54",
anchors: [
"Drakos opens three dragon cage doors (lines 89-107)",
"Drakos enables ten-construct count (lines 116-149)",
"Varos/Urom/Eregos attackability gates (lines 151-180)",
"ten Centrifuge Constructs remove Varos shield (lines 182-194)",
],
},
{
path: OCULUS_FILES[1],
sha256: "e09f7bd1ad478467bd41de119088d4e2aab5da4cfa7e3ddee4fb54c43a232238",
anchors: [
"three exact menagerie compositions (lines 36-76)",
"three outer platform and final Urom coordinates (lines 78-87)",
"summon, disappear, and next-platform lifecycle (lines 144-188, 241-274)",
],
},
{
path: OCULUS_FILES[2],
sha256: "d0bab24e54ad84484eaaed0dfe37d6f9b7bb962faa848e621fd56dc2077544b1",
anchors: [
"Urom prerequisite and Eregos reset gate (lines 84-96)",
"Eregos victory/cache handoff (lines 128-141)",
"Planar Shift and whelp schedules (lines 190-223)",
],
},
{
path: OCULUS_FILES[3],
sha256: "0e51c1b9fde0bb7b408e5b96557e2c96fe491e747297ce16c02b02735f7f1f29",
anchors: ["boss, construct, drake, cage-door, and cache ids (lines 32-86)"],
},
],
reviewedBehavior: "Drakos gates the exact ten imported Centrifuge Constructs and cage doors; Varos gates three exact Urom menageries; Urom gates Eregos.",
adaptations: [
"Party teleports between exact source platform coordinates replace player-controlled drake vehicles and free vertical flight.",
"Urom's three summon-and-disappear platform interactions are finite spawn groups followed by his final ground fight.",
"The three exact imported dragon cage doors are cloned as visible procedural blockers with source closed/open semantics; all unrelated imported objects remain untouched and hidden/nonblocking.",
"Eregos remains a direct party fight, so drake role abilities and void-achievement loadouts are not required to make the instance completable.",
],
remainingGaps: [
"Ruby/Emerald/Amber drake vehicles, free flight, player-selected drake composition, and vehicle-specific Eregos tuning require a vehicle runtime.",
"Varos construct beam visuals, Urom teleport presentation, Eregos Planar Shift/anomalies, dialogue, achievements, and cache loot presentation remain incomplete.",
],
},
{
dungeonId: "trial-of-the-champion",
fidelity: "source-backed-adapted",
sourceFiles: [
{
path: TRIAL_OF_THE_CHAMPION_FILES[0],
sha256: "85b569490763e207366e9a04741dfd3bba5e176725b2b953416d395759bca7a0",
anchors: [
"source summon position and known source TODOs (lines 25-35)",
"three distinct random champion selection (lines 467-505)",
"three minion waves, mounted champions, then foot champions (lines 550-626, 819-968)",
"nine Argent soldiers then Eadric/Paletress (lines 984-1083)",
"Black Knight arrival and attack scene (lines 1122-1217)",
],
},
{
path: TRIAL_OF_THE_CHAMPION_FILES[1],
sha256: "c7c517be4a355099574fee16c7e9ad1d0668f3d96f2213edb350629dd928bcb5",
anchors: [
"mounted Charge, Shield Breaker, and Thrust scheduling (lines 339-357, 724-785)",
"Mokra, Eressea, and Runok foot-combat schedules (lines 413-449, 790-926)",
],
},
{
path: TRIAL_OF_THE_CHAMPION_FILES[2],
sha256: "45376430d55e09cb7c37e1faea5493b154c028cd539d87dba290ead8716afb81",
anchors: [
"Eadric Vengeance and initial Radiance/Hammer schedule (lines 108-117)",
"Radiance and Hammer repeat schedules (lines 156-180)",
],
},
{
path: TRIAL_OF_THE_CHAMPION_FILES[3],
sha256: "e7d16a6973e3bfe56f4eaf70ee8f0bebddc193ee20f7dd8eafd0dd3b5fe1827a",
anchors: [
"three phase spell sets and model ids (lines 28-82)",
"lethal damage becomes resurrection for phases one/two (lines 122-146)",
"skeleton then ghost transitions (lines 175-216)",
"phase-specific initial and repeating combat schedules (lines 160-273)",
],
},
{
path: TRIAL_OF_THE_CHAMPION_FILES[4],
sha256: "4e6078e4dea2c3de5910649d044bda941d2033968cd88e9c20e493d716d4da7e",
anchors: [
"three encounter and ordered instance-progress states (lines 27-61)",
"champion/minion/Argent/Black Knight ids (lines 104-147)",
"main and arena gate ids (lines 167-181)",
],
},
],
reviewedBehavior: "A deterministic valid Horde champion roster runs three 3-add opening waves, a mounted-combat proxy, the same three champions on foot, nine Argent soldiers, Eadric, and all three Black Knight forms.",
adaptations: [
"Mokra, Eressea, and Runok are a deterministic valid three-of-five source roster because party faction/seed selection is not available in dungeon definitions.",
"Mounted combat is a separate champion combat pass using ordinary actors with the source Charge, Shield Breaker, and Thrust schedules; defeating it advances to the source foot-combat pass without replacement mounts or player jousting.",
"Eadric is the deterministic valid Argent Challenge branch and uses his source Vengeance, Radiance, and hammer schedule; Vengeance preserves the source 66865 -> 66889 chain, where each successful main-hand auto grants a six-second, one-charge +100% critical chance modifier for the next main-hand auto; the triggered Hammer of Justice plus Hammer of the Righteous pair is one compound damage/control release on the same target, collapsing the source's pre-cast stun staging into the hammer resolution.",
"Black Knight resurrection/model changes are three finite combat actors at the exact source combat position, each with the source phase-specific spell schedule, preserving phase order without in-place creature transformation.",
"The exact imported main/north gates are cloned as visible procedural blockers with reviewed state changes; the boxes are temporary geometry pending native models.",
],
remainingGaps: [
"Player mounts, lance abilities, defend layers, mount replacement, faction-derived roster selection, mounted threat resets, and spectators require vehicle and presentation systems.",
"Radiance's source facing check and blind/confuse wandering path are represented by an all-nearby generic confuse action lock; Eadric hammer throw-back, Black Knight disease/ground-area/stack exactness and ghoul AI, Paletress/Memory seeded selection, full announcer RP, the gryphon/announcer murder scene, achievements, and encounter chests remain unported.",
],
},
]);
const REVIEW_BY_DUNGEON = new Map(
WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS.map((review) => [review.dungeonId, review] as const),
);
const PACK_OFFSETS = Object.freeze([
[0, 0],
[4, 0],
[-4, 0],
[0, 4],
[0, -4],
[4, 4],
[-4, 4],
[4, -4],
[-4, -4],
] as const);
function sourceMapPosition(source: readonly [x: number, y: number, z: number]): Vector3Tuple {
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(
`Wrath encounter content for ${definition.id} requires three(x,y,z)=(wow.y,wow.z,wow.x), got ${definition.provenance.coordinateTransform}`,
);
}
}
function entityIdForEntry(entities: PopulationDefinitionMap, entry: number): string | undefined {
return Object.entries(entities).find(([, entity]) => entity.combat?.serverEntry === entry)?.[0];
}
function createAdaptedEntity(spec: AdaptedEntitySpec): PopulationEntityDefinition {
const caster = spec.role === "caster";
const spellProxy = spec.basicAttackStyle === "spell-proxy"
|| (spec.basicAttackStyle === undefined && caster);
const base = {
id: spec.id ?? `wrath-event-entry-${spec.entry}`,
name: spec.name,
hasLoot: spec.hasLoot ?? false,
combat: {
serverEntry: spec.entry,
level: spec.level,
healthMultiplier: spec.kind === "boss" ? 4.2 : caster ? 0.9 : 1.08,
damageMultiplier: spec.kind === "boss" ? 1.18 : 0.92,
moveSpeed: 3.65,
aggroRange: 32,
leashRange: 90,
mechanicIds: Object.freeze((spec.attacks ?? []).map((attack) => attack.id)),
attacks: Object.freeze([
{
id: "basic-attack",
name: spellProxy ? "Source Spell Proxy" : "Basic Attack",
delivery: spellProxy ? "projectile" as const : "melee" as const,
target: "primary" as const,
school: spellProxy ? "arcane" as const : "physical" as const,
animation: spellProxy ? "cast" as const : "attack" as const,
range: spellProxy ? 28 : 4.5,
cooldownMs: spellProxy ? 3_000 : 2_000,
damageMultiplier: spellProxy ? 0.82 : 1,
},
...(spec.attacks ?? []),
]),
},
visual: {
primaryColor: caster ? "#536a8e" : "#684b54",
accentColor: caster ? "#a8d7f4" : "#d7a08f",
scale: spec.kind === "boss" ? 1.16 : 1,
archetype: "humanoid" as const,
},
};
if (spec.kind === "boss") {
return Object.freeze({ ...base, kind: "boss" as const, title: spec.title ?? "Dungeon Boss" });
}
return Object.freeze({ ...base, kind: "mob" as const });
}
function ensureEntities(
definition: DungeonDefinition,
specs: readonly AdaptedEntitySpec[],
): PopulationDefinitionMap {
const entities: Record<string, PopulationEntityDefinition> = { ...definition.entities };
for (const spec of specs) {
const id = spec.id ?? `wrath-event-entry-${spec.entry}`;
if (entities[id]) continue;
if (!spec.id) {
const existing = entityIdForEntry(entities, spec.entry);
if (existing) continue;
}
entities[id] = createAdaptedEntity(spec);
}
return Object.freeze(entities);
}
function entryForSpawn(definition: DungeonDefinition, spawn: StaticMobSpawnDefinition): number | undefined {
return definition.entities[spawn.entityId]?.combat?.serverEntry;
}
function objectiveForEntry(definition: DungeonDefinition, entry: number): DungeonBossObjective {
const spawnIds = new Set(definition.staticSpawns
.filter((spawn) => entryForSpawn(definition, spawn) === entry)
.map((spawn) => spawn.id));
const objective = definition.bosses.find((boss) => spawnIds.has(boss.id));
if (!objective) throw new Error(`${definition.id} is missing boss objective for creature entry ${entry}`);
return objective;
}
function objectiveForName(definition: DungeonDefinition, name: string): DungeonBossObjective {
const objective = definition.bosses.find((boss) => boss.name.toLowerCase() === name.toLowerCase());
if (!objective) throw new Error(`${definition.id} is missing boss objective ${name}`);
return objective;
}
function spawnAt(
id: string,
entityId: string,
center: Vector3Tuple,
offset: readonly [x: number, z: number] = [0, 0],
): StaticMobSpawnDefinition {
const position: Vector3Tuple = [center[0] + offset[0], center[1], center[2] + offset[1]];
return Object.freeze({
id,
entityId,
position,
yaw: 0,
spawnMask: 3,
});
}
function startObject(
id: string,
name: string,
prompt: string,
position: Vector3Tuple,
sourceFiles: readonly string[],
initialState: "ready" | "hidden" = "ready",
): DungeonWorldObjectDefinition {
return Object.freeze({
id,
name,
kind: "npc",
position,
halfExtents: [0.8, 1.7, 0.8] as const,
initialState,
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: 4,
signalId: `${id}:start`,
allowedStates: ["ready"],
oneShot: true,
},
visual: { color: "#a78bd4", emissive: "#302044" },
source: {
fidelity: "source-backed-adapted" as const,
files: sourceFiles,
note: "A compact procedural interaction represents the source proximity/gossip start.",
},
});
}
function cloneReviewedDoors(
definition: DungeonDefinition,
templateIds: readonly number[],
prefix: string,
sourceFiles: readonly string[],
): readonly DungeonWorldObjectDefinition[] {
const templates = new Set(templateIds);
return Object.freeze(definition.serverGameObjects
.filter((object) => object.source?.templateId !== undefined && templates.has(object.source.templateId))
.map((object, index): DungeonWorldObjectDefinition => {
const source = object.source!;
return Object.freeze({
...object,
id: `${prefix}-${index + 1}`,
states: [
{ id: "closed", label: "Closed", visible: true, blocksMovement: true, blocksSight: true, interactionEnabled: false },
{ id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false, interactionEnabled: false },
],
visual: { color: "#65758a", emissive: "#111b29" },
source: {
...source,
fidelity: "source-backed-adapted" as const,
files: sourceFiles,
note: "Exact source transform and bounds are retained. A visible procedural blocker temporarily replaces the unexported client model.",
},
});
}));
}
function withoutReviewedDoorSources(
definition: DungeonDefinition,
reviewedDoors: readonly DungeonWorldObjectDefinition[],
): readonly DungeonWorldObjectDefinition[] {
const reviewedSpawnIds = new Set(reviewedDoors
.map((door) => door.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined));
return definition.serverGameObjects.filter(
(object) => object.source?.spawnId === undefined || !reviewedSpawnIds.has(object.source.spawnId),
);
}
function authoredIceWall(
index: number,
position: Vector3Tuple,
): DungeonWorldObjectDefinition {
return Object.freeze({
id: `halls-of-reflection:ice-wall-${index}`,
name: `Ice Wall ${index}`,
kind: "door",
position,
halfExtents: [7, 5, 1.2] as const,
initialState: "hidden",
states: [
{ id: "hidden", label: "Not summoned", visible: false, blocksMovement: false, blocksSight: false },
{ id: "closed", label: "Blocking the escape", visible: true, blocksMovement: true, blocksSight: true },
{ id: "open", label: "Destroyed", visible: false, blocksMovement: false, blocksSight: false },
],
visual: { color: "#a6d7e8", emissive: "#244c61" },
source: {
fidelity: "source-backed-adapted" as const,
files: HALLS_OF_REFLECTION_FILES,
templateId: 201_385,
note: "Dynamic GO_ICE_WALL is represented by a procedural collision box at the exact source escort stop.",
},
});
}
function sourceReview(
review: WowWrathEncounterContentReview,
): DungeonEncounterDefinition["source"] {
return {
fidelity: review.fidelity,
scope: "instance-orchestration",
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`
&& encounter.id !== `${definition.id}:wrath-instance`
));
}
function appendValidationWarnings(
definition: DungeonDefinition,
warnings: readonly string[],
): DungeonDefinition["validation"] {
return Object.freeze({
...definition.validation,
warnings: Object.freeze([...new Set([...definition.validation.warnings, ...warnings])]),
});
}
function deactivateAndWaitActions(
prefix: string,
groupId: string,
nextPhase: string,
): DungeonEncounterAction[] {
return [
{ id: `${prefix}-deactivate`, kind: "deactivate-spawn-group", spawnGroupId: groupId },
{ id: `${prefix}-wait`, kind: "set-phase", phase: nextPhase },
];
}
const HOR_WAVE_ENTRIES = Object.freeze({
priest: 38_175,
mage: 38_172,
mercenary: 38_177,
footman: 38_173,
rifleman: 38_176,
});
const HOR_WAVE_COMPOSITIONS = Object.freeze([
[HOR_WAVE_ENTRIES.mercenary, HOR_WAVE_ENTRIES.priest, HOR_WAVE_ENTRIES.rifleman],
[HOR_WAVE_ENTRIES.mage, HOR_WAVE_ENTRIES.rifleman, HOR_WAVE_ENTRIES.footman],
[HOR_WAVE_ENTRIES.mercenary, HOR_WAVE_ENTRIES.priest, HOR_WAVE_ENTRIES.footman, HOR_WAVE_ENTRIES.footman],
[HOR_WAVE_ENTRIES.mage, HOR_WAVE_ENTRIES.priest, HOR_WAVE_ENTRIES.footman, HOR_WAVE_ENTRIES.footman],
[HOR_WAVE_ENTRIES.mercenary, HOR_WAVE_ENTRIES.mage, HOR_WAVE_ENTRIES.rifleman, HOR_WAVE_ENTRIES.footman, HOR_WAVE_ENTRIES.footman],
[HOR_WAVE_ENTRIES.mercenary, HOR_WAVE_ENTRIES.mage, HOR_WAVE_ENTRIES.priest, HOR_WAVE_ENTRIES.rifleman, HOR_WAVE_ENTRIES.rifleman],
[HOR_WAVE_ENTRIES.mercenary, HOR_WAVE_ENTRIES.mage, HOR_WAVE_ENTRIES.priest, HOR_WAVE_ENTRIES.rifleman, HOR_WAVE_ENTRIES.footman],
[HOR_WAVE_ENTRIES.mercenary, HOR_WAVE_ENTRIES.mage, HOR_WAVE_ENTRIES.priest, HOR_WAVE_ENTRIES.footman, HOR_WAVE_ENTRIES.footman],
] as const);
const HOR_ESCAPE_STOPS = Object.freeze([
sourceMapPosition([5558.182617, 2103.950928, 731.263]),
sourceMapPosition([5513.573242, 1996.611206, 735.115723]),
sourceMapPosition([5456.632324, 1902.801025, 747.220886]),
sourceMapPosition([5340.560059, 1772.791016, 766.478149]),
]);
const HOR_ESCAPE_COMPOSITIONS = Object.freeze([
[36_940, 36_940, 36_940, 36_940, 36_940, 36_940, 36_941],
[37_069, 36_940, 36_940, 36_940, 36_940, 36_940, 36_940, 36_941, 36_941],
[37_069, 37_069, 36_940, 36_940, 36_940, 36_940, 36_940, 36_940, 36_941, 36_941],
[37_069, 37_069, 37_069, 36_940, 36_940, 36_940, 36_940, 36_940, 36_940, 36_941, 36_941, 36_941, 36_941],
] as const);
function buildHallsOfReflection(definition: DungeonDefinition): DungeonDefinition {
assertSourceMapTransform(definition);
const review = REVIEW_BY_DUNGEON.get("halls-of-reflection")!;
const falric = objectiveForEntry(definition, 38_112);
const marwyn = objectiveForEntry(definition, 38_113);
const waveStart = startObject(
"halls-of-reflection:wave-start",
"The Hall's Restless Spirits",
"Begin the reflection waves",
sourceMapPosition([5309.459473, 2006.478516, 711.595459]),
HALLS_OF_REFLECTION_FILES,
);
const escapeStart = startObject(
"halls-of-reflection:escape-start",
"Jaina / Sylvanas",
"Begin the escape from the Lich King",
sourceMapPosition([5576.80566, 2235.55004, 733.012268]),
HALLS_OF_REFLECTION_FILES,
"hidden",
);
const doors = cloneReviewedDoors(
definition,
[201_976, 197_341],
"halls-of-reflection:reviewed-door",
HALLS_OF_REFLECTION_FILES,
);
const frontDoors = doors.filter((door) => door.source?.templateId === 201_976);
const arthasDoors = doors.filter((door) => door.source?.templateId === 197_341);
const iceWalls = HOR_ESCAPE_STOPS.map((position, index) => authoredIceWall(index + 1, position));
const entities = ensureEntities(definition, [
{ entry: 38_175, name: "Phantom Priest", level: 80, role: "caster" },
{ entry: 38_172, name: "Phantom Mage", level: 80, role: "caster" },
{ entry: 38_177, name: "Shadowy Mercenary", level: 80, role: "melee" },
{ entry: 38_173, name: "Spectral Footman", level: 80, role: "melee" },
{ entry: 38_176, name: "Tortured Rifleman", level: 80, role: "caster" },
{ entry: 36_940, name: "Raging Ghoul", level: 80, role: "melee" },
{ entry: 36_941, name: "Risen Witch Doctor", level: 80, role: "caster" },
{ entry: 37_069, name: "Lumbering Abomination", level: 80, role: "melee" },
]);
const idFor = (entry: number): string => {
const id = entityIdForEntry(entities, entry);
if (!id) throw new Error(`halls-of-reflection is missing event creature entry ${entry}`);
return id;
};
const groups: DungeonEncounterSpawnGroupDefinition[] = [];
const waveGroupIds: string[] = [];
const hallCenter = sourceMapPosition([5309.459473, 2006.478516, 711.595459]);
HOR_WAVE_COMPOSITIONS.forEach((entries, index) => {
const sourceWave = index < 4 ? index + 1 : index + 2;
const groupId = `halls-of-reflection:wave-${String(sourceWave).padStart(2, "0")}`;
waveGroupIds.push(groupId);
groups.push(Object.freeze({
id: groupId,
spawns: Object.freeze(entries.map((entry, memberIndex) => spawnAt(
`${groupId}:mob-${memberIndex + 1}`,
idFor(entry),
hallCenter,
PACK_OFFSETS[memberIndex],
))),
}));
});
const falricGroupId = "halls-of-reflection:falric";
const marwynGroupId = "halls-of-reflection:marwyn";
groups.push(
Object.freeze({ id: falricGroupId, spawns: Object.freeze([spawnAt(falric.id, idFor(38_112), falric.position)]) }),
Object.freeze({ id: marwynGroupId, spawns: Object.freeze([spawnAt(marwyn.id, idFor(38_113), marwyn.position)]) }),
);
const generalSpawns = definition.staticSpawns.filter((spawn) => {
const entry = entryForSpawn(definition, spawn);
return entry === 36_723 || entry === 37_068;
});
if (!generalSpawns.some((spawn) => entryForSpawn(definition, spawn) === 36_723)) {
throw new Error("halls-of-reflection is missing the source Frostsworn General spawn");
}
const generalGroupId = "halls-of-reflection:frostsworn-general";
groups.push(Object.freeze({ id: generalGroupId, spawns: Object.freeze(generalSpawns) }));
const escapeGroupIds: string[] = [];
HOR_ESCAPE_COMPOSITIONS.forEach((entries, index) => {
const groupId = `halls-of-reflection:escape-${index + 1}`;
escapeGroupIds.push(groupId);
groups.push(Object.freeze({
id: groupId,
spawns: Object.freeze(entries.map((entry, memberIndex) => spawnAt(
`${groupId}:mob-${memberIndex + 1}`,
idFor(entry),
HOR_ESCAPE_STOPS[index],
PACK_OFFSETS[memberIndex % PACK_OFFSETS.length],
))),
}));
});
const escapeObjective: DungeonBossObjective = Object.freeze({
id: "halls-of-reflection:escape-objective",
name: "Escape from the Lich King",
position: sourceMapPosition([5262.773926, 1669.980103, 784.301697]),
eventOnly: true,
});
const updatedFalric = Object.freeze({ ...falric, eventOnly: true });
const updatedMarwyn = Object.freeze({ ...marwyn, eventOnly: true });
const objectiveReplacements = new Map<string, DungeonBossObjective>([
[falric.id, updatedFalric],
[marwyn.id, updatedMarwyn],
]);
const rules: DungeonEncounterRule[] = [
{
id: "start-reflection-waves",
trigger: { kind: "interact", objectId: waveStart.id },
guards: [{ kind: "phase", phases: ["ready"] }],
actions: [
{ id: "mark-wave-start-active", kind: "set-world-object-state", objectId: waveStart.id, state: "active" },
...frontDoors.map((door, index) => ({ id: `close-front-door-${index + 1}`, kind: "set-world-object-state" as const, objectId: door.id, state: "closed" })),
{ id: "activate-hor-wave-01", kind: "activate-spawn-group", spawnGroupId: waveGroupIds[0] },
{ id: "enter-hor-wave-01", kind: "set-phase", phase: "wave-01-active" },
],
},
{
id: "resume-hor-fresh",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: falric.id, equals: false },
],
actions: [
{ id: "resume-mark-wave-start-active", kind: "set-world-object-state", objectId: waveStart.id, state: "active" },
...frontDoors.map((door, index) => ({ id: `resume-close-front-door-${index + 1}`, kind: "set-world-object-state" as const, objectId: door.id, state: "closed" })),
{ id: "resume-activate-hor-wave-01", kind: "activate-spawn-group", spawnGroupId: waveGroupIds[0] },
{ id: "resume-enter-hor-wave-01", kind: "set-phase", phase: "wave-01-active" },
],
},
{
id: "resume-hor-after-falric",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: falric.id, equals: true },
{ kind: "objective-complete", objectiveId: marwyn.id, equals: false },
],
actions: [
{ id: "resume-falric-mark-wave-start-active", kind: "set-world-object-state", objectId: waveStart.id, state: "active" },
...frontDoors.map((door, index) => ({ id: `resume-falric-close-front-door-${index + 1}`, kind: "set-world-object-state" as const, objectId: door.id, state: "closed" })),
{ id: "resume-activate-hor-wave-06", kind: "activate-spawn-group", spawnGroupId: waveGroupIds[4] },
{ id: "resume-enter-hor-wave-06", kind: "set-phase", phase: "wave-06-active" },
],
},
{
id: "resume-hor-general",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: marwyn.id, equals: true },
{ kind: "flag", flagId: "frostsworn-general-complete", equals: false },
],
actions: [
{ id: "resume-mark-wave-start-complete", kind: "set-world-object-state", objectId: waveStart.id, state: "complete" },
...frontDoors.map((door, index) => ({ id: `resume-open-front-door-${index + 1}`, kind: "set-world-object-state" as const, objectId: door.id, state: "open" })),
...arthasDoors.map((door, index) => ({ id: `resume-open-arthas-door-${index + 1}`, kind: "set-world-object-state" as const, objectId: door.id, state: "open" })),
{ id: "resume-activate-frostsworn-general", kind: "activate-spawn-group", spawnGroupId: generalGroupId },
{ id: "resume-enter-frostsworn-general", kind: "set-phase", phase: "general-active" },
],
},
{
id: "resume-hor-escape",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "frostsworn-general-complete", equals: true },
{ kind: "objective-complete", objectiveId: escapeObjective.id, equals: false },
],
actions: [
{ id: "resume-ready-escape-start", kind: "set-world-object-state", objectId: escapeStart.id, state: "ready" },
{ id: "resume-enter-escape-ready", kind: "set-phase", phase: "escape-ready" },
],
},
];
const sourceWaveNumbers = [1, 2, 3, 4, 6, 7, 8, 9] as const;
sourceWaveNumbers.forEach((wave, index) => {
const groupId = waveGroupIds[index];
const group = groups.find((candidate) => candidate.id === groupId)!;
const bossNext = wave === 4 || wave === 9;
const nextWave = wave === 4 ? 5 : wave === 9 ? 10 : wave + 1;
const nextPhase = `wave-${String(nextWave).padStart(2, "0")}-wait`;
rules.push({
id: `clear-hor-wave-${String(wave).padStart(2, "0")}`,
trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id), timeoutMs: 600_000 },
guards: [{ kind: "phase", phases: [`wave-${String(wave).padStart(2, "0")}-active`] }],
actions: deactivateAndWaitActions(`clear-hor-wave-${String(wave).padStart(2, "0")}`, groupId, nextPhase),
});
const nextGroupId = bossNext
? (wave === 4 ? falricGroupId : marwynGroupId)
: waveGroupIds[index + 1];
rules.push({
id: `start-hor-wave-${String(nextWave).padStart(2, "0")}`,
trigger: { kind: "elapsed", afterMs: 5_000 },
guards: [{ kind: "phase", phases: [nextPhase] }],
actions: [
{ id: `activate-hor-wave-${String(nextWave).padStart(2, "0")}`, kind: "activate-spawn-group", spawnGroupId: nextGroupId },
{ id: `enter-hor-wave-${String(nextWave).padStart(2, "0")}`, kind: "set-phase", phase: `wave-${String(nextWave).padStart(2, "0")}-active` },
],
});
});
rules.push(
{
id: "clear-falric",
trigger: { kind: "entities-dead", entityIds: [falric.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["wave-05-active"] }],
actions: [
{ id: "deactivate-falric", kind: "deactivate-spawn-group", spawnGroupId: falricGroupId },
{ id: "complete-falric", kind: "complete-objective", objectiveId: falric.id, persistence: "run" },
{ id: "checkpoint-falric", kind: "set-checkpoint", checkpointId: falric.id, persistence: "checkpoint" },
{ id: "wait-source-falric-pause", kind: "set-phase", phase: "wave-06-source-pause" },
],
},
{
id: "finish-source-falric-pause",
trigger: { kind: "elapsed", afterMs: 60_000 },
guards: [{ kind: "phase", phases: ["wave-06-source-pause"] }],
actions: [
{ id: "activate-post-falric-wave-06", kind: "activate-spawn-group", spawnGroupId: waveGroupIds[4] },
{ id: "enter-post-falric-wave-06", kind: "set-phase", phase: "wave-06-active" },
],
},
{
id: "clear-marwyn",
trigger: { kind: "entities-dead", entityIds: [marwyn.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["wave-10-active"] }],
actions: [
{ id: "deactivate-marwyn", kind: "deactivate-spawn-group", spawnGroupId: marwynGroupId },
{ id: "complete-marwyn", kind: "complete-objective", objectiveId: marwyn.id, persistence: "run" },
{ id: "checkpoint-marwyn", kind: "set-checkpoint", checkpointId: marwyn.id, persistence: "checkpoint" },
{ id: "complete-wave-start", kind: "set-world-object-state", objectId: waveStart.id, state: "complete", persistence: "checkpoint" },
...frontDoors.map((door, index) => ({ id: `open-front-door-${index + 1}`, kind: "set-world-object-state" as const, objectId: door.id, state: "open", persistence: "checkpoint" as const })),
...arthasDoors.map((door, index) => ({ id: `open-arthas-door-${index + 1}`, kind: "set-world-object-state" as const, objectId: door.id, state: "open", persistence: "checkpoint" as const })),
{ id: "activate-frostsworn-general", kind: "activate-spawn-group", spawnGroupId: generalGroupId },
{ id: "enter-frostsworn-general", kind: "set-phase", phase: "general-active" },
],
},
{
id: "clear-frostsworn-general",
trigger: { kind: "entities-dead", entityIds: generalSpawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["general-active"] }],
actions: [
{ id: "deactivate-frostsworn-general", kind: "deactivate-spawn-group", spawnGroupId: generalGroupId },
{ id: "mark-frostsworn-general-complete", kind: "set-flag", flagId: "frostsworn-general-complete", value: true, persistence: "checkpoint" },
{ id: "checkpoint-frostsworn-general", kind: "set-checkpoint", checkpointId: generalGroupId, persistence: "checkpoint" },
{ id: "ready-escape-start", kind: "set-world-object-state", objectId: escapeStart.id, state: "ready", persistence: "checkpoint" },
{ id: "enter-escape-ready", kind: "set-phase", phase: "escape-ready" },
],
},
{
id: "start-lich-king-escape",
trigger: { kind: "interact", objectId: escapeStart.id },
guards: [{ kind: "phase", phases: ["escape-ready"] }],
actions: [
{ id: "mark-escape-start-active", kind: "set-world-object-state", objectId: escapeStart.id, state: "active" },
{ id: "close-escape-wall-1", kind: "set-world-object-state", objectId: iceWalls[0].id, state: "closed" },
{ id: "activate-escape-wall-1", kind: "activate-spawn-group", spawnGroupId: escapeGroupIds[0] },
{ id: "teleport-to-escape-wall-1", kind: "teleport-party", destination: HOR_ESCAPE_STOPS[0] },
{ id: "enter-escape-wall-1", kind: "set-phase", phase: "escape-1-active" },
],
},
);
escapeGroupIds.forEach((groupId, index) => {
const wall = index + 1;
const group = groups.find((candidate) => candidate.id === groupId)!;
const final = wall === escapeGroupIds.length;
const actions: DungeonEncounterAction[] = [
{ id: `deactivate-escape-wall-${wall}`, kind: "deactivate-spawn-group", spawnGroupId: groupId },
{ id: `open-escape-wall-${wall}`, kind: "set-world-object-state", objectId: iceWalls[index].id, state: "open" },
];
if (final) {
actions.push(
{ id: "complete-lich-king-escape", kind: "complete-objective", objectiveId: escapeObjective.id, persistence: "run" },
{ id: "checkpoint-lich-king-escape", kind: "set-checkpoint", checkpointId: escapeObjective.id, persistence: "run" },
{ id: "mark-halls-of-reflection-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "mark-escape-start-complete", kind: "set-world-object-state", objectId: escapeStart.id, state: "complete", persistence: "run" },
{ id: "enter-halls-of-reflection-complete", kind: "set-phase", phase: "complete", persistence: "run" },
);
} else {
actions.push({ id: `wait-for-escape-wall-${wall + 1}`, kind: "set-phase", phase: `escape-${wall + 1}-wait` });
}
rules.push({
id: `clear-escape-wall-${wall}`,
trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: [`escape-${wall}-active`] }],
actions,
});
if (!final) {
const nextIndex = index + 1;
const delayMs = wall === 3 ? 11_000 : 7_500;
rules.push({
id: `start-escape-wall-${wall + 1}`,
trigger: { kind: "elapsed", afterMs: delayMs },
guards: [{ kind: "phase", phases: [`escape-${wall + 1}-wait`] }],
actions: [
{ id: `close-escape-wall-${wall + 1}`, kind: "set-world-object-state", objectId: iceWalls[nextIndex].id, state: "closed" },
{ id: `activate-escape-wall-${wall + 1}`, kind: "activate-spawn-group", spawnGroupId: escapeGroupIds[nextIndex] },
{ id: `teleport-to-escape-wall-${wall + 1}`, kind: "teleport-party", destination: HOR_ESCAPE_STOPS[nextIndex] },
{ id: `enter-escape-wall-${wall + 1}`, kind: "set-phase", phase: `escape-${wall + 1}-active` },
],
});
}
});
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "halls-of-reflection:wrath-instance",
dungeonId: definition.id,
initialPhase: "ready",
initialState: {
flags: { "frostsworn-general-complete": false, "dungeon-complete": false },
worldObjectStates: Object.fromEntries([
[waveStart.id, "ready"],
[escapeStart.id, "hidden"],
...frontDoors.map((door) => [door.id, "open"] as const),
...arthasDoors.map((door) => [door.id, "closed"] as const),
...iceWalls.map((wall) => [wall.id, "hidden"] as const),
]),
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(review),
rules: Object.freeze(rules),
});
const gatedEntries = new Set([36_723, 36_954, 37_068, 37_225, 37_226, 37_554, 37_779, 38_112, 38_113]);
return Object.freeze({
...definition,
entities,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => !gatedEntries.has(entryForSpawn(definition, spawn) ?? -1))),
bosses: Object.freeze([
...definition.bosses.map((boss) => objectiveReplacements.get(boss.id) ?? boss),
escapeObjective,
]),
objectiveOrder: Object.freeze([
...definition.objectiveOrder.map((boss) => objectiveReplacements.get(boss.id) ?? boss),
escapeObjective,
]),
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("halls-of-reflection:")),
...groups,
]),
serverGameObjects: Object.freeze([
...withoutReviewedDoorSources(definition, doors),
waveStart,
escapeStart,
...doors,
...iceWalls,
]),
validation: appendValidationWarnings(definition, [
"Halls of Reflection now has finite source-ordered wave, boss, General, and four-wall escape orchestration; escort pursuit is explicitly adapted to checkpoint-safe party transitions.",
]),
});
}
const UROM_MENAGERIES = Object.freeze([
[27_650, 27_650, 27_653, 27_651],
[27_647, 27_647, 27_648, 27_649],
[27_645, 27_645, 27_642, 27_644],
] as const);
const UROM_PLATFORM_POSITIONS = Object.freeze([
sourceMapPosition([1177.47, 937.722, 527.405]),
sourceMapPosition([968.66, 1042.53, 527.32]),
sourceMapPosition([1164.02, 1170.85, 527.321]),
]);
const UROM_FINAL_POSITION = sourceMapPosition([1118.31, 1080.377, 508.361]);
function buildOculus(definition: DungeonDefinition): DungeonDefinition {
assertSourceMapTransform(definition);
const review = REVIEW_BY_DUNGEON.get("the-oculus")!;
const drakos = objectiveForEntry(definition, 27_654);
const varos = objectiveForEntry(definition, 27_447);
const urom = objectiveForEntry(definition, 27_655);
const eregos = objectiveForEntry(definition, 27_656);
const start = startObject(
"the-oculus:expedition-start",
"Oculus Expedition",
"Begin the ascent through the Oculus",
definition.entrance.footPosition,
OCULUS_FILES,
);
const cageDoors = cloneReviewedDoors(definition, [193_995], "the-oculus:reviewed-cage-door", OCULUS_FILES);
if (cageDoors.length !== 3) throw new Error(`the-oculus expected 3 source dragon cage doors, found ${cageDoors.length}`);
const entities = ensureEntities(definition, [
{ entry: 27_650, name: "Phantasmal Air", level: 80, role: "caster" },
{ entry: 27_651, name: "Phantasmal Fire", level: 80, role: "caster" },
{ entry: 27_653, name: "Phantasmal Water", level: 80, role: "caster" },
{ entry: 27_647, name: "Phantasmal Ogre", level: 80, role: "melee" },
{ entry: 27_648, name: "Phantasmal Naga", level: 80, role: "melee" },
{ entry: 27_649, name: "Phantasmal Murloc", level: 80, role: "caster" },
{ entry: 27_645, name: "Phantasmal Cloudscraper", level: 80, role: "melee" },
{ entry: 27_642, name: "Phantasmal Mammoth", level: 80, role: "melee" },
{ entry: 27_644, name: "Phantasmal Wolf", level: 80, role: "melee" },
]);
const idFor = (entry: number): string => {
const id = entityIdForEntry(entities, entry);
if (!id) throw new Error(`the-oculus is missing event creature entry ${entry}`);
return id;
};
const constructs = definition.staticSpawns.filter((spawn) => entryForSpawn(definition, spawn) === 27_641);
if (constructs.length !== 10) throw new Error(`the-oculus expected 10 Centrifuge Constructs, found ${constructs.length}`);
const groups: DungeonEncounterSpawnGroupDefinition[] = [
Object.freeze({ id: "the-oculus:drakos", spawns: Object.freeze([spawnAt(drakos.id, idFor(27_654), drakos.position)]) }),
Object.freeze({ id: "the-oculus:centrifuge-constructs", spawns: Object.freeze(constructs) }),
Object.freeze({ id: "the-oculus:varos", spawns: Object.freeze([spawnAt(varos.id, idFor(27_447), varos.position)]) }),
];
UROM_MENAGERIES.forEach((entries, index) => {
const groupId = `the-oculus:urom-menagerie-${index + 1}`;
groups.push(Object.freeze({
id: groupId,
spawns: Object.freeze(entries.map((entry, memberIndex) => spawnAt(
`${groupId}:mob-${memberIndex + 1}`,
idFor(entry),
UROM_PLATFORM_POSITIONS[index],
[memberIndex % 2 ? 4 : -4, memberIndex < 2 ? 4 : -4],
))),
}));
});
groups.push(
Object.freeze({ id: "the-oculus:urom", spawns: Object.freeze([spawnAt(urom.id, idFor(27_655), UROM_FINAL_POSITION)]) }),
Object.freeze({ id: "the-oculus:eregos", spawns: Object.freeze([spawnAt(eregos.id, idFor(27_656), eregos.position)]) }),
);
const updatedObjectives = new Map<string, DungeonBossObjective>([
[drakos.id, Object.freeze({ ...drakos, eventOnly: true })],
[varos.id, Object.freeze({ ...varos, eventOnly: true })],
[urom.id, Object.freeze({ ...urom, position: UROM_FINAL_POSITION, eventOnly: true })],
[eregos.id, Object.freeze({ ...eregos, eventOnly: true })],
]);
const rules: DungeonEncounterRule[] = [
{
id: "start-oculus-expedition",
trigger: { kind: "interact", objectId: start.id },
guards: [{ kind: "phase", phases: ["ready"] }],
actions: [
{ id: "mark-oculus-start-active", kind: "set-world-object-state", objectId: start.id, state: "active" },
{ id: "activate-drakos", kind: "activate-spawn-group", spawnGroupId: "the-oculus:drakos" },
{ id: "enter-drakos", kind: "set-phase", phase: "drakos-active" },
],
},
{
id: "resume-oculus-drakos",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: drakos.id, equals: false },
],
actions: [
{ id: "resume-mark-oculus-start-active", kind: "set-world-object-state", objectId: start.id, state: "active" },
{ id: "resume-activate-drakos", kind: "activate-spawn-group", spawnGroupId: "the-oculus:drakos" },
{ id: "resume-enter-drakos", kind: "set-phase", phase: "drakos-active" },
],
},
{
id: "resume-oculus-constructs",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: drakos.id, equals: true },
{ kind: "objective-complete", objectiveId: varos.id, equals: false },
],
actions: [
{ id: "resume-activate-constructs", kind: "activate-spawn-group", spawnGroupId: "the-oculus:centrifuge-constructs" },
{ id: "resume-teleport-construct-ring", kind: "teleport-party", destination: constructs[0].position },
{ id: "resume-enter-constructs", kind: "set-phase", phase: "constructs-active" },
],
},
{
id: "resume-oculus-urom-menageries",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: varos.id, equals: true },
{ kind: "objective-complete", objectiveId: urom.id, equals: false },
],
actions: [
{ id: "resume-activate-urom-menagerie-1", kind: "activate-spawn-group", spawnGroupId: "the-oculus:urom-menagerie-1" },
{ id: "resume-teleport-urom-platform-1", kind: "teleport-party", destination: UROM_PLATFORM_POSITIONS[0] },
{ id: "resume-enter-urom-menagerie-1", kind: "set-phase", phase: "urom-menagerie-1-active" },
],
},
{
id: "resume-oculus-eregos",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: urom.id, equals: true },
{ kind: "objective-complete", objectiveId: eregos.id, equals: false },
],
actions: [
{ id: "resume-activate-eregos", kind: "activate-spawn-group", spawnGroupId: "the-oculus:eregos" },
{ id: "resume-teleport-eregos", kind: "teleport-party", destination: eregos.position },
{ id: "resume-enter-eregos", kind: "set-phase", phase: "eregos-active" },
],
},
{
id: "clear-drakos",
trigger: { kind: "entities-dead", entityIds: [drakos.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["drakos-active"] }],
actions: [
{ id: "deactivate-drakos", kind: "deactivate-spawn-group", spawnGroupId: "the-oculus:drakos" },
{ id: "complete-drakos", kind: "complete-objective", objectiveId: drakos.id, persistence: "run" },
{ id: "checkpoint-drakos", kind: "set-checkpoint", checkpointId: drakos.id, persistence: "checkpoint" },
...cageDoors.map((door, index) => ({ id: `open-oculus-cage-door-${index + 1}`, kind: "set-world-object-state" as const, objectId: door.id, state: "open", persistence: "checkpoint" as const })),
{ id: "activate-centrifuge-constructs", kind: "activate-spawn-group", spawnGroupId: "the-oculus:centrifuge-constructs" },
{ id: "teleport-to-construct-ring", kind: "teleport-party", destination: constructs[0].position },
{ id: "enter-centrifuge-constructs", kind: "set-phase", phase: "constructs-active" },
],
},
{
id: "clear-centrifuge-constructs",
trigger: { kind: "entities-dead", entityIds: constructs.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["constructs-active"] }],
actions: [
{ id: "deactivate-centrifuge-constructs", kind: "deactivate-spawn-group", spawnGroupId: "the-oculus:centrifuge-constructs" },
{ id: "activate-varos", kind: "activate-spawn-group", spawnGroupId: "the-oculus:varos" },
{ id: "teleport-to-varos", kind: "teleport-party", destination: varos.position },
{ id: "enter-varos", kind: "set-phase", phase: "varos-active" },
],
},
{
id: "clear-varos",
trigger: { kind: "entities-dead", entityIds: [varos.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["varos-active"] }],
actions: [
{ id: "deactivate-varos", kind: "deactivate-spawn-group", spawnGroupId: "the-oculus:varos" },
{ id: "complete-varos", kind: "complete-objective", objectiveId: varos.id, persistence: "run" },
{ id: "checkpoint-varos", kind: "set-checkpoint", checkpointId: varos.id, persistence: "checkpoint" },
{ id: "activate-urom-menagerie-1", kind: "activate-spawn-group", spawnGroupId: "the-oculus:urom-menagerie-1" },
{ id: "teleport-to-urom-platform-1", kind: "teleport-party", destination: UROM_PLATFORM_POSITIONS[0] },
{ id: "enter-urom-menagerie-1", kind: "set-phase", phase: "urom-menagerie-1-active" },
],
},
];
UROM_MENAGERIES.forEach((_, index) => {
const platform = index + 1;
const groupId = `the-oculus:urom-menagerie-${platform}`;
const group = groups.find((candidate) => candidate.id === groupId)!;
const final = platform === UROM_MENAGERIES.length;
rules.push({
id: `clear-urom-menagerie-${platform}`,
trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id), timeoutMs: 600_000 },
guards: [{ kind: "phase", phases: [`urom-menagerie-${platform}-active`] }],
actions: final
? [
{ id: "deactivate-urom-menagerie-3", kind: "deactivate-spawn-group", spawnGroupId: groupId },
{ id: "activate-urom", kind: "activate-spawn-group", spawnGroupId: "the-oculus:urom" },
{ id: "teleport-to-urom-final", kind: "teleport-party", destination: UROM_FINAL_POSITION },
{ id: "enter-urom", kind: "set-phase", phase: "urom-active" },
]
: [
{ id: `deactivate-urom-menagerie-${platform}`, kind: "deactivate-spawn-group", spawnGroupId: groupId },
{ id: `activate-urom-menagerie-${platform + 1}`, kind: "activate-spawn-group", spawnGroupId: `the-oculus:urom-menagerie-${platform + 1}` },
{ id: `teleport-to-urom-platform-${platform + 1}`, kind: "teleport-party", destination: UROM_PLATFORM_POSITIONS[index + 1] },
{ id: `enter-urom-menagerie-${platform + 1}`, kind: "set-phase", phase: `urom-menagerie-${platform + 1}-active` },
],
});
});
rules.push(
{
id: "clear-urom",
trigger: { kind: "entities-dead", entityIds: [urom.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["urom-active"] }],
actions: [
{ id: "deactivate-urom", kind: "deactivate-spawn-group", spawnGroupId: "the-oculus:urom" },
{ id: "complete-urom", kind: "complete-objective", objectiveId: urom.id, persistence: "run" },
{ id: "checkpoint-urom", kind: "set-checkpoint", checkpointId: urom.id, persistence: "checkpoint" },
{ id: "activate-eregos", kind: "activate-spawn-group", spawnGroupId: "the-oculus:eregos" },
{ id: "teleport-to-eregos", kind: "teleport-party", destination: eregos.position },
{ id: "enter-eregos", kind: "set-phase", phase: "eregos-active" },
],
},
{
id: "clear-eregos",
trigger: { kind: "entities-dead", entityIds: [eregos.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["eregos-active"] }],
actions: [
{ id: "deactivate-eregos", kind: "deactivate-spawn-group", spawnGroupId: "the-oculus:eregos" },
{ id: "complete-eregos", kind: "complete-objective", objectiveId: eregos.id, persistence: "run" },
{ id: "checkpoint-eregos", kind: "set-checkpoint", checkpointId: eregos.id, persistence: "run" },
{ id: "mark-oculus-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "mark-oculus-start-complete", kind: "set-world-object-state", objectId: start.id, state: "complete", persistence: "run" },
{ id: "enter-oculus-complete", kind: "set-phase", phase: "complete", persistence: "run" },
],
},
);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "the-oculus:wrath-instance",
dungeonId: definition.id,
initialPhase: "ready",
initialState: {
flags: { "dungeon-complete": false },
worldObjectStates: Object.fromEntries([
[start.id, "ready"],
...cageDoors.map((door) => [door.id, "closed"] as const),
]),
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(review),
rules: Object.freeze(rules),
});
const gatedEntries = new Set([27_447, 27_641, 27_654, 27_655, 27_656]);
return Object.freeze({
...definition,
entities,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => !gatedEntries.has(entryForSpawn(definition, spawn) ?? -1))),
bosses: Object.freeze(definition.bosses.map((boss) => updatedObjectives.get(boss.id) ?? boss)),
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => updatedObjectives.get(boss.id) ?? boss)),
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("the-oculus:")),
...groups,
]),
serverGameObjects: Object.freeze([
...withoutReviewedDoorSources(definition, cageDoors),
start,
...cageDoors,
]),
validation: appendValidationWarnings(definition, [
"The Oculus now gates Drakos, all ten source constructs, Varos, three exact Urom menageries, Urom, and Eregos; source platform travel is a deterministic party-teleport adaptation.",
]),
});
}
const TOC_MOUNTED_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({
id: "mounted-charge-63010",
name: "Mounted Charge",
spellId: 63_010,
delivery: "melee",
target: "random-party",
school: "physical",
animation: "attack",
range: 25,
cooldownMs: 5_250,
initialCooldownMs: [2_500, 4_000] as const,
repeatCooldownMs: [4_500, 6_000] as const,
damageMultiplier: 1.15,
telegraph: { shape: "line", anchor: "source", warningMs: 750, length: 25, width: 3 } as const,
}),
Object.freeze({
id: "shield-breaker-68504",
name: "Shield Breaker",
spellId: 68_504,
delivery: "projectile",
target: "random-party",
school: "physical",
animation: "attack",
range: 30,
cooldownMs: 7_000,
initialCooldownMs: [5_000, 8_000] as const,
repeatCooldownMs: [6_000, 8_000] as const,
damageMultiplier: 0.65,
}),
Object.freeze({
id: "thrust-68505",
name: "Thrust",
spellId: 68_505,
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
range: 6,
cooldownMs: 4_000,
initialCooldownMs: [3_000, 5_000] as const,
repeatCooldownMs: [3_000, 5_000] as const,
damageMultiplier: 0.8,
}),
]);
const EADRIC_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({
id: "vengeance-66865",
name: "Vengeance",
spellId: 66_865,
delivery: "area",
target: "self",
school: "holy",
animation: "cast",
castTimeMs: 0,
range: 0,
cooldownMs: 1,
initialCooldownMs: [0, 0] as const,
repeatCooldownMs: [1, 1] as const,
maximumCasts: 1,
damageMultiplier: 0,
effect: {
kind: "aura",
aura: "vengeance",
durationMs: 0,
meleeAutoCriticalProc: {
chance: 1,
triggeredAura: "vengeance-next-melee-critical-66889",
durationMs: 6_000,
criticalChancePercent: 100,
charges: 1,
},
} as const,
}),
Object.freeze({
id: "radiance-66935",
name: "Radiance",
spellId: 66_935,
delivery: "area",
target: "nearby-party",
school: "holy",
animation: "cast",
range: 45,
radius: 45,
cooldownMs: 16_000,
initialCooldownMs: [16_000, 16_000] as const,
repeatCooldownMs: [16_000, 16_000] as const,
damageMultiplier: 0.9,
effect: { kind: "control", mechanic: "confuse", durationMs: 2_000, damageMultiplier: 0.9 } as const,
telegraph: { shape: "circle", anchor: "source", warningMs: 1_500, radius: 45 } as const,
}),
Object.freeze({
id: "hammer-of-justice-and-righteous-66863-66867",
name: "Hammer of Justice + Hammer of the Righteous",
spellId: 66_867,
delivery: "projectile",
target: "random-party",
school: "holy",
animation: "cast",
range: 55,
cooldownMs: 25_000,
initialCooldownMs: [25_000, 25_000] as const,
repeatCooldownMs: [25_000, 25_000] as const,
damageMultiplier: 1.3,
effect: { kind: "control", mechanic: "stun", durationMs: 6_000, damageMultiplier: 1.3 } as const,
}),
]);
const BLACK_KNIGHT_PHASE_ONE_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({ id: "plague-strike-67724", name: "Plague Strike", spellId: 67_724, delivery: "melee", target: "primary", school: "physical", animation: "attack", range: 5, cooldownMs: 11_000, initialCooldownMs: [7_000, 9_000] as const, repeatCooldownMs: [10_000, 12_000] as const, damageMultiplier: 0.85 }),
Object.freeze({ id: "icy-touch-67718", name: "Icy Touch", spellId: 67_718, delivery: "projectile", target: "primary", school: "frost", animation: "cast", range: 30, cooldownMs: 5_500, initialCooldownMs: [3_500, 7_000] as const, repeatCooldownMs: [5_000, 6_000] as const, damageMultiplier: 0.75 }),
Object.freeze({ id: "death-respite-67745", name: "Death's Respite", spellId: 67_745, delivery: "projectile", target: "random-party", school: "shadow", animation: "cast", range: 50, cooldownMs: 14_000, initialCooldownMs: [13_000, 15_000] as const, repeatCooldownMs: [13_000, 15_000] as const, damageMultiplier: 1.05 }),
Object.freeze({ id: "obliterate-67725", name: "Obliterate", spellId: 67_725, delivery: "melee", target: "primary", school: "physical", animation: "attack", range: 5, cooldownMs: 16_000, initialCooldownMs: [11_000, 19_000] as const, repeatCooldownMs: [15_000, 17_000] as const, damageMultiplier: 1.35 }),
]);
const BLACK_KNIGHT_PHASE_TWO_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
...BLACK_KNIGHT_PHASE_ONE_ATTACKS.filter((attack) => attack.id !== "death-respite-67745"),
Object.freeze({
id: "desecration-67778",
name: "Desecration",
spellId: 67_778,
delivery: "area",
target: "random-party",
school: "shadow",
animation: "cast",
range: 50,
radius: 7,
cooldownMs: 15_500,
initialCooldownMs: [2_000, 3_000] as const,
repeatCooldownMs: [14_000, 17_000] as const,
damageMultiplier: 0.95,
telegraph: { shape: "circle", anchor: "target", warningMs: 1_500, radius: 7 } as const,
}),
]);
const BLACK_KNIGHT_PHASE_THREE_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
Object.freeze({
id: "death-bite-67808",
name: "Death's Bite",
spellId: 67_808,
delivery: "area",
target: "nearby-party",
school: "shadow",
animation: "cast",
range: 45,
radius: 45,
cooldownMs: 3_000,
initialCooldownMs: [2_000, 2_000] as const,
repeatCooldownMs: [2_000, 4_000] as const,
damageMultiplier: 0.6,
telegraph: { shape: "circle", anchor: "source", warningMs: 1_000, radius: 45 } as const,
}),
Object.freeze({
id: "marked-for-death-67823",
name: "Marked for Death",
spellId: 67_823,
delivery: "projectile",
target: "random-party",
school: "shadow",
animation: "cast",
range: 50,
cooldownMs: 9_000,
initialCooldownMs: [1_000, 1_000] as const,
repeatCooldownMs: [9_000, 9_000] as const,
damageMultiplier: 0,
effect: { kind: "target-aura", aura: "marked-for-death", durationMs: 9_000 } as const,
}),
]);
const TOC_CENTER = sourceMapPosition([748.309, 619.448, 411.3]);
const TOC_CHAMPION_POSITIONS = Object.freeze([
sourceMapPosition([736.695, 650.02, 412.4]),
sourceMapPosition([756.32, 650.05, 412.4]),
sourceMapPosition([746.5, 650.65, 411.7]),
]);
const TOC_EADRIC_POSITION = sourceMapPosition([746.881, 635.263, 411.7]);
const TOC_BLACK_KNIGHT_POSITION = sourceMapPosition([745.016, 631.506, 411.575]);
function buildTrialOfTheChampion(definition: DungeonDefinition): DungeonDefinition {
assertSourceMapTransform(definition);
const review = REVIEW_BY_DUNGEON.get("trial-of-the-champion")!;
const championObjectives = [35_572, 35_569, 35_571].map((entry) => objectiveForEntry(definition, entry));
const argentObjective = objectiveForName(definition, "Argent Champion");
const blackKnightObjective = objectiveForEntry(definition, 35_451);
const start = startObject(
"trial-of-the-champion:announcer-start",
"Coliseum Announcer",
"Begin the Trial of the Champion",
sourceMapPosition([735.81, 661.92, 412.39]),
TRIAL_OF_THE_CHAMPION_FILES,
);
const gates = cloneReviewedDoors(
definition,
[195_647, 195_650],
"trial-of-the-champion:reviewed-gate",
TRIAL_OF_THE_CHAMPION_FILES,
);
const mainGates = gates.filter((gate) => gate.source?.templateId === 195_647);
const arenaGates = gates.filter((gate) => gate.source?.templateId === 195_650);
if (mainGates.length !== 1 || arenaGates.length !== 1) {
throw new Error(`trial-of-the-champion expected one main and one north gate, found ${mainGates.length}/${arenaGates.length}`);
}
const entities = ensureEntities(definition, [
{ entry: 35_314, name: "Orgrimmar Champion", level: 80, role: "melee" },
{ entry: 35_326, name: "Silvermoon Champion", level: 80, role: "caster" },
{ entry: 35_325, name: "Thunder Bluff Champion", level: 80, role: "melee" },
{ entry: 35_309, name: "Argent Lightwielder", level: 80, role: "caster" },
{ entry: 35_305, name: "Argent Monk", level: 80, role: "melee" },
{ entry: 35_307, name: "Argent Priestess", level: 80, role: "caster" },
{ id: "wrath-event-eadric", entry: 35_119, name: "Eadric the Pure", level: 80, role: "melee", kind: "boss", title: "Argent Champion", hasLoot: true, attacks: EADRIC_ATTACKS },
{ id: "wrath-event-mounted-mokra", entry: 35_572, name: "Mounted Mokra the Skullcrusher", level: 80, role: "melee", kind: "boss", title: "Grand Champion", attacks: TOC_MOUNTED_ATTACKS },
{ id: "wrath-event-mounted-eressea", entry: 35_569, name: "Mounted Eressea Dawnsinger", level: 80, role: "caster", basicAttackStyle: "melee", kind: "boss", title: "Grand Champion", attacks: TOC_MOUNTED_ATTACKS },
{ id: "wrath-event-mounted-runok", entry: 35_571, name: "Mounted Runok Wildmane", level: 80, role: "melee", kind: "boss", title: "Grand Champion", attacks: TOC_MOUNTED_ATTACKS },
{ id: "wrath-event-black-knight-phase-1", entry: 35_451, name: "The Black Knight", level: 80, role: "melee", kind: "boss", title: "Death Knight Form", attacks: BLACK_KNIGHT_PHASE_ONE_ATTACKS },
{ id: "wrath-event-black-knight-phase-2", entry: 35_451, name: "The Black Knight", level: 80, role: "melee", kind: "boss", title: "Skeleton Form", attacks: BLACK_KNIGHT_PHASE_TWO_ATTACKS },
{ id: "wrath-event-black-knight-phase-3", entry: 35_451, name: "The Black Knight", level: 80, role: "caster", basicAttackStyle: "melee", kind: "boss", title: "Ghost Form", hasLoot: true, attacks: BLACK_KNIGHT_PHASE_THREE_ATTACKS },
]);
const idFor = (entry: number): string => {
const id = entityIdForEntry(entities, entry);
if (!id) throw new Error(`trial-of-the-champion is missing event creature entry ${entry}`);
return id;
};
const groups: DungeonEncounterSpawnGroupDefinition[] = [];
const minionWaves = [
{ id: "silvermoon", entry: 35_326 },
{ id: "orgrimmar", entry: 35_314 },
{ id: "thunder-bluff", entry: 35_325 },
] as const;
minionWaves.forEach((wave, index) => {
const groupId = `trial-of-the-champion:minions-${index + 1}-${wave.id}`;
groups.push(Object.freeze({
id: groupId,
spawns: Object.freeze([0, 1, 2].map((member) => spawnAt(
`${groupId}:mob-${member + 1}`,
idFor(wave.entry),
TOC_CENTER,
PACK_OFFSETS[member],
))),
}));
});
groups.push(Object.freeze({
id: "trial-of-the-champion:mounted-champions",
spawns: Object.freeze([
spawnAt("trial-of-the-champion:mounted-mokra", "wrath-event-mounted-mokra", TOC_CENTER, PACK_OFFSETS[1]),
spawnAt("trial-of-the-champion:mounted-eressea", "wrath-event-mounted-eressea", TOC_CENTER, PACK_OFFSETS[2]),
spawnAt("trial-of-the-champion:mounted-runok", "wrath-event-mounted-runok", TOC_CENTER, PACK_OFFSETS[0]),
]),
}));
groups.push(Object.freeze({
id: "trial-of-the-champion:foot-champions",
spawns: Object.freeze(championObjectives.map((objective, index) => spawnAt(
objective.id,
idFor([35_572, 35_569, 35_571][index]),
TOC_CHAMPION_POSITIONS[index],
))),
}));
const argentSoldierSpawns: StaticMobSpawnDefinition[] = [];
for (let row = -1; row <= 1; row += 1) {
const sourceX = 746.67 + row * 10;
argentSoldierSpawns.push(
spawnAt(`trial-of-the-champion:argent-${row + 2}-lightwielder`, idFor(35_309), sourceMapPosition([sourceX - 2, 684.08, 412.5])),
spawnAt(`trial-of-the-champion:argent-${row + 2}-monk`, idFor(35_305), sourceMapPosition([sourceX, 684.08, 412.5])),
spawnAt(`trial-of-the-champion:argent-${row + 2}-priestess`, idFor(35_307), sourceMapPosition([sourceX + 2, 684.08, 412.5])),
);
}
groups.push(
Object.freeze({ id: "trial-of-the-champion:argent-soldiers", spawns: Object.freeze(argentSoldierSpawns) }),
Object.freeze({ id: "trial-of-the-champion:eadric", spawns: Object.freeze([spawnAt(argentObjective.id, "wrath-event-eadric", TOC_EADRIC_POSITION)]) }),
Object.freeze({ id: "trial-of-the-champion:black-knight-phase-1", spawns: Object.freeze([spawnAt("trial-of-the-champion:black-knight-form-1", "wrath-event-black-knight-phase-1", TOC_BLACK_KNIGHT_POSITION)]) }),
Object.freeze({ id: "trial-of-the-champion:black-knight-phase-2", spawns: Object.freeze([spawnAt("trial-of-the-champion:black-knight-form-2", "wrath-event-black-knight-phase-2", TOC_BLACK_KNIGHT_POSITION)]) }),
Object.freeze({ id: "trial-of-the-champion:black-knight-phase-3", spawns: Object.freeze([spawnAt(blackKnightObjective.id, "wrath-event-black-knight-phase-3", TOC_BLACK_KNIGHT_POSITION)]) }),
);
const objectiveReplacements = new Map<string, DungeonBossObjective>([
...championObjectives.map((objective, index) => [
objective.id,
Object.freeze({ ...objective, position: TOC_CHAMPION_POSITIONS[index], eventOnly: true }),
] as const),
[argentObjective.id, Object.freeze({ ...argentObjective, name: "Eadric the Pure", position: TOC_EADRIC_POSITION, eventOnly: true })],
[blackKnightObjective.id, Object.freeze({ ...blackKnightObjective, position: TOC_BLACK_KNIGHT_POSITION, eventOnly: true })],
]);
const rules: DungeonEncounterRule[] = [
{
id: "start-trial",
trigger: { kind: "interact", objectId: start.id },
guards: [{ kind: "phase", phases: ["ready"] }],
actions: [
{ id: "mark-trial-start-active", kind: "set-world-object-state", objectId: start.id, state: "active" },
...arenaGates.map((gate, index) => ({ id: `close-trial-arena-gate-${index + 1}`, kind: "set-world-object-state" as const, objectId: gate.id, state: "closed" })),
{ id: "activate-champion-minions-1", kind: "activate-spawn-group", spawnGroupId: groups[0].id },
{ id: "enter-champion-minions-1", kind: "set-phase", phase: "champion-minions-1-active" },
],
},
{
id: "resume-trial-champions",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
...championObjectives.map((objective) => ({ kind: "objective-complete" as const, objectiveId: objective.id, equals: false })),
],
actions: [
{ id: "resume-mark-trial-start-active", kind: "set-world-object-state", objectId: start.id, state: "active" },
...arenaGates.map((gate, index) => ({ id: `resume-close-trial-arena-gate-${index + 1}`, kind: "set-world-object-state" as const, objectId: gate.id, state: "closed" })),
{ id: "resume-activate-champion-minions-1", kind: "activate-spawn-group", spawnGroupId: groups[0].id },
{ id: "resume-enter-champion-minions-1", kind: "set-phase", phase: "champion-minions-1-active" },
],
},
{
id: "resume-trial-argent",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
...championObjectives.map((objective) => ({ kind: "objective-complete" as const, objectiveId: objective.id, equals: true })),
{ kind: "objective-complete", objectiveId: argentObjective.id, equals: false },
],
actions: [
{ id: "resume-activate-argent-soldiers", kind: "activate-spawn-group", spawnGroupId: "trial-of-the-champion:argent-soldiers" },
{ id: "resume-enter-argent-soldiers", kind: "set-phase", phase: "argent-soldiers-active" },
],
},
{
id: "resume-trial-black-knight",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "objective-complete", objectiveId: argentObjective.id, equals: true },
{ kind: "objective-complete", objectiveId: blackKnightObjective.id, equals: false },
],
actions: [
{ id: "resume-activate-black-knight-phase-1", kind: "activate-spawn-group", spawnGroupId: "trial-of-the-champion:black-knight-phase-1" },
{ id: "resume-enter-black-knight-phase-1", kind: "set-phase", phase: "black-knight-phase-1-active" },
],
},
];
minionWaves.forEach((_, index) => {
const wave = index + 1;
const group = groups[index];
const nextGroupId = wave < 3 ? groups[index + 1].id : "trial-of-the-champion:mounted-champions";
const nextPhase = wave < 3 ? `champion-minions-${wave + 1}-active` : "mounted-champions-active";
rules.push({
id: `clear-champion-minions-${wave}`,
trigger: { kind: "entities-dead", entityIds: group.spawns.map((spawn) => spawn.id), timeoutMs: 600_000 },
guards: [{ kind: "phase", phases: [`champion-minions-${wave}-active`] }],
actions: [
{ id: `deactivate-champion-minions-${wave}`, kind: "deactivate-spawn-group", spawnGroupId: group.id },
{ id: wave < 3 ? `activate-champion-minions-${wave + 1}` : "activate-mounted-champions", kind: "activate-spawn-group", spawnGroupId: nextGroupId },
{ id: wave < 3 ? `enter-champion-minions-${wave + 1}` : "enter-mounted-champions", kind: "set-phase", phase: nextPhase },
],
});
});
const mountedGroup = groups.find((group) => group.id === "trial-of-the-champion:mounted-champions")!;
const footGroup = groups.find((group) => group.id === "trial-of-the-champion:foot-champions")!;
rules.push(
{
id: "clear-mounted-champions",
trigger: { kind: "entities-dead", entityIds: mountedGroup.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["mounted-champions-active"] }],
actions: [
{ id: "deactivate-mounted-champions", kind: "deactivate-spawn-group", spawnGroupId: mountedGroup.id },
{ id: "activate-foot-champions", kind: "activate-spawn-group", spawnGroupId: footGroup.id },
{ id: "enter-foot-champions", kind: "set-phase", phase: "foot-champions-active" },
],
},
{
id: "clear-foot-champions",
trigger: { kind: "entities-dead", entityIds: footGroup.spawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["foot-champions-active"] }],
actions: [
{ id: "deactivate-foot-champions", kind: "deactivate-spawn-group", spawnGroupId: footGroup.id },
...championObjectives.map((objective, index) => ({ id: `complete-grand-champion-${index + 1}`, kind: "complete-objective" as const, objectiveId: objective.id, persistence: "run" as const })),
{ id: "checkpoint-grand-champions", kind: "set-checkpoint", checkpointId: "trial-of-the-champion:grand-champions", persistence: "checkpoint" },
{ id: "activate-argent-soldiers", kind: "activate-spawn-group", spawnGroupId: "trial-of-the-champion:argent-soldiers" },
{ id: "enter-argent-soldiers", kind: "set-phase", phase: "argent-soldiers-active" },
],
},
{
id: "clear-argent-soldiers",
trigger: { kind: "entities-dead", entityIds: argentSoldierSpawns.map((spawn) => spawn.id), timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["argent-soldiers-active"] }],
actions: [
{ id: "deactivate-argent-soldiers", kind: "deactivate-spawn-group", spawnGroupId: "trial-of-the-champion:argent-soldiers" },
{ id: "activate-eadric", kind: "activate-spawn-group", spawnGroupId: "trial-of-the-champion:eadric" },
{ id: "enter-eadric", kind: "set-phase", phase: "eadric-active" },
],
},
{
id: "clear-eadric",
trigger: { kind: "entities-dead", entityIds: [argentObjective.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["eadric-active"] }],
actions: [
{ id: "deactivate-eadric", kind: "deactivate-spawn-group", spawnGroupId: "trial-of-the-champion:eadric" },
{ id: "complete-eadric", kind: "complete-objective", objectiveId: argentObjective.id, persistence: "run" },
{ id: "checkpoint-eadric", kind: "set-checkpoint", checkpointId: argentObjective.id, persistence: "checkpoint" },
{ id: "activate-black-knight-phase-1", kind: "activate-spawn-group", spawnGroupId: "trial-of-the-champion:black-knight-phase-1" },
{ id: "enter-black-knight-phase-1", kind: "set-phase", phase: "black-knight-phase-1-active" },
],
},
{
id: "clear-black-knight-phase-1",
trigger: { kind: "entities-dead", entityIds: ["trial-of-the-champion:black-knight-form-1"], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["black-knight-phase-1-active"] }],
actions: [
{ id: "deactivate-black-knight-phase-1", kind: "deactivate-spawn-group", spawnGroupId: "trial-of-the-champion:black-knight-phase-1" },
{ id: "activate-black-knight-phase-2", kind: "activate-spawn-group", spawnGroupId: "trial-of-the-champion:black-knight-phase-2" },
{ id: "enter-black-knight-phase-2", kind: "set-phase", phase: "black-knight-phase-2-active" },
],
},
{
id: "clear-black-knight-phase-2",
trigger: { kind: "entities-dead", entityIds: ["trial-of-the-champion:black-knight-form-2"], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["black-knight-phase-2-active"] }],
actions: [
{ id: "deactivate-black-knight-phase-2", kind: "deactivate-spawn-group", spawnGroupId: "trial-of-the-champion:black-knight-phase-2" },
{ id: "activate-black-knight-phase-3", kind: "activate-spawn-group", spawnGroupId: "trial-of-the-champion:black-knight-phase-3" },
{ id: "enter-black-knight-phase-3", kind: "set-phase", phase: "black-knight-phase-3-active" },
],
},
{
id: "clear-black-knight-phase-3",
trigger: { kind: "entities-dead", entityIds: [blackKnightObjective.id], timeoutMs: 900_000 },
guards: [{ kind: "phase", phases: ["black-knight-phase-3-active"] }],
actions: [
{ id: "deactivate-black-knight-phase-3", kind: "deactivate-spawn-group", spawnGroupId: "trial-of-the-champion:black-knight-phase-3" },
{ id: "complete-black-knight", kind: "complete-objective", objectiveId: blackKnightObjective.id, persistence: "run" },
{ id: "checkpoint-black-knight", kind: "set-checkpoint", checkpointId: blackKnightObjective.id, persistence: "run" },
...arenaGates.map((gate, index) => ({ id: `open-trial-arena-gate-${index + 1}`, kind: "set-world-object-state" as const, objectId: gate.id, state: "open", persistence: "run" as const })),
{ id: "mark-trial-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "mark-trial-start-complete", kind: "set-world-object-state", objectId: start.id, state: "complete", persistence: "run" },
{ id: "enter-trial-complete", kind: "set-phase", phase: "complete", persistence: "run" },
],
},
);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "trial-of-the-champion:wrath-instance",
dungeonId: definition.id,
initialPhase: "ready",
initialState: {
flags: { "dungeon-complete": false },
worldObjectStates: Object.fromEntries([
[start.id, "ready"],
...mainGates.map((gate) => [gate.id, "closed"] as const),
...arenaGates.map((gate) => [gate.id, "open"] as const),
]),
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(review),
rules: Object.freeze(rules),
});
const gatedEntries = new Set([30_188, 35_451, 35_569, 35_571, 35_572]);
return Object.freeze({
...definition,
entities,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => !gatedEntries.has(entryForSpawn(definition, spawn) ?? -1))),
bosses: Object.freeze(definition.bosses.map((boss) => objectiveReplacements.get(boss.id) ?? boss)),
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => objectiveReplacements.get(boss.id) ?? boss)),
encounters: Object.freeze([...withoutBaselineBossProgression(definition), encounter]),
encounterSpawnGroups: Object.freeze([
...(definition.encounterSpawnGroups ?? []).filter((group) => !group.id.startsWith("trial-of-the-champion:")),
...groups,
]),
serverGameObjects: Object.freeze([
...withoutReviewedDoorSources(definition, gates),
start,
...gates,
]),
validation: appendValidationWarnings(definition, [
"Trial of the Champion now has a complete finite announcer sequence with a deterministic valid champion roster, explicit mounted proxy, Eadric branch, and all three Black Knight forms.",
]),
});
}
/**
* Adds checkpoint-safe source-reviewed adapters for Wrath instances whose
* retail vehicle, escort, or in-place transformation primitives are absent.
* Apply this after the general WoW catalog and before RuneWaker content.
*/
export function withWowWrathEncounterContent(definition: DungeonDefinition): DungeonDefinition {
if (definition.encounters?.some((encounter) => encounter.id === `${definition.id}:wrath-instance`)) {
return definition;
}
if (definition.id === "halls-of-reflection") return buildHallsOfReflection(definition);
if (definition.id === "the-oculus") return buildOculus(definition);
if (definition.id === "trial-of-the-champion") return buildTrialOfTheChampion(definition);
return definition;
}