516 lines
30 KiB
TypeScript
516 lines
30 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { gunzipSync, strFromU8 } from "fflate";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createDungeonEncounterSnapshot,
|
|
reduceDungeonEncounter,
|
|
resetDungeonEncounterSnapshot,
|
|
validateDungeonEncounterDefinition,
|
|
} from "./dungeonEncounterRuntime";
|
|
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
|
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
|
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
|
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
|
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
|
import {
|
|
WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS,
|
|
withWowClassicEncounterBatchThree,
|
|
} from "./wowClassicEncounterBatchThreeCatalog";
|
|
|
|
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 campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
|
readonly definitions: readonly DungeonDefinition[];
|
|
};
|
|
|
|
function original(id: string): DungeonDefinition {
|
|
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
|
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
|
return definition;
|
|
}
|
|
|
|
function preadapted(id: string): DungeonDefinition {
|
|
return withWowEncounterContent(original(id));
|
|
}
|
|
|
|
function adapted(id: string): DungeonDefinition {
|
|
return withWowClassicEncounterBatchThree(preadapted(id));
|
|
}
|
|
|
|
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
|
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-three`);
|
|
if (!value) throw new Error(`Missing Classic batch-three encounter for ${definition.id}`);
|
|
return value;
|
|
}
|
|
|
|
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
|
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
|
if (!value) throw new Error(`Missing encounter group ${id}`);
|
|
return value;
|
|
}
|
|
|
|
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
|
|
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
|
|
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
|
|
return matches[0];
|
|
}
|
|
|
|
function killGroup(
|
|
scripted: DungeonEncounterDefinition,
|
|
snapshot: DungeonEncounterSnapshot,
|
|
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
|
now: number,
|
|
): DungeonEncounterSnapshot {
|
|
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
|
return reduceDungeonEncounter(scripted, snapshot, {
|
|
kind: "tick",
|
|
now,
|
|
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
|
}).snapshot;
|
|
}
|
|
|
|
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
|
return definition.staticSpawns.flatMap((spawn) => {
|
|
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
|
return entry === undefined ? [] : [entry];
|
|
});
|
|
}
|
|
|
|
describe("Classic source-backed encounter batch three", () => {
|
|
it("pins sources, validates references, suppresses event actors, remains idempotent, and preserves Solakar", () => {
|
|
const cases = [
|
|
["gnomeregan", [7_998]],
|
|
["dire-maul", [11_496]],
|
|
["blackrock-spire", [9_816, 10_316, 10_429, 10_264]],
|
|
] as const;
|
|
expect(WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId))
|
|
.toEqual(cases.map(([id]) => id));
|
|
|
|
const checkedPaths = new Set<string>();
|
|
for (const review of WOW_CLASSIC_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.sha256).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
|
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
|
|
if (!checkedPaths.has(sourceFile.path)) {
|
|
expect(createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex"), sourceFile.path)
|
|
.toBe(sourceFile.sha256);
|
|
checkedPaths.add(sourceFile.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
const priorBRS = preadapted("blackrock-spire");
|
|
const priorSolakarEncounter = priorBRS.encounters?.find((value) => value.id === "blackrock-spire:wow-instance");
|
|
const priorSolakarGroups = priorBRS.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar"));
|
|
const priorFatherFlame = priorBRS.serverGameObjects.find((value) => value.source?.templateId === 175_245);
|
|
expect(priorSolakarEncounter).toBeDefined();
|
|
expect(priorSolakarGroups).toHaveLength(6);
|
|
expect(priorFatherFlame).toBeDefined();
|
|
|
|
for (const [id, suppressed] of cases) {
|
|
const definition = adapted(id);
|
|
const scripted = encounter(definition);
|
|
validateDungeonEncounterDefinition(scripted);
|
|
expect(scripted.source?.scope).toBe("instance-orchestration");
|
|
expect(withWowClassicEncounterBatchThree(definition)).toBe(definition);
|
|
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressed]));
|
|
|
|
const spawnIds = new Set([
|
|
...definition.staticSpawns.map((spawn) => spawn.id),
|
|
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
|
|
]);
|
|
const groupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
|
|
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
|
const objectiveIds = new Set(definition.bosses.map((objective) => objective.id));
|
|
expect(objectIds.size).toBe(definition.serverGameObjects.length);
|
|
expect(groupIds.size).toBe(definition.encounterSpawnGroups?.length ?? 0);
|
|
const actionIds = new Set<string>();
|
|
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
|
for (const spawn of spawnGroup.spawns) expect(definition.entities[spawn.entityId], `${id}:${spawn.id}`).toBeDefined();
|
|
}
|
|
for (const rule of scripted.rules) {
|
|
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
|
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
|
|
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
|
|
}
|
|
if (rule.trigger.kind === "entities-dead") {
|
|
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${id}:${rule.id}`).toBe(true);
|
|
}
|
|
for (const action of rule.actions) {
|
|
expect(actionIds.has(action.id), `${id}:duplicate action ${action.id}`).toBe(false);
|
|
actionIds.add(action.id);
|
|
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
|
expect(groupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
|
}
|
|
if (action.kind === "set-world-object-state") {
|
|
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
|
|
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
|
|
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
|
|
}
|
|
if (action.kind === "complete-objective") {
|
|
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
const sourceSpawnIds = definition.serverGameObjects.flatMap((object) => (
|
|
object.source?.spawnId === undefined ? [] : [object.source.spawnId]
|
|
));
|
|
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
|
|
for (const object of definition.serverGameObjects.filter((candidate) => (
|
|
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
|
|
))) {
|
|
validateDungeonWorldObject(object);
|
|
expect(object.visual?.hidden).not.toBe(true);
|
|
}
|
|
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
|
|
}
|
|
|
|
const brs = adapted("blackrock-spire");
|
|
expect(brs.encounters?.find((value) => value.id === "blackrock-spire:wow-instance")).toEqual(priorSolakarEncounter);
|
|
expect(brs.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar")))
|
|
.toEqual(priorSolakarGroups);
|
|
expect(brs.serverGameObjects.find((value) => value.source?.templateId === 175_245)).toEqual(priorFatherFlame);
|
|
});
|
|
|
|
it("ports Gyth and Urok's pinned combat schedules instead of leaving event bosses basic-only", () => {
|
|
const definition = adapted("blackrock-spire");
|
|
const byEntry = (entry: number) => Object.values(definition.entities)
|
|
.find((entity) => entity.combat?.serverEntry === entry)!;
|
|
const gyth = byEntry(10_339);
|
|
const urok = byEntry(10_584);
|
|
|
|
expect(gyth.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
|
|
id: attack.id,
|
|
spellId: attack.spellId,
|
|
initial: attack.initialCooldownMs,
|
|
repeat: attack.repeatCooldownMs,
|
|
}))).toEqual([
|
|
{ id: "corrosive-acid-16359", spellId: 16_359, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
|
{ id: "freeze-16350", spellId: 16_350, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
|
{ id: "flame-breath-16390", spellId: 16_390, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
|
{ id: "knock-away-10101", spellId: 10_101, initial: [12_000, 18_000], repeat: [14_000, 20_000] },
|
|
]);
|
|
expect(gyth.combat?.mechanicIds).toEqual([
|
|
"corrosive-acid-16359",
|
|
"freeze-16350",
|
|
"flame-breath-16390",
|
|
"knock-away-10101",
|
|
]);
|
|
expect(gyth.combat?.attacks.find((attack) => attack.id === "corrosive-acid-16359"))
|
|
.toMatchObject({
|
|
target: "nearby-party",
|
|
school: "nature",
|
|
range: 20,
|
|
radius: 20,
|
|
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
|
});
|
|
expect(gyth.combat?.attacks.find((attack) => attack.id === "freeze-16350"))
|
|
.toMatchObject({
|
|
target: "nearby-party",
|
|
school: "frost",
|
|
range: 20,
|
|
radius: 20,
|
|
effect: { kind: "control", mechanic: "stun", durationMs: 10_000, damageMultiplier: 0.8 },
|
|
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
|
});
|
|
expect(gyth.combat?.attacks.find((attack) => attack.id === "flame-breath-16390"))
|
|
.toMatchObject({
|
|
target: "nearby-party",
|
|
school: "fire",
|
|
range: 20,
|
|
radius: 20,
|
|
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
|
});
|
|
|
|
expect(urok.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
|
|
id: attack.id,
|
|
spellId: attack.spellId,
|
|
initial: attack.initialCooldownMs,
|
|
repeat: attack.repeatCooldownMs,
|
|
}))).toEqual([
|
|
{ id: "rend-16509", spellId: 16_509, initial: [17_000, 20_000], repeat: [8_000, 10_000] },
|
|
{ id: "strike-15580", spellId: 15_580, initial: [10_000, 12_000], repeat: [8_000, 10_000] },
|
|
{ id: "intimidating-roar-16508", spellId: 16_508, initial: [25_000, 30_000], repeat: [40_000, 45_000] },
|
|
]);
|
|
expect(urok.combat?.attacks.find((attack) => attack.id === "intimidating-roar-16508"))
|
|
.toMatchObject({
|
|
target: "nearby-party",
|
|
range: 8,
|
|
radius: 8,
|
|
effect: { kind: "control", mechanic: "fear", durationMs: 8_000 },
|
|
telegraph: { shape: "circle", anchor: "source", radius: 8 },
|
|
});
|
|
|
|
const review = WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
|
|
candidate.dungeonId === "blackrock-spire"
|
|
))!;
|
|
expect(review.remainingGaps.join(" ")).toContain("Freeze periodic damage");
|
|
expect(review.remainingGaps.join(" ")).toContain("split primary-root/nearby-fear targeting");
|
|
});
|
|
|
|
it("ports Crystal Fang's exact death summon while keeping the source-faithful rare boss melee-only", () => {
|
|
const definition = adapted("blackrock-spire");
|
|
const scripted = encounter(definition);
|
|
const crystalFang = definition.staticSpawns.find((spawn) => (
|
|
definition.entities[spawn.entityId]?.combat?.serverEntry === 10_376
|
|
))!;
|
|
const spiderling = group(definition, "blackrock-spire:batch-three:crystal-fang-spiderling");
|
|
expect(crystalFang.spawnChance).toBe(0.15);
|
|
expect(spiderling.spawns).toHaveLength(1);
|
|
expect(spiderling.spawns[0]).toMatchObject({
|
|
entityId: expect.stringContaining("entry-10375"),
|
|
position: crystalFang.position,
|
|
});
|
|
|
|
const snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
|
|
kind: "entity-death",
|
|
entityId: crystalFang.id,
|
|
now: 1,
|
|
}).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(spiderling.id);
|
|
});
|
|
|
|
it("runs Gnomeregan's exact Emi group counts, cave-ins, retry, doors, and persistent Grubbis gate", () => {
|
|
const definition = adapted("gnomeregan");
|
|
const scripted = encounter(definition);
|
|
const groups = ["ambushers-1", "ambushers-2", "ambushers-3a", "ambushers-3b", "grubbis"]
|
|
.map((name) => group(definition, `gnomeregan:batch-three:${name}`));
|
|
expect(groups.map((value) => value.spawns.length)).toEqual([10, 4, 5, 5, 2]);
|
|
expect(groups[2].spawns.map((spawn) => spawn.position)).toEqual(groups[3].spawns.map((spawn) => spawn.position));
|
|
const emi = definition.serverGameObjects.find((object) => object.id === "gnomeregan:batch-three:emi-shortfuse")!;
|
|
const caveOne = objectBySpawn(definition, 6_447);
|
|
const caveTwo = objectBySpawn(definition, 17_633);
|
|
const workshop = objectBySpawn(definition, 12_003);
|
|
const finalDoor = objectBySpawn(definition, 6_910);
|
|
let snapshot = createDungeonEncounterSnapshot(scripted);
|
|
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: workshop.id, now: 0 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: finalDoor.id, now: 1 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 2 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(groups[0].id);
|
|
snapshot = killGroup(scripted, snapshot, groups[0], 3);
|
|
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 4);
|
|
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
|
expect(snapshot.worldObjectStates[emi.id]).toBe("ready");
|
|
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("closed");
|
|
expect(snapshot.worldObjectStates[workshop.id]).toBe("open");
|
|
expect(snapshot.worldObjectStates[finalDoor.id]).toBe("open");
|
|
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 10 }).snapshot;
|
|
snapshot = killGroup(scripted, snapshot, groups[0], 11);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_011 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(groups[1].id);
|
|
snapshot = killGroup(scripted, snapshot, groups[1], 5_012);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 45_012 }).snapshot;
|
|
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("open");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(groups[2].id);
|
|
snapshot = killGroup(scripted, snapshot, groups[2], 45_013);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 48_013 }).snapshot;
|
|
snapshot = killGroup(scripted, snapshot, groups[3], 48_014);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 108_014 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(groups[4].id);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
|
kind: "entity-death",
|
|
entityId: "gnomeregan:batch-three:grubbis",
|
|
now: 108_015,
|
|
}).snapshot;
|
|
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
|
|
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
|
|
expect(snapshot.worldObjectStates[emi.id]).toBe("complete");
|
|
expect(snapshot.checkpointId).toBe("gnomeregan-grubbis");
|
|
|
|
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 108_016);
|
|
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
|
|
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
|
|
const fleet = definition.encounters?.find((value) => value.id === "gnomeregan:boss-progression")!;
|
|
const completion = fleet.rules.find((rule) => rule.id === "all-required-bosses-defeated")!;
|
|
expect(completion.guards).toContainEqual({
|
|
kind: "objective-complete",
|
|
objectiveId: "gnomeregan:batch-three:grubbis",
|
|
equals: true,
|
|
});
|
|
});
|
|
|
|
it("runs Dire Maul's pylons, Immol'thar recovery, east-wing wall, doors, and six tribute tiers", () => {
|
|
const definition = adapted("dire-maul");
|
|
const scripted = encounter(definition);
|
|
const pylons = [29_742, 29_743, 29_744, 29_745, 29_746].map((id) => objectBySpawn(definition, id));
|
|
const forcefield = objectBySpawn(definition, 29_747);
|
|
const courtyard = objectBySpawn(definition, 29_750);
|
|
const inner = objectBySpawn(definition, 29_756);
|
|
const conservatory = objectBySpawn(definition, 268_275);
|
|
const wall = objectBySpawn(definition, 268_276);
|
|
const ironbark = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:old-ironbark")!;
|
|
const tribute = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:gordok-tribute")!;
|
|
const immol = group(definition, "dire-maul:batch-three:immolthar");
|
|
const alzzin = group(definition, "dire-maul:batch-three:alzzin");
|
|
let snapshot = createDungeonEncounterSnapshot(scripted);
|
|
|
|
pylons.forEach((pylon, index) => {
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pylon.id, now: index }).snapshot;
|
|
});
|
|
expect(snapshot.counters["pylons-off"]).toBe(5);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 10 }).snapshot;
|
|
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
|
|
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 11);
|
|
expect(snapshot.activeSpawnGroupIds).not.toContain(immol.id);
|
|
expect(snapshot.counters["pylons-off"]).toBe(5);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 12 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: immol.spawns[0].id, now: 13 }).snapshot;
|
|
expect(snapshot.completedObjectiveIds).toContain(immol.spawns[0].id);
|
|
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248429", now: 20 }).snapshot;
|
|
expect(snapshot.worldObjectStates[ironbark.id]).toBe("ready");
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: ironbark.id, now: 21 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_021 }).snapshot;
|
|
expect(snapshot.worldObjectStates[conservatory.id]).toBe("open");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 51, now: 7_022 }).snapshot;
|
|
expect(snapshot.worldObjectStates[wall.id]).toBe("closed");
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 50, now: 7_023 }).snapshot;
|
|
expect(snapshot.worldObjectStates[wall.id]).toBe("open");
|
|
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 7_024);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_025 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: alzzin.spawns[0].id, now: 7_026 }).snapshot;
|
|
expect(snapshot.completedObjectiveIds).toContain("dire-maul:batch-three:alzzin");
|
|
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: courtyard.id, now: 8_000 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: inner.id, now: 8_001 }).snapshot;
|
|
expect(snapshot.worldObjectStates[courtyard.id]).toBe("open");
|
|
expect(snapshot.worldObjectStates[inner.id]).toBe("open");
|
|
for (const id of ["creature-248079", "creature-248080"]) {
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: id, now: snapshot.revision + 8_010 }).snapshot;
|
|
}
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248076", now: 9_000 }).snapshot;
|
|
expect(snapshot.counters["tribute-penalties"]).toBe(2);
|
|
expect(snapshot.worldObjectStates[tribute.id]).toBe("tier-4");
|
|
|
|
let perfect = createDungeonEncounterSnapshot(scripted);
|
|
perfect = reduceDungeonEncounter(scripted, perfect, { kind: "entity-death", entityId: "creature-248076", now: 1 }).snapshot;
|
|
expect(perfect.worldObjectStates[tribute.id]).toBe("tier-6");
|
|
const optionalIds = new Set(["creature-248077", "creature-248079", "creature-248080", "creature-248081", "creature-248082"]);
|
|
expect(definition.bosses.filter((boss) => optionalIds.has(boss.id)).every((boss) => boss.optional)).toBe(true);
|
|
const fleetCompletion = definition.encounters?.find((value) => value.id === "dire-maul:boss-progression")
|
|
?.rules.find((rule) => rule.id === "all-required-bosses-defeated");
|
|
expect(fleetCompletion?.guards?.some((guard) => guard.kind === "objective-complete" && optionalIds.has(guard.objectiveId)))
|
|
.toBe(false);
|
|
});
|
|
|
|
it("runs Blackrock Spire's runes, Emberseer reset, exact stadium, Drakkisath doors, and Urok retry", () => {
|
|
const definition = adapted("blackrock-spire");
|
|
const scripted = encounter(definition);
|
|
const hallRunes = [87_827, 87_829, 87_825, 87_830, 87_828, 87_826, 87_824].map((id) => objectBySpawn(definition, id));
|
|
const emberRunes = [87_831, 87_832, 87_833, 87_834, 87_835, 87_836, 87_837].map((id) => objectBySpawn(definition, id));
|
|
const emberIn = objectBySpawn(definition, 87_839);
|
|
const emberOut = objectBySpawn(definition, 87_841);
|
|
const emberDoors = objectBySpawn(definition, 87_842);
|
|
const emberTrigger = definition.serverGameObjects.find((object) => object.id === "blackrock-spire:batch-three:emberseer-trigger")!;
|
|
const incarcerators = group(definition, "blackrock-spire:batch-three:incarcerators");
|
|
const emberseer = group(definition, "blackrock-spire:batch-three:emberseer");
|
|
const runeMobs = [
|
|
["creature-137869", "creature-137871", "creature-137928", "creature-137930"],
|
|
["creature-137872", "creature-137896", "creature-137898"],
|
|
["creature-137870", "creature-137886", "creature-137929"],
|
|
["creature-137873", "creature-137892", "creature-137897", "creature-137903", "creature-137904", "creature-137905", "creature-137931", "creature-137934"],
|
|
["creature-137878", "creature-137911", "creature-137912", "creature-137939"],
|
|
["creature-137880", "creature-137881", "creature-137942", "creature-137943"],
|
|
["creature-137877", "creature-137908", "creature-137909", "creature-137910", "creature-137938"],
|
|
];
|
|
let snapshot = createDungeonEncounterSnapshot(scripted);
|
|
runeMobs.forEach((ids, index) => {
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
|
kind: "tick",
|
|
now: index + 1,
|
|
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
|
}).snapshot;
|
|
});
|
|
expect(snapshot.counters["dragonspire-runes"]).toBe(7);
|
|
expect(hallRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "inactive")).toBe(true);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 8 }).snapshot;
|
|
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
|
|
expect(snapshot.worldObjectStates[emberDoors.id]).toBe("open");
|
|
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
|
|
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 10 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_010 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(incarcerators.id);
|
|
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2_011);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_012 }).snapshot;
|
|
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
|
|
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
|
|
expect(snapshot.activeSpawnGroupIds).not.toContain(incarcerators.id);
|
|
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 3_000 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
|
|
snapshot = killGroup(scripted, snapshot, incarcerators, 5_001);
|
|
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "growing")).toBe(true);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 30_001 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(emberseer.id);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: emberseer.spawns[0].id, now: 30_002 }).snapshot;
|
|
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
|
|
expect(snapshot.worldObjectStates[emberOut.id]).toBe("open");
|
|
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "released")).toBe(true);
|
|
|
|
const stadiumEntry = objectBySpawn(definition, 87_845);
|
|
const stadiumWaveDoor = objectBySpawn(definition, 87_843);
|
|
const stadiumExit = objectBySpawn(definition, 87_844);
|
|
const waves = Array.from({ length: 6 }, (_, index) => group(definition, `blackrock-spire:batch-three:stadium-wave-${index + 1}`));
|
|
expect(waves.map((value) => value.spawns.length)).toEqual([4, 3, 4, 4, 5, 5]);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: stadiumEntry.id, now: 31_000 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 42_000 }).snapshot;
|
|
for (let index = 0; index < waves.length; index += 1) {
|
|
expect(snapshot.activeSpawnGroupIds).toContain(waves[index].id);
|
|
snapshot = killGroup(scripted, snapshot, waves[index], snapshot.phaseStartedAt + 1);
|
|
if (index < waves.length - 1) {
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 13_000 }).snapshot;
|
|
}
|
|
}
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 18_000 }).snapshot;
|
|
const gyth = group(definition, "blackrock-spire:batch-three:gyth");
|
|
const rend = group(definition, "blackrock-spire:batch-three:rend");
|
|
expect(snapshot.activeSpawnGroupIds).toContain(gyth.id);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 26, now: snapshot.phaseStartedAt + 1 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).not.toContain(rend.id);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 25, now: snapshot.phaseStartedAt + 2 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(rend.id);
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: gyth.spawns[0].id, now: snapshot.phaseStartedAt + 3 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: rend.spawns[0].id, now: snapshot.phaseStartedAt + 4 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 5 }).snapshot;
|
|
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
|
|
expect(snapshot.worldObjectStates[stadiumEntry.id]).toBe("complete");
|
|
expect(snapshot.worldObjectStates[stadiumWaveDoor.id]).toBe("open");
|
|
expect(snapshot.worldObjectStates[stadiumExit.id]).toBe("open");
|
|
|
|
const drakkDoors = [objectBySpawn(definition, 87_854), objectBySpawn(definition, 87_855)];
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-137971", now: snapshot.phaseStartedAt + 6 }).snapshot;
|
|
expect(drakkDoors.every((door) => snapshot.worldObjectStates[door.id] === "open")).toBe(true);
|
|
|
|
const pile = objectBySpawn(definition, 99_791);
|
|
const urok = group(definition, "blackrock-spire:batch-three:urok");
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 7 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(urok.id);
|
|
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", snapshot.phaseStartedAt + 8);
|
|
expect(snapshot.worldObjectStates[pile.id]).toBe("ready");
|
|
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
|
|
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 9 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: urok.spawns[0].id, now: snapshot.phaseStartedAt + 10 }).snapshot;
|
|
expect(snapshot.completedObjectiveIds).toContain("blackrock-spire:batch-three:urok-doomhowl");
|
|
expect(snapshot.worldObjectStates[pile.id]).toBe("complete");
|
|
});
|
|
});
|