669 lines
38 KiB
TypeScript
669 lines
38 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { gunzipSync, strFromU8 } from "fflate";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createDungeonEncounterSnapshot,
|
|
reduceDungeonEncounter,
|
|
resetDungeonEncounterSnapshot,
|
|
validateDungeonEncounterDefinition,
|
|
} from "./dungeonEncounterRuntime";
|
|
import type {
|
|
DungeonEncounterDefinition,
|
|
DungeonEncounterEvent,
|
|
DungeonEncounterSnapshot,
|
|
} from "./dungeonEncounterTypes";
|
|
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
|
|
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
|
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
|
import {
|
|
WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS,
|
|
withWowWrathEncounterBatchThreeContent,
|
|
type WowWrathEncounterBatchThreeDungeonId,
|
|
} from "./wowWrathEncounterBatchThreeCatalog";
|
|
|
|
const TARGETS = [
|
|
"utgarde-keep",
|
|
"the-nexus",
|
|
"draktharon-keep",
|
|
"azjol-nerub",
|
|
"halls-of-lightning",
|
|
"ahnkahet-old-kingdom",
|
|
"forge-of-souls",
|
|
] as const satisfies readonly WowWrathEncounterBatchThreeDungeonId[];
|
|
|
|
const REVIEWED_TEMPLATES: Readonly<Record<WowWrathEncounterBatchThreeDungeonId, readonly number[]>> = Object.freeze({
|
|
"utgarde-keep": [186_756, 186_694],
|
|
"the-nexus": [188_526, 188_527, 188_528],
|
|
"draktharon-keep": [189_299, 189_300, 189_301, 189_302],
|
|
"azjol-nerub": [192_395, 192_396, 192_397, 192_398],
|
|
"halls-of-lightning": [191_325, 191_326],
|
|
"ahnkahet-old-kingdom": [192_236, 193_093, 193_094, 193_564],
|
|
"forge-of-souls": [],
|
|
});
|
|
|
|
const SUPPRESSED_ENTRIES: Readonly<Record<WowWrathEncounterBatchThreeDungeonId, readonly number[]>> = Object.freeze({
|
|
"utgarde-keep": [24_200, 24_201, 23_954],
|
|
"the-nexus": [26_731, 26_763, 26_723],
|
|
"draktharon-keep": [26_631, 26_632],
|
|
"azjol-nerub": [28_684, 28_921, 29_120, 28_729, 28_730, 28_731],
|
|
"halls-of-lightning": [28_546],
|
|
"ahnkahet-old-kingdom": [29_309, 29_308, 29_310, 29_311],
|
|
"forge-of-souls": [36_497],
|
|
});
|
|
|
|
function decodeBase64(value: string): Uint8Array {
|
|
const decoded = atob(value);
|
|
const bytes = new Uint8Array(decoded.length);
|
|
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
|
return bytes;
|
|
}
|
|
|
|
const generatedDefinitions = (
|
|
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
|
readonly definitions: readonly DungeonDefinition[];
|
|
}
|
|
).definitions;
|
|
|
|
function sourceDefinition(id: WowWrathEncounterBatchThreeDungeonId): DungeonDefinition {
|
|
const source = generatedDefinitions.find((definition) => definition.id === id);
|
|
if (!source) throw new Error(`Missing generated dungeon ${id}`);
|
|
return source;
|
|
}
|
|
|
|
function adaptedDefinition(id: WowWrathEncounterBatchThreeDungeonId): DungeonDefinition {
|
|
return withWowWrathEncounterBatchThreeContent(sourceDefinition(id));
|
|
}
|
|
|
|
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
|
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:wrath-batch-three-instance`);
|
|
if (!value) throw new Error(`Missing batch-three encounter for ${definition.id}`);
|
|
return value;
|
|
}
|
|
|
|
function normalized(value: string): string {
|
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
}
|
|
|
|
function objective(definition: DungeonDefinition, nameFragment: string): DungeonBossObjective {
|
|
const fragment = normalized(nameFragment);
|
|
const value = definition.bosses.find((candidate) => normalized(candidate.name).includes(fragment));
|
|
if (!value) throw new Error(`${definition.id} is missing objective matching ${nameFragment}`);
|
|
return value;
|
|
}
|
|
|
|
function group(definition: DungeonDefinition, id: string) {
|
|
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
|
if (!value) throw new Error(`${definition.id} is missing encounter group ${id}`);
|
|
return value;
|
|
}
|
|
|
|
function send(
|
|
definition: DungeonEncounterDefinition,
|
|
snapshot: DungeonEncounterSnapshot,
|
|
event: DungeonEncounterEvent,
|
|
difficultyId: "normal" | "heroic" = "normal",
|
|
): DungeonEncounterSnapshot {
|
|
return reduceDungeonEncounter(definition, snapshot, event, { difficultyId }).snapshot;
|
|
}
|
|
|
|
function killGroup(
|
|
definition: DungeonEncounterDefinition,
|
|
snapshot: DungeonEncounterSnapshot,
|
|
actorIds: readonly string[],
|
|
now: number,
|
|
difficultyId: "normal" | "heroic" = "normal",
|
|
): DungeonEncounterSnapshot {
|
|
return send(definition, snapshot, {
|
|
kind: "tick",
|
|
now,
|
|
facts: { registeredEntityIds: actorIds, deadEntityIds: actorIds },
|
|
}, difficultyId);
|
|
}
|
|
|
|
function assertRecoverable(
|
|
definition: DungeonEncounterDefinition,
|
|
snapshot: DungeonEncounterSnapshot,
|
|
now: number,
|
|
difficultyId: "normal" | "heroic" = "normal",
|
|
): DungeonEncounterSnapshot {
|
|
const completed = snapshot.completedObjectiveIds;
|
|
const checkpoint = snapshot.checkpointId;
|
|
expect(checkpoint).not.toBeNull();
|
|
let recovered = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", now);
|
|
expect(recovered.phase).toBe("resume");
|
|
recovered = send(definition, recovered, { kind: "tick", now: now + 1 }, difficultyId);
|
|
expect(recovered.phase).not.toBe("resume");
|
|
expect(recovered.completedObjectiveIds).toEqual(completed);
|
|
expect(recovered.checkpointId).toBe(checkpoint);
|
|
return recovered;
|
|
}
|
|
|
|
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
|
|
return [
|
|
...definition.staticSpawns.map((spawn) => spawn.id),
|
|
...definition.roamingPacks.flatMap((pack) => pack.members.map((member) => `${pack.id}:${member.id}`)),
|
|
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
|
|
];
|
|
}
|
|
|
|
describe("Wrath encounter batch three source contract", () => {
|
|
it("pins every reviewed AzerothCore source file by SHA-256", async () => {
|
|
expect(WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
|
|
for (const review of WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS) {
|
|
expect(review.fidelity).toBe("source-backed-adapted");
|
|
expect(review.adaptations.length).toBeGreaterThan(0);
|
|
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
|
for (const sourceFile of review.sourceFiles) {
|
|
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
|
const bytes = await readFile(sourceFile.path);
|
|
expect(createHash("sha256").update(bytes).digest("hex"), sourceFile.path).toBe(sourceFile.sha256);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("is idempotent and keeps all definitions, references, and source-row replacements valid", () => {
|
|
for (const id of TARGETS) {
|
|
const source = sourceDefinition(id);
|
|
const adapted = adaptedDefinition(id);
|
|
const controller = encounter(adapted);
|
|
expect(withWowWrathEncounterBatchThreeContent(adapted)).toBe(adapted);
|
|
expect(controller.source?.scope).toBe("instance-orchestration");
|
|
validateDungeonEncounterDefinition(controller);
|
|
adapted.serverGameObjects.forEach(validateDungeonWorldObject);
|
|
|
|
const objectiveIds = adapted.bosses.map((boss) => boss.id);
|
|
expect(new Set(objectiveIds).size, `${id} objective ids`).toBe(objectiveIds.length);
|
|
const orderedObjectiveIds = adapted.objectiveOrder.map((boss) => boss.id);
|
|
expect(new Set(orderedObjectiveIds).size, `${id} objective-order ids`).toBe(orderedObjectiveIds.length);
|
|
const spawnIds = runtimeSpawnIds(adapted);
|
|
expect(new Set(spawnIds).size, `${id} runtime spawn ids`).toBe(spawnIds.length);
|
|
const objectIds = adapted.serverGameObjects.map((object) => object.id);
|
|
expect(new Set(objectIds).size, `${id} object ids`).toBe(objectIds.length);
|
|
const sourceObjectSpawnIds = adapted.serverGameObjects
|
|
.map((object) => object.source?.spawnId)
|
|
.filter((spawnId): spawnId is number => spawnId !== undefined);
|
|
expect(new Set(sourceObjectSpawnIds).size, `${id} source object rows`).toBe(sourceObjectSpawnIds.length);
|
|
|
|
for (const templateId of REVIEWED_TEMPLATES[id]) {
|
|
const originalRows = source.serverGameObjects.filter((object) => object.source?.templateId === templateId);
|
|
const adaptedRows = adapted.serverGameObjects.filter((object) => object.source?.templateId === templateId);
|
|
expect(adaptedRows.length, `${id} template ${templateId}`).toBe(originalRows.length);
|
|
expect(adaptedRows.map((object) => object.source?.spawnId)).toEqual(originalRows.map((object) => object.source?.spawnId));
|
|
expect(adaptedRows.every((object) => object.source?.fidelity === "source-backed-adapted")).toBe(true);
|
|
expect(adaptedRows.some((object) => originalRows.some((original) => original.id === object.id))).toBe(false);
|
|
}
|
|
|
|
const remainingEntries = new Set(adapted.staticSpawns.map((spawn) => adapted.entities[spawn.entityId]?.combat?.serverEntry));
|
|
for (const entry of SUPPRESSED_ENTRIES[id]) expect(remainingEntries.has(entry), `${id} static entry ${entry}`).toBe(false);
|
|
|
|
const spawnGroupIds = new Set((adapted.encounterSpawnGroups ?? []).map((spawnGroup) => spawnGroup.id));
|
|
const worldObjectIds = new Set(adapted.serverGameObjects.map((object) => object.id));
|
|
const validObjectiveIds = new Set(adapted.bosses.map((boss) => boss.id));
|
|
const validEntityIds = new Set(runtimeSpawnIds(adapted));
|
|
for (const rule of controller.rules) {
|
|
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
|
|
expect(validEntityIds.has(rule.trigger.entityId), `${id} trigger entity ${rule.trigger.entityId}`).toBe(true);
|
|
}
|
|
if (rule.trigger.kind === "entities-dead") {
|
|
rule.trigger.entityIds.forEach((entityId) => expect(validEntityIds.has(entityId), `${id} dead-set entity ${entityId}`).toBe(true));
|
|
}
|
|
if (rule.trigger.kind === "interact") expect(worldObjectIds.has(rule.trigger.objectId), `${id} interaction ${rule.trigger.objectId}`).toBe(true);
|
|
for (const action of rule.actions) {
|
|
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
|
expect(spawnGroupIds.has(action.spawnGroupId), `${id} action group ${action.spawnGroupId}`).toBe(true);
|
|
}
|
|
if (action.kind === "set-world-object-state") expect(worldObjectIds.has(action.objectId), `${id} action object ${action.objectId}`).toBe(true);
|
|
if (action.kind === "complete-objective") expect(validObjectiveIds.has(action.objectiveId), `${id} action objective ${action.objectiveId}`).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Wrath encounter batch three lifecycle", () => {
|
|
it("runs Utgarde Keep's order-safe duo ghosts and timed Ingvar resurrection with wipe recovery", () => {
|
|
const dungeon = adaptedDefinition("utgarde-keep");
|
|
const controller = encounter(dungeon);
|
|
const keleseth = objective(dungeon, "Keleseth");
|
|
const duo = objective(dungeon, "Skarvold");
|
|
const ingvar = objective(dungeon, "Ingvar");
|
|
const skarvald = group(dungeon, "utgarde-keep:skarvald");
|
|
const dalronn = group(dungeon, "utgarde-keep:dalronn");
|
|
const skarvaldGhost = group(dungeon, "utgarde-keep:skarvald-ghost");
|
|
const dalronnGhost = group(dungeon, "utgarde-keep:dalronn-ghost");
|
|
const ingvarLiving = group(dungeon, "utgarde-keep:ingvar-living");
|
|
const ingvarUndead = group(dungeon, "utgarde-keep:ingvar-undead");
|
|
let now = 0;
|
|
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
|
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: keleseth.id, now: ++now });
|
|
expect(snapshot.phase).toBe("duo-active");
|
|
expect(snapshot.activeSpawnGroupIds).toEqual(expect.arrayContaining([skarvald.id, dalronn.id]));
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: dalronn.spawns[0].id, now: ++now });
|
|
expect(snapshot.flags["dalronn-dead"]).toBe(true);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(dalronnGhost.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: skarvald.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toContain(duo.id);
|
|
expect(snapshot.activeSpawnGroupIds).not.toContain(skarvaldGhost.id);
|
|
expect(snapshot.activeSpawnGroupIds).not.toContain(dalronnGhost.id);
|
|
expect(snapshot.phase).toBe("ingvar-living-active");
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ingvarLiving.spawns[0].id, now: ++now });
|
|
expect(snapshot.phase).toBe("ingvar-resurrection");
|
|
const resurrectionStarted = now;
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 19_999 });
|
|
expect(snapshot.phase).toBe("ingvar-resurrection");
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 20_000 });
|
|
expect(snapshot.phase).toBe("ingvar-undead-active");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(ingvarUndead.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ingvarUndead.spawns[0].id, now: resurrectionStarted + 20_001 });
|
|
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([keleseth.id, duo.id, ingvar.id]));
|
|
expect(snapshot.worldObjectStates["utgarde-keep:reviewed-portcullis-one"]).toBe("open");
|
|
expect(snapshot.worldObjectStates["utgarde-keep:reviewed-portcullis-two"]).toBe("open");
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 20_002 });
|
|
expect(snapshot.phase).toBe("complete");
|
|
|
|
let alternate = createDungeonEncounterSnapshot(controller, 0);
|
|
alternate = send(controller, alternate, { kind: "entity-death", entityId: keleseth.id, now: 1 });
|
|
alternate = send(controller, alternate, { kind: "entity-death", entityId: skarvald.spawns[0].id, now: 2 });
|
|
expect(alternate.activeSpawnGroupIds).toContain(skarvaldGhost.id);
|
|
alternate = send(controller, alternate, { kind: "entity-death", entityId: dalronn.spawns[0].id, now: 3 });
|
|
expect(alternate.completedObjectiveIds).toContain(duo.id);
|
|
});
|
|
|
|
it("keeps the source combat schedules on Utgarde Keep's ghost and undead replacement actors", () => {
|
|
const dungeon = adaptedDefinition("utgarde-keep");
|
|
const entityForGroup = (groupId: string) => {
|
|
const entityId = group(dungeon, groupId).spawns[0].entityId;
|
|
return dungeon.entities[entityId];
|
|
};
|
|
const attacksForGroup = (groupId: string) => {
|
|
return entityForGroup(groupId).combat?.attacks.filter((attack) => attack.id !== "basic-attack") ?? [];
|
|
};
|
|
|
|
expect(entityForGroup("utgarde-keep:dalronn-ghost").combat?.attacks[0]).toMatchObject({
|
|
id: "basic-attack",
|
|
name: "Basic Attack",
|
|
delivery: "melee",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 4.5,
|
|
});
|
|
|
|
expect(attacksForGroup("utgarde-keep:skarvald-ghost").map((attack) => ({
|
|
id: attack.id,
|
|
spellId: attack.spellId,
|
|
initial: attack.initialCooldownMs,
|
|
repeat: attack.repeatCooldownMs,
|
|
}))).toEqual([
|
|
{ id: "charge-43651", spellId: 43_651, initial: [5_000, 5_000], repeat: [5_000, 10_000] },
|
|
{ id: "stone-strike-48583", spellId: 48_583, initial: [10_000, 10_000], repeat: [5_000, 10_000] },
|
|
]);
|
|
expect(attacksForGroup("utgarde-keep:dalronn-ghost").map((attack) => ({
|
|
id: attack.id,
|
|
spellId: attack.spellId,
|
|
initial: attack.initialCooldownMs,
|
|
repeat: attack.repeatCooldownMs,
|
|
}))).toEqual([
|
|
{ id: "shadow-bolt-43649", spellId: 43_649, initial: [1_000, 1_000], repeat: [2_000, 2_000] },
|
|
{ id: "debilitate-43650", spellId: 43_650, initial: [5_000, 5_000], repeat: [5_000, 10_000] },
|
|
]);
|
|
expect(attacksForGroup("utgarde-keep:dalronn-ghost")[1].effect)
|
|
.toEqual({ kind: "target-aura", aura: "debilitate", durationMs: 8_000 });
|
|
expect(attacksForGroup("utgarde-keep:ingvar-undead").map((attack) => ({
|
|
id: attack.id,
|
|
spellId: attack.spellId,
|
|
initial: attack.initialCooldownMs,
|
|
repeat: attack.repeatCooldownMs,
|
|
}))).toEqual([
|
|
{ id: "dreadful-roar-42729", spellId: 42_729, initial: [15_000, 15_000], repeat: [15_000, 20_000] },
|
|
{ id: "woe-strike-42730", spellId: 42_730, initial: [2_000, 2_000], repeat: [3_000, 7_000] },
|
|
{ id: "dark-smash-42723", spellId: 42_723, initial: [5_000, 5_000], repeat: [9_000, 11_000] },
|
|
{ id: "shadow-axe-42749", spellId: 42_749, initial: [10_000, 10_000], repeat: [35_000, 35_000] },
|
|
]);
|
|
expect(attacksForGroup("utgarde-keep:ingvar-undead").find((attack) => (
|
|
attack.id === "woe-strike-42730"
|
|
))?.school).toBe("physical");
|
|
expect(attacksForGroup("utgarde-keep:ingvar-undead").find((attack) => (
|
|
attack.id === "shadow-axe-42749"
|
|
))?.school).toBe("physical");
|
|
|
|
const review = WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
|
|
candidate.dungeonId === "utgarde-keep"
|
|
))!;
|
|
expect(review.remainingGaps.join(" ")).toContain("Woe Strike's damage-on-heal reaction");
|
|
expect(review.remainingGaps.join(" ")).toContain("Shadow Axe's outbound/returning actor");
|
|
});
|
|
|
|
it("runs Nexus spheres, normal Telestra split, Keristrasza release, and checkpoint recovery", () => {
|
|
const dungeon = adaptedDefinition("the-nexus");
|
|
const controller = encounter(dungeon);
|
|
const anomalus = objective(dungeon, "Anomalus");
|
|
const ormorok = objective(dungeon, "Ormorok");
|
|
const telestra = objective(dungeon, "Telestra");
|
|
const keristrasza = objective(dungeon, "Keristrasza");
|
|
const opening = group(dungeon, "the-nexus:telestra-opening");
|
|
const anomalusOpening = group(dungeon, "the-nexus:anomalus-opening");
|
|
const anomalusRift = group(dungeon, "the-nexus:anomalus-empowered-rift");
|
|
const anomalusFinal = group(dungeon, "the-nexus:anomalus-final");
|
|
const clones = group(dungeon, "the-nexus:telestra-first-clones");
|
|
const normalFinal = group(dungeon, "the-nexus:telestra-normal-final");
|
|
const keristraszaActor = group(dungeon, "the-nexus:keristrasza");
|
|
let now = 0;
|
|
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
|
|
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: anomalusOpening.spawns[0].id, healthPercent: 51, now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(anomalusRift.id);
|
|
snapshot = killGroup(controller, snapshot, anomalusRift.spawns.map((spawn) => spawn.id), ++now);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(anomalusFinal.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: anomalusFinal.spawns[0].id, now: ++now });
|
|
expect(snapshot.worldObjectStates["the-nexus:reviewed-anomalus-sphere"]).toBe("ready");
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ormorok.id, now: ++now });
|
|
expect(snapshot.worldObjectStates["the-nexus:reviewed-ormorok-sphere"]).toBe("ready");
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 51, now: ++now });
|
|
expect(snapshot.phase).toBe("telestra-first-clones");
|
|
snapshot = killGroup(controller, snapshot, clones.spawns.map((spawn) => spawn.id), ++now);
|
|
expect(snapshot.phase).toBe("telestra-normal-final");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(normalFinal.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: normalFinal.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toContain(telestra.id);
|
|
expect(snapshot.worldObjectStates["the-nexus:reviewed-telestra-sphere"]).toBe("ready");
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
|
|
for (const sphere of [
|
|
"the-nexus:reviewed-telestra-sphere",
|
|
"the-nexus:reviewed-anomalus-sphere",
|
|
"the-nexus:reviewed-ormorok-sphere",
|
|
]) snapshot = send(controller, snapshot, { kind: "interact", objectId: sphere, now: ++now });
|
|
expect(snapshot.counters["spheres-used"]).toBe(3);
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(keristraszaActor.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: keristraszaActor.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toContain(keristrasza.id);
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
|
expect(snapshot.phase).toBe("complete");
|
|
});
|
|
|
|
it("selects Telestra's second split only on heroic", () => {
|
|
const dungeon = adaptedDefinition("the-nexus");
|
|
const controller = encounter(dungeon);
|
|
const opening = group(dungeon, "the-nexus:telestra-opening");
|
|
const firstClones = group(dungeon, "the-nexus:telestra-first-clones");
|
|
const middle = group(dungeon, "the-nexus:telestra-heroic-middle");
|
|
const secondClones = group(dungeon, "the-nexus:telestra-second-clones");
|
|
const final = group(dungeon, "the-nexus:telestra-heroic-final");
|
|
let snapshot = createDungeonEncounterSnapshot(controller, 0);
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: 1 }, "heroic");
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 51, now: 2 }, "heroic");
|
|
snapshot = killGroup(controller, snapshot, firstClones.spawns.map((spawn) => spawn.id), 3, "heroic");
|
|
expect(snapshot.phase).toBe("telestra-heroic-middle");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(middle.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: middle.spawns[0].id, healthPercent: 11, now: 4 }, "heroic");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(secondClones.id);
|
|
snapshot = killGroup(controller, snapshot, secondClones.spawns.map((spawn) => spawn.id), 5, "heroic");
|
|
expect(snapshot.phase).toBe("telestra-heroic-final");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(final.id);
|
|
});
|
|
|
|
it("runs Drak'Tharon's four crystal waves and Tharon'ja stage with each checkpoint recoverable", () => {
|
|
const dungeon = adaptedDefinition("draktharon-keep");
|
|
const controller = encounter(dungeon);
|
|
const trollgore = objective(dungeon, "Trollgore");
|
|
const novos = objective(dungeon, "Novos");
|
|
const dred = objective(dungeon, "King Dred");
|
|
const tharon = objective(dungeon, "Tharon'ja");
|
|
const novosActor = group(dungeon, "draktharon-keep:novos");
|
|
const tharonOpening = group(dungeon, "draktharon-keep:tharon-opening");
|
|
const tharonFlesh = group(dungeon, "draktharon-keep:tharon-flesh");
|
|
let now = 0;
|
|
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: trollgore.id, now: ++now });
|
|
expect(snapshot.phase).toBe("novos-handler-1");
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
for (let waveIndex = 1; waveIndex <= 4; waveIndex += 1) {
|
|
const wave = group(dungeon, `draktharon-keep:novos-handler-wave-${waveIndex}`);
|
|
snapshot = killGroup(controller, snapshot, wave.spawns.map((spawn) => spawn.id), ++now);
|
|
expect(snapshot.worldObjectStates[`draktharon-keep:reviewed-novos-crystal-${waveIndex}`]).toBe("complete");
|
|
}
|
|
expect(snapshot.phase).toBe("novos-active");
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: novosActor.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toContain(novos.id);
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
expect(snapshot.phase).toBe("dred-active");
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: dred.id, now: ++now });
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(tharonOpening.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: tharonOpening.spawns[0].id, healthPercent: 55, now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(tharonFlesh.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: tharonFlesh.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([trollgore.id, novos.id, dred.id, tharon.id]));
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
|
expect(snapshot.phase).toBe("complete");
|
|
});
|
|
});
|
|
|
|
function advanceAzjolToAnubarak(difficultyId: "normal" | "heroic") {
|
|
const dungeon = adaptedDefinition("azjol-nerub");
|
|
const controller = encounter(dungeon);
|
|
const krik = objective(dungeon, "Krik'thir");
|
|
const hadronox = objective(dungeon, "Hadronox");
|
|
const watchers = group(dungeon, "azjol-nerub:krikthir-watchers");
|
|
const krikActor = group(dungeon, "azjol-nerub:krikthir");
|
|
const hadronoxActor = group(dungeon, "azjol-nerub:hadronox");
|
|
let now = 0;
|
|
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now }, difficultyId);
|
|
snapshot = killGroup(controller, snapshot, watchers.spawns.map((spawn) => spawn.id), ++now, difficultyId);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(krikActor.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: krikActor.spawns[0].id, now: ++now }, difficultyId);
|
|
expect(snapshot.completedObjectiveIds).toContain(krik.id);
|
|
snapshot = assertRecoverable(controller, snapshot, ++now, difficultyId);
|
|
expect(snapshot.worldObjectStates["azjol-nerub:reviewed-krikthir-passage"]).toBe("open");
|
|
snapshot = send(controller, snapshot, { kind: "interact", objectId: "azjol-nerub:hadronox-start", now: ++now }, difficultyId);
|
|
for (let waveIndex = 1; waveIndex <= 3; waveIndex += 1) {
|
|
const wave = group(dungeon, `azjol-nerub:crusher-wave-${waveIndex}`);
|
|
snapshot = killGroup(controller, snapshot, wave.spawns.map((spawn) => spawn.id), ++now, difficultyId);
|
|
}
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: hadronoxActor.spawns[0].id, now: ++now }, difficultyId);
|
|
expect(snapshot.completedObjectiveIds).toContain(hadronox.id);
|
|
snapshot = assertRecoverable(controller, snapshot, ++now, difficultyId);
|
|
snapshot = send(controller, snapshot, { kind: "interact", objectId: "azjol-nerub:anubarak-start", now: ++now }, difficultyId);
|
|
expect(snapshot.phase).toBe("anubarak-stage-1");
|
|
for (let doorIndex = 1; doorIndex <= 3; doorIndex += 1) {
|
|
expect(snapshot.worldObjectStates[`azjol-nerub:reviewed-anubarak-door-${doorIndex}`]).toBe("closed");
|
|
}
|
|
return { dungeon, controller, snapshot, now };
|
|
}
|
|
|
|
describe("Wrath encounter batch three route gates", () => {
|
|
it("runs Azjol-Nerub's normal watcher, crusher, and three-submerge route", () => {
|
|
const prepared = advanceAzjolToAnubarak("normal");
|
|
const { dungeon, controller } = prepared;
|
|
const anub = objective(dungeon, "Anub'arak");
|
|
let { snapshot, now } = prepared;
|
|
const thresholds = [75, 50, 25] as const;
|
|
for (let index = 0; index < thresholds.length; index += 1) {
|
|
const stage = group(dungeon, `azjol-nerub:anubarak-stage-${index + 1}`);
|
|
const adds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-normal`);
|
|
snapshot = send(controller, snapshot, {
|
|
kind: "entity-health",
|
|
entityId: stage.spawns[0].id,
|
|
healthPercent: thresholds[index],
|
|
now: ++now,
|
|
});
|
|
expect(snapshot.activeSpawnGroupIds).toContain(adds.id);
|
|
expect(adds.spawns.length).toBe([7, 8, 13][index]);
|
|
snapshot = killGroup(controller, snapshot, adds.spawns.map((spawn) => spawn.id), ++now);
|
|
expect(snapshot.phase).toBe(`anubarak-stage-${index + 2}`);
|
|
}
|
|
const finalStage = group(dungeon, "azjol-nerub:anubarak-stage-4");
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: finalStage.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toContain(anub.id);
|
|
for (let doorIndex = 1; doorIndex <= 3; doorIndex += 1) {
|
|
expect(snapshot.worldObjectStates[`azjol-nerub:reviewed-anubarak-door-${doorIndex}`]).toBe("open");
|
|
}
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
|
expect(snapshot.phase).toBe("complete");
|
|
});
|
|
|
|
it("selects every larger Anub'arak intermission on heroic", () => {
|
|
const prepared = advanceAzjolToAnubarak("heroic");
|
|
const { dungeon, controller } = prepared;
|
|
let { snapshot, now } = prepared;
|
|
const thresholds = [75, 50, 25] as const;
|
|
for (let index = 0; index < thresholds.length; index += 1) {
|
|
const stage = group(dungeon, `azjol-nerub:anubarak-stage-${index + 1}`);
|
|
const heroicAdds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-heroic`);
|
|
const normalAdds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-normal`);
|
|
snapshot = send(controller, snapshot, {
|
|
kind: "entity-health",
|
|
entityId: stage.spawns[0].id,
|
|
healthPercent: thresholds[index],
|
|
now: ++now,
|
|
}, "heroic");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(heroicAdds.id);
|
|
expect(snapshot.activeSpawnGroupIds).not.toContain(normalAdds.id);
|
|
expect(heroicAdds.spawns.length).toBe([8, 10, 15][index]);
|
|
expect(heroicAdds.spawns.every((spawn) => spawn.spawnMask === 2)).toBe(true);
|
|
snapshot = killGroup(controller, snapshot, heroicAdds.spawns.map((spawn) => spawn.id), ++now, "heroic");
|
|
}
|
|
expect(snapshot.phase).toBe("anubarak-stage-4");
|
|
});
|
|
|
|
it("runs Halls of Lightning's timed Ionar split and mandatory watcher checkpoint", () => {
|
|
const dungeon = adaptedDefinition("halls-of-lightning");
|
|
const controller = encounter(dungeon);
|
|
const bjarngrim = objective(dungeon, "Bjarngrim");
|
|
const volkhan = objective(dungeon, "Volkhan");
|
|
const ionar = objective(dungeon, "Ionar");
|
|
const loken = objective(dungeon, "Loken");
|
|
const opening = group(dungeon, "halls-of-lightning:ionar-opening");
|
|
const sparks = group(dungeon, "halls-of-lightning:ionar-sparks");
|
|
const final = group(dungeon, "halls-of-lightning:ionar-final");
|
|
const watchers = group(dungeon, "halls-of-lightning:awakened-watchers");
|
|
let now = 0;
|
|
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: bjarngrim.id, now: ++now });
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
expect(snapshot.phase).toBe("volkhan-active");
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: volkhan.id, now: ++now });
|
|
expect(snapshot.worldObjectStates["halls-of-lightning:reviewed-volkhan-door"]).toBe("open");
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 50, now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(sparks.id);
|
|
const splitStarted = now;
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 19_999 });
|
|
expect(snapshot.phase).toBe("ionar-sparks-active");
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 20_000 });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(final.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: final.spawns[0].id, now: splitStarted + 20_001 });
|
|
expect(snapshot.completedObjectiveIds).toContain(ionar.id);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(watchers.id);
|
|
snapshot = assertRecoverable(controller, snapshot, splitStarted + 20_002);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(watchers.id);
|
|
snapshot = killGroup(controller, snapshot, watchers.spawns.map((spawn) => spawn.id), splitStarted + 20_003);
|
|
expect(snapshot.flags["watchers-cleared"]).toBe(true);
|
|
expect(snapshot.phase).toBe("loken-active");
|
|
snapshot = assertRecoverable(controller, snapshot, splitStarted + 20_004);
|
|
expect(snapshot.phase).toBe("loken-active");
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: loken.id, now: splitStarted + 20_005 });
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 20_006 });
|
|
expect(snapshot.phase).toBe("complete");
|
|
});
|
|
|
|
it("runs Ahn'kahet's spheres, guardian, ritual, insanity stages, and recovers each boss checkpoint", () => {
|
|
const dungeon = adaptedDefinition("ahnkahet-old-kingdom");
|
|
const controller = encounter(dungeon);
|
|
const nadox = objective(dungeon, "Nadox");
|
|
const taldaram = objective(dungeon, "Taldaram");
|
|
const jedoga = objective(dungeon, "Jedoga");
|
|
const volazj = objective(dungeon, "Volazj");
|
|
const nadoxOpening = group(dungeon, "ahnkahet-old-kingdom:nadox-opening");
|
|
const nadoxGuardian = group(dungeon, "ahnkahet-old-kingdom:nadox-guardian");
|
|
const nadoxFinal = group(dungeon, "ahnkahet-old-kingdom:nadox-final");
|
|
const taldaramActor = group(dungeon, "ahnkahet-old-kingdom:taldaram");
|
|
const initiates = group(dungeon, "ahnkahet-old-kingdom:jedoga-initiates");
|
|
const jedogaOpening = group(dungeon, "ahnkahet-old-kingdom:jedoga-opening");
|
|
const volunteer = group(dungeon, "ahnkahet-old-kingdom:jedoga-volunteer");
|
|
const jedogaFinal = group(dungeon, "ahnkahet-old-kingdom:jedoga-final");
|
|
let now = 0;
|
|
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
|
for (let sphereIndex = 1; sphereIndex <= 2; sphereIndex += 1) {
|
|
snapshot = send(controller, snapshot, { kind: "interact", objectId: `ahnkahet-old-kingdom:reviewed-sphere-${sphereIndex}`, now: ++now });
|
|
}
|
|
expect(snapshot.counters["spheres-used"]).toBe(2);
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: nadoxOpening.spawns[0].id, healthPercent: 50, now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(nadoxGuardian.id);
|
|
snapshot = killGroup(controller, snapshot, nadoxGuardian.spawns.map((spawn) => spawn.id), ++now);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: nadoxFinal.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toContain(nadox.id);
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(taldaramActor.id);
|
|
expect(snapshot.worldObjectStates["ahnkahet-old-kingdom:reviewed-taldaram-platform"]).toBe("open");
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(taldaramActor.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: taldaramActor.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toContain(taldaram.id);
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(initiates.id);
|
|
snapshot = killGroup(controller, snapshot, initiates.spawns.map((spawn) => spawn.id), ++now);
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: jedogaOpening.spawns[0].id, healthPercent: 55, now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(volunteer.id);
|
|
snapshot = killGroup(controller, snapshot, volunteer.spawns.map((spawn) => spawn.id), ++now);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: jedogaFinal.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toContain(jedoga.id);
|
|
snapshot = assertRecoverable(controller, snapshot, ++now);
|
|
|
|
for (const [index, threshold] of [66, 33].entries()) {
|
|
const stage = group(dungeon, `ahnkahet-old-kingdom:volazj-stage-${index + 1}`);
|
|
const visages = group(dungeon, `ahnkahet-old-kingdom:volazj-visages-${index + 1}`);
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: stage.spawns[0].id, healthPercent: threshold, now: ++now });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(visages.id);
|
|
snapshot = killGroup(controller, snapshot, visages.spawns.map((spawn) => spawn.id), ++now);
|
|
}
|
|
const volazjFinal = group(dungeon, "ahnkahet-old-kingdom:volazj-stage-3");
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: volazjFinal.spawns[0].id, now: ++now });
|
|
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([nadox.id, taldaram.id, jedoga.id, volazj.id]));
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
|
expect(snapshot.phase).toBe("complete");
|
|
|
|
const amanitarEntry = dungeon.staticSpawns
|
|
.filter((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 30_258);
|
|
expect(amanitarEntry).toHaveLength(1);
|
|
expect(amanitarEntry[0].spawnMask).toBe(2);
|
|
});
|
|
|
|
it("keeps Forge of Souls doorless while staging Bronjahm's 35-percent Soulstorm", () => {
|
|
const dungeon = adaptedDefinition("forge-of-souls");
|
|
const controller = encounter(dungeon);
|
|
const bronjahm = objective(dungeon, "Bronjahm");
|
|
const devourer = objective(dungeon, "Devourer");
|
|
const opening = group(dungeon, "forge-of-souls:bronjahm-opening");
|
|
const soulstorm = group(dungeon, "forge-of-souls:bronjahm-soulstorm");
|
|
let snapshot = createDungeonEncounterSnapshot(controller, 0);
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: 1 });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 35, now: 2 });
|
|
expect(snapshot.activeSpawnGroupIds).toContain(soulstorm.id);
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: soulstorm.spawns[0].id, now: 3 });
|
|
expect(snapshot.completedObjectiveIds).toContain(bronjahm.id);
|
|
snapshot = assertRecoverable(controller, snapshot, 4);
|
|
expect(snapshot.phase).toBe("devourer-active");
|
|
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: devourer.id, now: 5 });
|
|
snapshot = send(controller, snapshot, { kind: "tick", now: 6 });
|
|
expect(snapshot.phase).toBe("complete");
|
|
expect(REVIEWED_TEMPLATES["forge-of-souls"]).toEqual([]);
|
|
});
|
|
});
|