Files
healer-man/src/game/dungeons/biteSizedRoMDungeons.test.ts
T
2026-09-27 10:16:42 -04:00

121 lines
6.6 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
import { describe, expect, it } from "vitest";
import { dungeonContentPackId, dungeonContentSourceId } from "../dungeonContentSource";
import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry";
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
import { buildPartyNavigationGraph, findNearestPartyNavigationSurface, findPartyNavigationPath } from "../partyNavigation";
const sliceIds = [
"bite-sized-forsaken-abbey-ancalon",
"bite-sized-paspers-shrine-hafiz",
"bite-sized-mystic-altar-golem",
"bite-sized-grafu-castle-barkud",
"bite-sized-barren-caves-anglo",
"bite-sized-hoto-teeth",
"bite-sized-kawaks-tomb-linobia",
"bite-sized-hall-of-survivors-pursuer",
] as const;
describe("RoM bite-sized finales", () => {
it.each(sliceIds)("selects every %s source boss from stable run seeds at the same finale anchor", (id) => {
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
const anchor = DUNGEON_REGISTRY[id].bosses[0].position;
const selected = new Set<string>();
for (let index = 0; index < 500; index += 1) {
const definition = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
const again = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
const boss = definition.bosses[0];
selected.add(boss.id);
expect(again.bosses[0].id).toBe(boss.id);
expect(boss.position).toEqual(anchor);
expect(boss.optional).toBe(false);
expect(definition.encounterSpawnGroups![0].spawns[0].position).toEqual(anchor);
for (const difficulty of definition.difficultyVariants) {
expect((definition.encounterSpawnGroups![0].spawns[0].spawnMask! & difficulty.spawnMask)).not.toBe(0);
}
expect(source.bosses.map((candidate) => candidate.id)).toContain(boss.id);
}
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
});
it.each(sliceIds)("gates %s behind its retained mobs and completes on its boss", (id) => {
const slice = DUNGEON_REGISTRY[id];
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
expect(slice).toBeDefined();
expect(source).toBeDefined();
expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`);
expect(slice.assets).toBe(source.assets);
expect(slice.staticSpawns).toHaveLength(id === "bite-sized-barren-caves-anglo" ? 4
: id === "bite-sized-kawaks-tomb-linobia" || id === "bite-sized-hall-of-survivors-pursuer" ? 5 : 6);
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.includes(spawn))).toBe(true);
expect(slice.bosses).toHaveLength(1);
expect(slice.bosses[0].eventOnly).toBe(true);
expect(slice.staticSpawns).not.toContainEqual(expect.objectContaining({ id: slice.bosses[0].id }));
expect(source.staticSpawns).toContainEqual(expect.objectContaining({ id: slice.bosses[0].id }));
const encounter = slice.encounters![0];
const mobIds = slice.staticSpawns.map((spawn) => spawn.id);
const initial = createDungeonEncounterSnapshot(encounter);
const beforeClear = reduceDungeonEncounter(encounter, initial, { kind: "tick", now: 100,
facts: { registeredEntityIds: mobIds, deadEntityIds: mobIds.slice(0, -1) } }).snapshot;
expect(beforeClear.activeSpawnGroupIds).toEqual([]);
const afterClear = reduceDungeonEncounter(encounter, beforeClear, { kind: "tick", now: 200,
facts: { registeredEntityIds: mobIds, deadEntityIds: mobIds } }).snapshot;
expect(afterClear.activeSpawnGroupIds).toEqual([slice.encounterSpawnGroups![0].id]);
const completed = reduceDungeonEncounter(encounter, afterClear, { kind: "tick", now: 300,
facts: { registeredEntityIds: [slice.bosses[0].id], deadEntityIds: [slice.bosses[0].id] } }).snapshot;
expect(completed.completedObjectiveIds).toContain(slice.bosses[0].id);
expect(completed.flags["dungeon-complete"]).toBe(true);
expect(completed.phase).toBe("complete");
// The complete source remains available with its original population and objectives.
expect(source.bosses.length).toBeGreaterThanOrEqual(1);
expect(slice.presentation.ambientLight.intensity).toBeGreaterThan(source.presentation.ambientLight.intensity);
expect(slice.presentation.hemisphereLight.intensity).toBeGreaterThan(source.presentation.hemisphereLight.intensity);
});
it.each(sliceIds)("keeps the %s start and boss reachable on shipped navigation", async (id) => {
const slice = DUNGEON_REGISTRY[id];
const sourceId = dungeonContentSourceId(id);
const navigation = slice.assets.navigation!;
const file = path.resolve("src/assets/game/dungeons", sourceId, navigation.fileName);
expect(readFileSync(file).length).toBeGreaterThan(0);
const document = await new NodeIO().read(file);
const positions: number[] = [];
const vertex = new Vector3();
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const world = new Matrix4().fromArray(node.getWorldMatrix());
for (const primitive of mesh.listPrimitives()) {
const source = primitive.getAttribute("POSITION")?.getArray();
if (!source) continue;
const indices = primitive.getIndices()?.getArray() ?? Array.from({ length: source.length / 3 }, (_, i) => i);
for (const index of indices) {
vertex.fromArray(source, Number(index) * 3).applyMatrix4(world);
positions.push(vertex.x, vertex.y, vertex.z);
}
}
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const graph = buildPartyNavigationGraph(geometry);
const start = findNearestPartyNavigationSurface(graph, slice.entrance.footPosition, 3);
const boss = findNearestPartyNavigationSurface(graph, slice.bosses[0].position, 6);
expect(start, `${id} start`).not.toBeNull();
expect(boss, `${id} boss`).not.toBeNull();
expect(findPartyNavigationPath(graph, start!.position, boss!.position, {
maxStartSnapDistance: 3, maxEndSnapDistance: 6,
}), `${id} start to boss`).not.toBeNull();
for (const spawn of slice.staticSpawns) {
const mob = findNearestPartyNavigationSurface(graph, spawn.position, 3);
expect(mob, `${id} ${spawn.id}`).not.toBeNull();
expect(findPartyNavigationPath(graph, start!.position, mob!.position, {
maxStartSnapDistance: 3, maxEndSnapDistance: 3,
}), `${id} route to ${spawn.id}`).not.toBeNull();
}
}, 30_000);
});