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(); 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); });