1648 lines
80 KiB
TypeScript
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;
|
|
}
|