Files
healer-man/src/game/ascensionCustomEncounterCatalog.test.ts
2026-09-09 11:21:33 -04:00

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: [] });
});
});