forked from phenom/healer-man
Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -0,0 +1,673 @@
|
||||
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 {
|
||||
DungeonEncounterAction,
|
||||
DungeonEncounterDefinition,
|
||||
DungeonEncounterGuard,
|
||||
DungeonEncounterSnapshot,
|
||||
DungeonEncounterTrigger,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS,
|
||||
withWowWrathEncounterBatchTwoContent,
|
||||
} from "./wowWrathEncounterBatchTwoCatalog";
|
||||
|
||||
const TARGETS = [
|
||||
"halls-of-stone",
|
||||
"pit-of-saron",
|
||||
"utgarde-pinnacle",
|
||||
"gundrak",
|
||||
] as const;
|
||||
|
||||
type TargetId = typeof TARGETS[number];
|
||||
|
||||
const REVIEWED_TEMPLATES: Readonly<Record<TargetId, readonly number[]>> = Object.freeze({
|
||||
"halls-of-stone": [193_907, 193_906, 191_296],
|
||||
"pit-of-saron": [201_885, 201_848],
|
||||
"utgarde-pinnacle": [191_745, 188_593, 192_173, 192_174],
|
||||
gundrak: [
|
||||
192_518, 192_519, 192_520,
|
||||
192_564, 192_565, 192_567, 192_566,
|
||||
193_188, 192_633, 192_632, 192_569,
|
||||
192_568, 193_208, 193_209,
|
||||
],
|
||||
});
|
||||
|
||||
const EVENT_BOSS_NAMES: Readonly<Record<TargetId, readonly string[]>> = Object.freeze({
|
||||
"halls-of-stone": ["Tribunal of Ages"],
|
||||
"pit-of-saron": ["Tyrannus"],
|
||||
"utgarde-pinnacle": ["Svala", "Gortok", "Skadi"],
|
||||
gundrak: ["Eck"],
|
||||
});
|
||||
|
||||
const FINAL_BOSS_NAME: Readonly<Record<TargetId, string>> = Object.freeze({
|
||||
"halls-of-stone": "Sjonnir",
|
||||
"pit-of-saron": "Tyrannus",
|
||||
"utgarde-pinnacle": "Ymiron",
|
||||
gundrak: "Gal'darah",
|
||||
});
|
||||
|
||||
const EXPECTED_NONFINAL_CHECKPOINTS: Readonly<Record<TargetId, number>> = Object.freeze({
|
||||
"halls-of-stone": 3,
|
||||
"pit-of-saron": 3,
|
||||
"utgarde-pinnacle": 3,
|
||||
gundrak: 4,
|
||||
});
|
||||
|
||||
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: TargetId): DungeonDefinition {
|
||||
const source = generatedDefinitions.find((definition) => definition.id === id);
|
||||
if (!source) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return source;
|
||||
}
|
||||
|
||||
function adaptedDefinition(id: TargetId): DungeonDefinition {
|
||||
return withWowWrathEncounterBatchTwoContent(sourceDefinition(id));
|
||||
}
|
||||
|
||||
function adaptedEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const encounter = definition.encounters?.find((candidate) => (
|
||||
candidate.id === `${definition.id}:wrath-batch-two-instance`
|
||||
));
|
||||
if (!encounter) throw new Error(`Missing batch-two encounter for ${definition.id}`);
|
||||
return encounter;
|
||||
}
|
||||
|
||||
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 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)
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
function activeSpawnIds(
|
||||
definition: DungeonDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
ignoredGroupIds: ReadonlySet<string> = new Set(),
|
||||
): readonly string[] {
|
||||
const active = new Set(snapshot.activeSpawnGroupIds);
|
||||
return (definition.encounterSpawnGroups ?? [])
|
||||
.filter((spawnGroup) => active.has(spawnGroup.id) && !ignoredGroupIds.has(spawnGroup.id))
|
||||
.flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id));
|
||||
}
|
||||
|
||||
function killIds(
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
ids: readonly string[],
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
}).snapshot;
|
||||
}
|
||||
|
||||
function interact(
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
objectId: string,
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
return reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId, now }).snapshot;
|
||||
}
|
||||
|
||||
function incompleteObjective(
|
||||
definition: DungeonDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
names: readonly string[],
|
||||
): DungeonBossObjective | undefined {
|
||||
return names
|
||||
.map((name) => objective(definition, name))
|
||||
.find((candidate) => !snapshot.completedObjectiveIds.includes(candidate.id));
|
||||
}
|
||||
|
||||
function advanceOneStep(
|
||||
definition: DungeonDefinition,
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
if (snapshot.phase === "resume") {
|
||||
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
|
||||
if (definition.id === "halls-of-stone") {
|
||||
if (snapshot.phase === "tribunal-ready") {
|
||||
const remaining = incompleteObjective(definition, snapshot, ["Krystallus", "Maiden"]);
|
||||
return remaining
|
||||
? killIds(encounter, snapshot, [remaining.id], now)
|
||||
: interact(encounter, snapshot, "halls-of-stone:reviewed-tribunal-console", now);
|
||||
}
|
||||
if (snapshot.phase === "sjonnir-ready") {
|
||||
return killIds(encounter, snapshot, [objective(definition, "Sjonnir").id], now);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.id === "pit-of-saron") {
|
||||
if (snapshot.phase === "progression") {
|
||||
const remaining = incompleteObjective(definition, snapshot, ["Garfrost", "Ick"]);
|
||||
return remaining
|
||||
? killIds(encounter, snapshot, [remaining.id], now)
|
||||
: reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "gauntlet-ready") {
|
||||
return interact(encounter, snapshot, "pit-of-saron:gauntlet-start", now);
|
||||
}
|
||||
if (snapshot.phase === "tunnel-ready") {
|
||||
return interact(encounter, snapshot, "pit-of-saron:tunnel-start", now);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.id === "utgarde-pinnacle") {
|
||||
if (snapshot.phase === "svala-ready") {
|
||||
return interact(encounter, snapshot, "utgarde-pinnacle:svala-start", now);
|
||||
}
|
||||
if (snapshot.phase === "svala-phase-one-active") {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: "utgarde-pinnacle:svala-phase-one-actor",
|
||||
healthPercent: 50,
|
||||
now,
|
||||
}).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "gortok-ready") {
|
||||
return interact(encounter, snapshot, "utgarde-pinnacle:reviewed-stasis-sphere", now);
|
||||
}
|
||||
if (snapshot.phase === "skadi-ready") {
|
||||
return interact(encounter, snapshot, "utgarde-pinnacle:skadi-start", now);
|
||||
}
|
||||
const harpoonByPhase: Readonly<Record<string, string>> = {
|
||||
"harpoon-one-ready": "utgarde-pinnacle:harpoon-1",
|
||||
"harpoon-two-ready": "utgarde-pinnacle:harpoon-2",
|
||||
"harpoon-three-ready": "utgarde-pinnacle:harpoon-3",
|
||||
};
|
||||
const harpoon = harpoonByPhase[snapshot.phase];
|
||||
if (harpoon) return interact(encounter, snapshot, harpoon, now);
|
||||
if (snapshot.phase === "ymiron-ready") {
|
||||
return killIds(encounter, snapshot, [objective(definition, "Ymiron").id], now);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.id === "gundrak") {
|
||||
if (snapshot.phase === "altar-progression") {
|
||||
const remaining = incompleteObjective(
|
||||
definition,
|
||||
snapshot,
|
||||
["Slad'ran", "Moorabi", "Drakkari Colossus"],
|
||||
);
|
||||
if (remaining) return killIds(encounter, snapshot, [remaining.id], now);
|
||||
const altar = definition.serverGameObjects.find((object) => (
|
||||
object.id.startsWith("gundrak:reviewed-altar-")
|
||||
&& snapshot.worldObjectStates[object.id] === "ready"
|
||||
));
|
||||
return altar
|
||||
? interact(encounter, snapshot, altar.id, now)
|
||||
: reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "bridge-activating") {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now: Math.max(now, snapshot.phaseStartedAt + 5_000),
|
||||
}).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "gal-darah-ready") {
|
||||
return killIds(encounter, snapshot, [objective(definition, "Gal'darah").id], now);
|
||||
}
|
||||
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
|
||||
const spawnIds = activeSpawnIds(definition, snapshot);
|
||||
if (spawnIds.length) return killIds(encounter, snapshot, spawnIds, now);
|
||||
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
|
||||
interface FinishedEncounter {
|
||||
readonly snapshot: DungeonEncounterSnapshot;
|
||||
readonly phases: readonly string[];
|
||||
readonly checkpoints: readonly DungeonEncounterSnapshot[];
|
||||
}
|
||||
|
||||
function finishEncounter(
|
||||
definition: DungeonDefinition,
|
||||
encounter: DungeonEncounterDefinition,
|
||||
initial = createDungeonEncounterSnapshot(encounter),
|
||||
): FinishedEncounter {
|
||||
let snapshot = initial;
|
||||
const phases = [snapshot.phase];
|
||||
const checkpoints: DungeonEncounterSnapshot[] = [];
|
||||
const seenCheckpoints = new Set<string>();
|
||||
const finalObjectiveId = objective(definition, FINAL_BOSS_NAME[definition.id as TargetId]).id;
|
||||
let now = snapshot.phaseStartedAt + 1;
|
||||
for (let step = 0; step < 100 && snapshot.phase !== "complete"; step += 1) {
|
||||
const previous = snapshot;
|
||||
snapshot = advanceOneStep(definition, encounter, snapshot, now);
|
||||
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
|
||||
if (snapshot.phase !== phases.at(-1)) phases.push(snapshot.phase);
|
||||
if (snapshot.checkpointId
|
||||
&& snapshot.checkpointId !== finalObjectiveId
|
||||
&& !seenCheckpoints.has(snapshot.checkpointId)) {
|
||||
seenCheckpoints.add(snapshot.checkpointId);
|
||||
checkpoints.push(snapshot);
|
||||
}
|
||||
if (snapshot === previous) throw new Error(`${definition.id} soft locked in ${snapshot.phase}`);
|
||||
}
|
||||
if (snapshot.phase !== "complete") throw new Error(`${definition.id} did not complete`);
|
||||
return { snapshot, phases, checkpoints };
|
||||
}
|
||||
|
||||
function expectOrderedPhases(phases: readonly string[], expected: readonly string[]): void {
|
||||
let cursor = -1;
|
||||
for (const phase of expected) {
|
||||
cursor = phases.indexOf(phase, cursor + 1);
|
||||
expect(cursor, `missing ordered phase ${phase}`).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
function expectTriggerReferences(
|
||||
trigger: DungeonEncounterTrigger,
|
||||
spawnIds: ReadonlySet<string>,
|
||||
objectIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (trigger.kind === "entity-death" || trigger.kind === "entity-engaged" || trigger.kind === "entity-health") {
|
||||
expect(spawnIds, `trigger entity ${trigger.entityId}`).toContain(trigger.entityId);
|
||||
}
|
||||
if (trigger.kind === "entities-dead") {
|
||||
for (const entityId of trigger.entityIds) expect(spawnIds, `trigger entity ${entityId}`).toContain(entityId);
|
||||
}
|
||||
if (trigger.kind === "interact") expect(objectIds, `trigger object ${trigger.objectId}`).toContain(trigger.objectId);
|
||||
}
|
||||
|
||||
function expectGuardReferences(
|
||||
guard: DungeonEncounterGuard,
|
||||
groupIds: ReadonlySet<string>,
|
||||
objectIds: ReadonlySet<string>,
|
||||
objectiveIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (guard.kind === "spawn-group-active") expect(groupIds).toContain(guard.spawnGroupId);
|
||||
if (guard.kind === "world-object-state") expect(objectIds).toContain(guard.objectId);
|
||||
if (guard.kind === "objective-complete") expect(objectiveIds).toContain(guard.objectiveId);
|
||||
}
|
||||
|
||||
function expectActionReferences(
|
||||
action: DungeonEncounterAction,
|
||||
groupIds: ReadonlySet<string>,
|
||||
objectsById: ReadonlyMap<string, DungeonDefinition["serverGameObjects"][number]>,
|
||||
objectiveIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groupIds, `action group ${action.spawnGroupId}`).toContain(action.spawnGroupId);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
const object = objectsById.get(action.objectId);
|
||||
expect(object, `action object ${action.objectId}`).toBeDefined();
|
||||
expect(object?.states.map((state) => state.id), `${action.objectId}:${action.state}`)
|
||||
.toContain(action.state);
|
||||
}
|
||||
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
|
||||
}
|
||||
|
||||
describe("source-reviewed Wrath encounter batch two", () => {
|
||||
it("pins every reviewed AzerothCore source file by actual SHA-256 and line/symbol anchors", async () => {
|
||||
expect(WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
|
||||
for (const review of WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const source of review.sourceFiles) {
|
||||
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(source.anchors.length).toBeGreaterThan(0);
|
||||
expect(source.anchors.every((anchor) => /lines? \d+/i.test(anchor))).toBe(true);
|
||||
const digest = createHash("sha256").update(await readFile(source.path)).digest("hex");
|
||||
expect(digest, source.path).toBe(source.sha256);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches idempotently with unique rows, valid references, and finite event groups", () => {
|
||||
for (const id of TARGETS) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const encounter = adaptedEncounter(definition);
|
||||
expect(withWowWrathEncounterBatchTwoContent(definition)).toBe(definition);
|
||||
expect(encounter.source).toMatchObject({
|
||||
fidelity: "source-backed-adapted",
|
||||
scope: "instance-orchestration",
|
||||
});
|
||||
validateDungeonEncounterDefinition(encounter);
|
||||
|
||||
const spawnIdList = runtimeSpawnIds(definition);
|
||||
expect(new Set(spawnIdList).size, `${id} duplicate runtime spawn id`).toBe(spawnIdList.length);
|
||||
const groupIdList = (definition.encounterSpawnGroups ?? []).map((spawnGroup) => spawnGroup.id);
|
||||
expect(new Set(groupIdList).size, `${id} duplicate group id`).toBe(groupIdList.length);
|
||||
const objectIdList = definition.serverGameObjects.map((object) => object.id);
|
||||
expect(new Set(objectIdList).size, `${id} duplicate object id`).toBe(objectIdList.length);
|
||||
const encounterIdList = (definition.encounters ?? []).map((candidate) => candidate.id);
|
||||
expect(new Set(encounterIdList).size, `${id} duplicate encounter id`).toBe(encounterIdList.length);
|
||||
const sourceSpawnIds = definition.serverGameObjects
|
||||
.map((object) => object.source?.spawnId)
|
||||
.filter((spawnId): spawnId is number => spawnId !== undefined);
|
||||
expect(new Set(sourceSpawnIds).size, `${id} duplicate source gameobject row`).toBe(sourceSpawnIds.length);
|
||||
|
||||
const spawnIds = new Set(spawnIdList);
|
||||
const groupIds = new Set(groupIdList);
|
||||
const objectIds = new Set(objectIdList);
|
||||
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
|
||||
const objectiveIds = new Set(definition.bosses.map((boss) => boss.id));
|
||||
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of spawnGroup.spawns) {
|
||||
expect(definition.entities[spawn.entityId], `${spawnGroup.id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
|
||||
for (const rule of encounter.rules) {
|
||||
expectTriggerReferences(rule.trigger, spawnIds, objectIds);
|
||||
for (const guard of rule.guards ?? []) {
|
||||
expectGuardReferences(guard, groupIds, objectIds, objectiveIds);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
expectActionReferences(action, groupIds, objectsById, objectiveIds);
|
||||
}
|
||||
}
|
||||
|
||||
const batchGroups = (definition.encounterSpawnGroups ?? [])
|
||||
.filter((spawnGroup) => spawnGroup.id.startsWith(`${id}:`));
|
||||
for (const spawnGroup of batchGroups) {
|
||||
const completion = encounter.rules.find((rule) => (
|
||||
rule.trigger.kind === "entities-dead"
|
||||
&& rule.trigger.entityIds.length === spawnGroup.spawns.length
|
||||
&& rule.trigger.entityIds.every((spawnId) => spawnGroup.spawns.some((spawn) => spawn.id === spawnId))
|
||||
));
|
||||
const isHealthGatedSvala = spawnGroup.id === "utgarde-pinnacle:svala-phase-one";
|
||||
expect(Boolean(completion) || isHealthGatedSvala, `${id} finite completion for ${spawnGroup.id}`).toBe(true);
|
||||
if (completion?.trigger.kind === "entities-dead") {
|
||||
expect(completion.trigger.timeoutMs, `${id} timeout for ${spawnGroup.id}`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces each reviewed imported object by spawnId without dropping or duplicating source rows", () => {
|
||||
for (const id of TARGETS) {
|
||||
const source = sourceDefinition(id);
|
||||
const definition = adaptedDefinition(id);
|
||||
const sourceRowIds = source.serverGameObjects
|
||||
.map((object) => object.source?.spawnId)
|
||||
.filter((spawnId): spawnId is number => spawnId !== undefined)
|
||||
.sort((left, right) => left - right);
|
||||
const adaptedRowIds = definition.serverGameObjects
|
||||
.map((object) => object.source?.spawnId)
|
||||
.filter((spawnId): spawnId is number => spawnId !== undefined)
|
||||
.sort((left, right) => left - right);
|
||||
expect(adaptedRowIds, `${id} source-row inventory`).toEqual(sourceRowIds);
|
||||
|
||||
for (const templateId of REVIEWED_TEMPLATES[id]) {
|
||||
const originalRows = source.serverGameObjects.filter((object) => object.source?.templateId === templateId);
|
||||
expect(originalRows, `${id} template ${templateId}`).toHaveLength(1);
|
||||
const original = originalRows[0];
|
||||
expect(original.source?.spawnId).toEqual(expect.any(Number));
|
||||
const replacements = definition.serverGameObjects.filter((object) => (
|
||||
object.source?.spawnId === original.source?.spawnId
|
||||
));
|
||||
expect(replacements, `${id} spawn ${original.source?.spawnId}`).toHaveLength(1);
|
||||
const replacement = replacements[0];
|
||||
expect(replacement.id).not.toBe(original.id);
|
||||
expect(replacement.id).toContain(":reviewed-");
|
||||
expect(replacement.position).toEqual(original.position);
|
||||
expect(replacement.halfExtents).toEqual(original.halfExtents);
|
||||
expect(replacement.yaw).toEqual(original.yaw);
|
||||
expect(replacement.visual?.hidden).not.toBe(true);
|
||||
expect(replacement.source?.fidelity).toBe("source-backed-adapted");
|
||||
}
|
||||
}
|
||||
|
||||
expect(adaptedDefinition("halls-of-stone").serverGameObjects
|
||||
.find((object) => object.id === "halls-of-stone:reviewed-tribunal-console")?.interaction)
|
||||
.toBeDefined();
|
||||
expect(adaptedDefinition("utgarde-pinnacle").serverGameObjects
|
||||
.find((object) => object.id === "utgarde-pinnacle:reviewed-stasis-sphere")?.interaction)
|
||||
.toBeDefined();
|
||||
expect(adaptedDefinition("gundrak").serverGameObjects
|
||||
.filter((object) => object.id.startsWith("gundrak:reviewed-altar-")).every((object) => object.interaction))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses only source-gated event objectives and preserves exact finite cardinalities", () => {
|
||||
for (const id of TARGETS) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const staticIds = new Set(definition.staticSpawns.map((spawn) => spawn.id));
|
||||
for (const name of EVENT_BOSS_NAMES[id]) {
|
||||
const eventBoss = objective(definition, name);
|
||||
expect(eventBoss.eventOnly, `${id}:${eventBoss.name}`).toBe(true);
|
||||
expect(staticIds, `${id}:${eventBoss.name}`).not.toContain(eventBoss.id);
|
||||
}
|
||||
}
|
||||
|
||||
const halls = adaptedDefinition("halls-of-stone");
|
||||
expect([
|
||||
group(halls, "halls-of-stone:tribunal-protectors").spawns.length,
|
||||
group(halls, "halls-of-stone:tribunal-stormcallers").spawns.length,
|
||||
group(halls, "halls-of-stone:tribunal-custodian").spawns.length,
|
||||
]).toEqual([3, 2, 1]);
|
||||
expect(incompleteObjective(halls, createDungeonEncounterSnapshot(adaptedEncounter(halls)), ["Krystallus", "Maiden"]))
|
||||
.toBeDefined();
|
||||
|
||||
const pit = adaptedDefinition("pit-of-saron");
|
||||
expect(group(pit, "pit-of-saron:first-ambush").spawns).toHaveLength(10);
|
||||
expect(group(pit, "pit-of-saron:second-ambush-normal").spawns).toHaveLength(6);
|
||||
expect(group(pit, "pit-of-saron:tyrannus").spawns).toHaveLength(1);
|
||||
expect(pit.staticSpawns.map((spawn) => spawn.id)).toEqual(expect.arrayContaining([
|
||||
objective(pit, "Garfrost").id,
|
||||
objective(pit, "Ick").id,
|
||||
]));
|
||||
|
||||
const pinnacle = adaptedDefinition("utgarde-pinnacle");
|
||||
expect(group(pinnacle, "utgarde-pinnacle:svala-ritual-channelers").spawns).toHaveLength(3);
|
||||
expect(group(pinnacle, "utgarde-pinnacle:skadi-first-wave").spawns).toHaveLength(13);
|
||||
expect(group(pinnacle, "utgarde-pinnacle:skadi-reinforcement-one").spawns).toHaveLength(3);
|
||||
expect(group(pinnacle, "utgarde-pinnacle:skadi-reinforcement-two").spawns).toHaveLength(3);
|
||||
expect(pinnacle.staticSpawns.map((spawn) => spawn.id)).toContain(objective(pinnacle, "Ymiron").id);
|
||||
|
||||
const gundrak = adaptedDefinition("gundrak");
|
||||
expect(group(gundrak, "gundrak:eck").spawns).toHaveLength(1);
|
||||
expect(group(gundrak, "gundrak:eck").spawns[0].spawnMask).toBe(2);
|
||||
expect(objective(gundrak, "Eck").optional).toBe(true);
|
||||
expect(gundrak.staticSpawns.map((spawn) => spawn.id)).toEqual(expect.arrayContaining([
|
||||
objective(gundrak, "Slad'ran").id,
|
||||
objective(gundrak, "Moorabi").id,
|
||||
objective(gundrak, "Drakkari Colossus").id,
|
||||
objective(gundrak, "Gal'darah").id,
|
||||
]));
|
||||
});
|
||||
|
||||
it("runs every required lifecycle through its reviewed gates and completes without renderer state", () => {
|
||||
const halls = adaptedDefinition("halls-of-stone");
|
||||
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
|
||||
expectOrderedPhases(hallsRun.phases, [
|
||||
"tribunal-protectors-active",
|
||||
"tribunal-stormcallers-active",
|
||||
"tribunal-custodian-active",
|
||||
"sjonnir-ready",
|
||||
"complete",
|
||||
]);
|
||||
expect(hallsRun.snapshot.worldObjectStates["halls-of-stone:reviewed-sjonnir-door"]).toBe("open");
|
||||
|
||||
const pit = adaptedDefinition("pit-of-saron");
|
||||
const pitRun = finishEncounter(pit, adaptedEncounter(pit));
|
||||
expectOrderedPhases(pitRun.phases, [
|
||||
"gauntlet-ready",
|
||||
"first-ambush-active",
|
||||
"second-ambush-active",
|
||||
"tunnel-ready",
|
||||
"tyrannus-active",
|
||||
"complete",
|
||||
]);
|
||||
expect(pitRun.snapshot.flags["gauntlet-complete"]).toBe(true);
|
||||
expect(pitRun.snapshot.worldObjectStates["pit-of-saron:reviewed-portcullis"]).toBe("open");
|
||||
|
||||
const pinnacle = adaptedDefinition("utgarde-pinnacle");
|
||||
const pinnacleRun = finishEncounter(pinnacle, adaptedEncounter(pinnacle));
|
||||
expectOrderedPhases(pinnacleRun.phases, [
|
||||
"svala-phase-one-active",
|
||||
"svala-ritual-active",
|
||||
"svala-phase-two-active",
|
||||
"gortok-ready",
|
||||
"stasis-worgen-active",
|
||||
"stasis-furbolg-active",
|
||||
"gortok-active",
|
||||
"skadi-ready",
|
||||
"skadi-first-wave-active",
|
||||
"harpoon-one-ready",
|
||||
"skadi-reinforcement-one-active",
|
||||
"harpoon-two-ready",
|
||||
"skadi-reinforcement-two-active",
|
||||
"harpoon-three-ready",
|
||||
"skadi-ground-active",
|
||||
"ymiron-ready",
|
||||
"complete",
|
||||
]);
|
||||
expect(pinnacleRun.snapshot.counters["harpoon-hits"]).toBe(3);
|
||||
expect(pinnacleRun.snapshot.worldObjectStates["utgarde-pinnacle:reviewed-ymiron-door"]).toBe("open");
|
||||
|
||||
const gundrak = adaptedDefinition("gundrak");
|
||||
const gundrakRun = finishEncounter(gundrak, adaptedEncounter(gundrak));
|
||||
expectOrderedPhases(gundrakRun.phases, [
|
||||
"altar-progression",
|
||||
"bridge-activating",
|
||||
"gal-darah-ready",
|
||||
"complete",
|
||||
]);
|
||||
expect(gundrakRun.snapshot.counters["keys-in"]).toBe(3);
|
||||
expect(gundrakRun.snapshot.flags["bridge-open"]).toBe(true);
|
||||
expect(gundrakRun.snapshot.worldObjectStates["gundrak:reviewed-bridge"]).toBe("open");
|
||||
expect(gundrakRun.snapshot.completedObjectiveIds).not.toContain(objective(gundrak, "Eck").id);
|
||||
expect(gundrakRun.snapshot.activeSpawnGroupIds).toContain("gundrak:eck");
|
||||
|
||||
for (const [definition, result] of [
|
||||
[halls, hallsRun],
|
||||
[pit, pitRun],
|
||||
[pinnacle, pinnacleRun],
|
||||
[gundrak, gundrakRun],
|
||||
] as const) {
|
||||
expect(result.snapshot.flags["dungeon-complete"], definition.id).toBe(true);
|
||||
expect(result.snapshot.completedObjectiveIds, definition.id).toEqual(expect.arrayContaining(
|
||||
definition.bosses.filter((boss) => !boss.optional).map((boss) => boss.id),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers from both wipe scopes at every nonfinal checkpoint, including optional Eck", () => {
|
||||
for (const id of TARGETS) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const encounter = adaptedEncounter(definition);
|
||||
const run = finishEncounter(definition, encounter);
|
||||
expect(run.checkpoints, id).toHaveLength(EXPECTED_NONFINAL_CHECKPOINTS[id]);
|
||||
for (const checkpoint of run.checkpoints) {
|
||||
for (const scope of ["boss-wipe", "checkpoint"] as const) {
|
||||
const reset = resetDungeonEncounterSnapshot(
|
||||
encounter,
|
||||
checkpoint,
|
||||
scope,
|
||||
checkpoint.phaseStartedAt + 10_000,
|
||||
);
|
||||
expect(reset.phase, `${id}:${checkpoint.checkpointId}:${scope}`).toBe("resume");
|
||||
expect(reset.activeSpawnGroupIds).toEqual([]);
|
||||
expect(reset.completedObjectiveIds).toEqual(checkpoint.completedObjectiveIds);
|
||||
expect(reset.checkpointId).toBe(checkpoint.checkpointId);
|
||||
expect(finishEncounter(definition, encounter, reset).snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gundrak = adaptedDefinition("gundrak");
|
||||
const encounter = adaptedEncounter(gundrak);
|
||||
let eckCheckpoint = killIds(
|
||||
encounter,
|
||||
createDungeonEncounterSnapshot(encounter),
|
||||
[objective(gundrak, "Moorabi").id],
|
||||
1,
|
||||
);
|
||||
eckCheckpoint = killIds(
|
||||
encounter,
|
||||
eckCheckpoint,
|
||||
group(gundrak, "gundrak:eck").spawns.map((spawn) => spawn.id),
|
||||
2,
|
||||
);
|
||||
expect(eckCheckpoint.checkpointId).toBe(objective(gundrak, "Eck").id);
|
||||
for (const scope of ["boss-wipe", "checkpoint"] as const) {
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, eckCheckpoint, scope, 3);
|
||||
expect(reset.completedObjectiveIds).toContain(objective(gundrak, "Eck").id);
|
||||
expect(finishEncounter(gundrak, encounter, reset).snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps completion order-safe when a static final boss is reported dead before its event gate", () => {
|
||||
for (const id of ["halls-of-stone", "utgarde-pinnacle", "gundrak"] as const) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const encounter = adaptedEncounter(definition);
|
||||
const finalBoss = objective(definition, FINAL_BOSS_NAME[id]);
|
||||
const premature = killIds(encounter, createDungeonEncounterSnapshot(encounter), [finalBoss.id], 1);
|
||||
expect(premature.flags["dungeon-complete"], id).toBe(false);
|
||||
const completed = finishEncounter(definition, encounter, premature).snapshot;
|
||||
expect(completed.flags["dungeon-complete"], id).toBe(true);
|
||||
if (id === "gundrak") expect(completed.flags["bridge-open"]).toBe(true);
|
||||
}
|
||||
|
||||
const pit = adaptedDefinition("pit-of-saron");
|
||||
const pitEncounter = adaptedEncounter(pit);
|
||||
const tyrannusGroup = group(pit, "pit-of-saron:tyrannus");
|
||||
const prematureTyrannus = killIds(
|
||||
pitEncounter,
|
||||
createDungeonEncounterSnapshot(pitEncounter),
|
||||
tyrannusGroup.spawns.map((spawn) => spawn.id),
|
||||
1,
|
||||
);
|
||||
expect(prematureTyrannus.completedObjectiveIds).not.toContain(objective(pit, "Tyrannus").id);
|
||||
expect(prematureTyrannus.phase).toBe("progression");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user