forked from phenom/healer-man
341 lines
15 KiB
TypeScript
341 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createDungeonEncounterSnapshot,
|
|
reduceDungeonEncounter,
|
|
resetDungeonEncounterSnapshot,
|
|
validateDungeonEncounterDefinition,
|
|
} from "./dungeonEncounterRuntime";
|
|
import type {
|
|
DungeonEncounterAction,
|
|
DungeonEncounterGuard,
|
|
DungeonEncounterTrigger,
|
|
} from "./dungeonEncounterTypes";
|
|
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
|
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
|
import type { DungeonDefinition } from "./dungeonTypes";
|
|
import {
|
|
ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE,
|
|
withAscensionCustomEncounterContent,
|
|
} from "./ascensionCustomEncounterCatalog";
|
|
|
|
const CUSTOM_DUNGEON_IDS = [
|
|
"frozen-reach",
|
|
"forgotten-mine",
|
|
"tinkertech-showdown",
|
|
] as const;
|
|
|
|
function adaptedDungeon(id: typeof CUSTOM_DUNGEON_IDS[number]): DungeonDefinition {
|
|
const source = DUNGEON_REGISTRY[id];
|
|
expect(source, id).toBeDefined();
|
|
return withAscensionCustomEncounterContent(source);
|
|
}
|
|
|
|
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((group) => group.spawns.map((spawn) => spawn.id)),
|
|
];
|
|
}
|
|
|
|
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), `object state ${action.objectId}:${action.state}`)
|
|
.toContain(action.state);
|
|
}
|
|
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
|
|
}
|
|
|
|
describe("Ascension custom encounter content", () => {
|
|
it("attaches idempotently with no dangling or duplicate static/runtime references", () => {
|
|
expect(ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.map((entry) => entry.dungeonId).sort())
|
|
.toEqual([...CUSTOM_DUNGEON_IDS].sort());
|
|
|
|
for (const id of CUSTOM_DUNGEON_IDS) {
|
|
const definition = adaptedDungeon(id);
|
|
expect(withAscensionCustomEncounterContent(definition)).toBe(definition);
|
|
const spawnIdList = runtimeSpawnIds(definition);
|
|
const spawnIds = new Set(spawnIdList);
|
|
expect(spawnIds.size, `${id} duplicate runtime spawn ids`).toBe(spawnIdList.length);
|
|
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((group) => group.id));
|
|
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
|
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
|
|
const objectiveIds = new Set([
|
|
...definition.bosses.map((objective) => objective.id),
|
|
...definition.objectiveOrder.map((objective) => objective.id),
|
|
]);
|
|
|
|
for (const spawn of definition.staticSpawns) {
|
|
expect(definition.entities[spawn.entityId], `${id} static ${spawn.id}`).toBeDefined();
|
|
}
|
|
for (const group of definition.encounterSpawnGroups ?? []) {
|
|
for (const spawn of group.spawns) {
|
|
expect(definition.entities[spawn.entityId], `${id} group ${group.id}:${spawn.id}`).toBeDefined();
|
|
}
|
|
}
|
|
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
|
|
for (const encounter of definition.encounters ?? []) {
|
|
validateDungeonEncounterDefinition(encounter);
|
|
expect(encounter.dungeonId).toBe(id);
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("removes Frozen Reach placeholder trash and drives the Arktos-to-vengeance lifecycle", () => {
|
|
const definition = adaptedDungeon("frozen-reach");
|
|
expect(definition.staticSpawns.map((spawn) => spawn.id)).toEqual(["encounter-2168"]);
|
|
expect(definition.entities["frozen-reach-guardian"]).toBeUndefined();
|
|
expect(definition.entities["frozen-reach-caster"]).toBeUndefined();
|
|
expect(definition.entities["frozen-reach-brute"]).toBeUndefined();
|
|
expect(definition.entities["entry-97700"]).toMatchObject({
|
|
name: "Alva",
|
|
combat: { serverEntry: 97_700 },
|
|
});
|
|
expect(definition.entities["frozen-reach:arktos"]).toMatchObject({
|
|
name: "Arktos",
|
|
combat: { serverEntry: 97_701 },
|
|
});
|
|
const alvaSpellIds = definition.entities["entry-97700"].combat?.attacks
|
|
.flatMap((entry) => entry.spellId ?? []);
|
|
expect(alvaSpellIds).toEqual(expect.arrayContaining([
|
|
255_347,
|
|
255_349,
|
|
255_352,
|
|
255_358,
|
|
255_363,
|
|
255_394,
|
|
255_397,
|
|
255_410,
|
|
255_419,
|
|
255_421,
|
|
]));
|
|
|
|
const encounter = definition.encounters?.find((entry) => entry.id === "frozen-reach:alva-and-arktos");
|
|
expect(encounter?.source?.fidelity).toBe("source-backed-adapted");
|
|
const arktosGroup = definition.encounterSpawnGroups
|
|
?.find((group) => group.id === "frozen-reach:arktos-attempt");
|
|
expect(arktosGroup?.spawns).toMatchObject([{
|
|
id: "frozen-reach:arktos",
|
|
entityId: "frozen-reach:arktos",
|
|
}]);
|
|
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "tick",
|
|
now: 1,
|
|
}).snapshot;
|
|
expect(snapshot).toMatchObject({
|
|
phase: "duo-active",
|
|
activeSpawnGroupIds: ["frozen-reach:arktos-attempt"],
|
|
});
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "entity-death",
|
|
entityId: "frozen-reach:arktos",
|
|
now: 1_000,
|
|
}).snapshot;
|
|
expect(snapshot).toMatchObject({
|
|
phase: "vengeance",
|
|
flags: { "arktos-defeated": true },
|
|
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
|
|
});
|
|
const addIds = definition.encounterSpawnGroups
|
|
?.find((group) => group.id === "frozen-reach:rebirth-alvajar")
|
|
?.spawns.map((spawn) => spawn.id) ?? [];
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "tick",
|
|
now: 2_000,
|
|
facts: { registeredEntityIds: addIds, deadEntityIds: addIds },
|
|
}).snapshot;
|
|
expect(snapshot.phase).toBe("vengeance");
|
|
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
|
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
|
|
expect(reset).toMatchObject({
|
|
phase: "duo-active",
|
|
flags: { "arktos-defeated": false },
|
|
activeSpawnGroupIds: [],
|
|
});
|
|
|
|
snapshot = reduceDungeonEncounter(encounter!, reset, {
|
|
kind: "tick",
|
|
now: 3_001,
|
|
}).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual(["frozen-reach:arktos-attempt"]);
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "entity-death",
|
|
entityId: "frozen-reach:arktos",
|
|
now: 4_000,
|
|
}).snapshot;
|
|
expect(snapshot).toMatchObject({
|
|
phase: "vengeance",
|
|
flags: { "arktos-defeated": true },
|
|
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
|
|
});
|
|
});
|
|
|
|
it("runs Vek's Geospike, Resonance, and repeatable Resonite shard lifecycle", () => {
|
|
const definition = adaptedDungeon("forgotten-mine");
|
|
expect(definition.entities["entry-254272"]).toMatchObject({
|
|
name: "Geolord Vek",
|
|
combat: { serverEntry: 254_272 },
|
|
});
|
|
expect(definition.entities["forgotten-mine-guardian"].name).toBe("Forgotten Mine Stoneguard");
|
|
expect(definition.entities["forgotten-mine-caster"].name).toBe("Forgotten Mine Geomancer");
|
|
expect(definition.entities["forgotten-mine-brute"].name).toBe("Forgotten Mine Quarry Brute");
|
|
expect(definition.entities["entry-254272"].combat?.attacks.flatMap((entry) => entry.spellId ?? []))
|
|
.toEqual(expect.arrayContaining([354_000, 354_001, 354_002, 354_004, 354_013, 354_015, 354_017]));
|
|
|
|
const shards = definition.serverGameObjects.filter((object) => object.source?.templateId === 254_051);
|
|
expect(shards).toHaveLength(3);
|
|
for (const shard of shards) {
|
|
expect(shard).toMatchObject({
|
|
kind: "resource",
|
|
halfExtents: [2.25, 2.4, 2.25],
|
|
interaction: { range: 4.5, allowedStates: ["ready"] },
|
|
source: { fidelity: "source-backed-adapted", templateId: 254_051 },
|
|
});
|
|
}
|
|
|
|
const encounter = definition.encounters?.find((entry) => entry.id === "forgotten-mine:geolord-vek");
|
|
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "signal",
|
|
signalId: "forgotten-mine:geospike-cast",
|
|
now: 100,
|
|
}).snapshot;
|
|
expect(snapshot.phase).toBe("pre-resonance-geospike");
|
|
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_599 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_600 }).snapshot;
|
|
expect(snapshot.phase).toBe("boss-active");
|
|
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
|
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "entity-health",
|
|
entityId: "encounter-5069",
|
|
healthPercent: 50,
|
|
now: 4_000,
|
|
}).snapshot;
|
|
expect(snapshot.phase).toBe("resonance-active");
|
|
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "interact",
|
|
objectId: "forgotten-mine:resonite-shard-1",
|
|
now: 5_000,
|
|
}).snapshot;
|
|
expect(snapshot).toMatchObject({
|
|
phase: "resonance-cleansed",
|
|
counters: { "resonite-cleanses": 1 },
|
|
worldObjectStates: { "forgotten-mine:resonite-shard-1": "consumed" },
|
|
});
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 12_999 }).snapshot;
|
|
expect(snapshot.phase).toBe("resonance-cleansed");
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 13_000 }).snapshot;
|
|
expect(snapshot.phase).toBe("resonance-recharge");
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 35_000 }).snapshot;
|
|
expect(snapshot.phase).toBe("resonance-active");
|
|
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "interact",
|
|
objectId: "forgotten-mine:resonite-shard-1",
|
|
now: 36_000,
|
|
}).snapshot;
|
|
expect(snapshot.counters["resonite-cleanses"]).toBe(2);
|
|
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 37_000);
|
|
expect(reset.phase).toBe("boss-active");
|
|
expect(Object.values(reset.worldObjectStates)).toEqual(["dormant", "dormant", "dormant"]);
|
|
expect(reset.counters["resonite-cleanses"]).toBe(0);
|
|
});
|
|
|
|
it("keeps Tinkertech explicitly authored and switches Mechatron to faster overdrive with reinforcements", () => {
|
|
const definition = adaptedDungeon("tinkertech-showdown");
|
|
expect(definition.entities["entry-663012"]).toMatchObject({ name: "Mechatron", combat: { serverEntry: 663_012 } });
|
|
expect(definition.entities["tinkertech-showdown-guardian"].name).toBe("Tinkertech Guardbot");
|
|
expect(definition.entities["tinkertech-showdown-caster"].name).toBe("Tinkertech Coilcaster");
|
|
expect(definition.entities["tinkertech-showdown-brute"].name).toBe("Tinkertech Demolisher");
|
|
const review = ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.find((entry) => entry.dungeonId === definition.id)!;
|
|
expect(review.fidelity).toBe("healer-man-authored");
|
|
expect(review.exactSpellFamilies).toEqual({});
|
|
expect(review.candidateUnboundSpellFamilies).toHaveProperty("gatlingMissileBarrage");
|
|
|
|
const attacks = definition.entities["entry-663012"].combat?.attacks ?? [];
|
|
for (const spellId of [257_438, 257_459, 257_470, 257_472]) {
|
|
const normal = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-normal"));
|
|
const overdrive = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-overdrive"));
|
|
expect(normal, `normal spell ${spellId}`).toBeDefined();
|
|
expect(overdrive?.cooldownMs, `overdrive spell ${spellId}`).toBe(Math.round(normal!.cooldownMs * 0.8));
|
|
}
|
|
|
|
const encounter = definition.encounters?.find((entry) => entry.id === "tinkertech-showdown:mechatron");
|
|
expect(encounter?.source?.fidelity).toBe("healer-man-authored");
|
|
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
|
expect(reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "entity-health",
|
|
entityId: "encounter-9013",
|
|
healthPercent: 36,
|
|
now: 1_000,
|
|
}).snapshot).toBe(snapshot);
|
|
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
|
kind: "entity-health",
|
|
entityId: "encounter-9013",
|
|
healthPercent: 35,
|
|
now: 2_000,
|
|
}).snapshot;
|
|
expect(snapshot).toMatchObject({
|
|
phase: "overdrive",
|
|
flags: { overdrive: true },
|
|
activeSpawnGroupIds: ["tinkertech-showdown:overdrive-reinforcements"],
|
|
});
|
|
const reinforcements = definition.encounterSpawnGroups
|
|
?.find((group) => group.id === "tinkertech-showdown:overdrive-reinforcements");
|
|
expect(reinforcements?.spawns).toHaveLength(2);
|
|
expect(reinforcements?.spawns.every((spawn) => spawn.entityId === "tinkertech-showdown-caster"))
|
|
.toBe(true);
|
|
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
|
|
expect(reset).toMatchObject({ phase: "active", flags: { overdrive: false }, activeSpawnGroupIds: [] });
|
|
});
|
|
});
|