121 lines
6.6 KiB
TypeScript
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);
|
|
});
|