Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 20:27:07 -04:00
1 parent 4957b69e86
commit 8a98797fcf
30 files changed
+469 -39

No files matched your search

@@ -0,0 +1,91 @@
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 } 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",
] as const;
describe("RoM bite-sized finales", () => {
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(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).toBeGreaterThan(1);
});
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);
});
+182
View File
@@ -0,0 +1,182 @@
import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
Vector3Tuple,
} from "../dungeonTypes";
import type { DungeonEncounterDefinition } from "../dungeonEncounterTypes";
import {
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
} from "../dungeonContentSource";
interface RoMSlice {
readonly sourceId: string;
readonly id: string;
readonly title: string;
readonly location: string;
readonly entranceName: string;
readonly start: Vector3Tuple;
readonly forward: Vector3Tuple;
readonly bossName: string;
readonly mobSpawnIds: readonly string[];
readonly summary: string;
readonly adaptation: string;
}
const ROM_SLICES: readonly RoMSlice[] = [
{
sourceId: "forsaken-abbey",
id: BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
title: "Forsaken Abbey: Ancalon's Approach",
location: "Forsaken Abbey · Ancalon's Passage",
entranceName: "Ancalon's Passage",
// The omitted source bat occupied this floor-projected point before the last packs.
start: [330.022888, -19.847076, -34.424903],
forward: [1, 0, 0],
bossName: "Demon Witch Ancalon",
mobSpawnIds: [
"rw-fa-spawn-7855", "rw-fa-spawn-7856",
"rw-fa-spawn-12074", "rw-fa-spawn-12081",
"rw-fa-spawn-12071", "rw-fa-spawn-12072",
],
summary: "Clear the shadows and followers in the final passage, then defeat Demon Witch Ancalon.",
adaptation: "The earlier keepers, side rooms, patrol script, and deferred keeper doors are outside this short run.",
},
{
sourceId: "paspers-shrine",
id: BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
title: "Paspers' Shrine: Hafiz's Hall",
location: "Paspers' Shrine · Hafiz's Hall",
entranceName: "Hafiz's Hall",
// The omitted source axe soldier occupied this projected point before the final corridor.
start: [95.618457, 0.348065, -285.473267],
forward: [1, 0, 0],
bossName: "Blackhorn Hafiz",
mobSpawnIds: [
"rw-paspers-shrine-spawn-5943704", "rw-paspers-shrine-spawn-5943709",
"rw-paspers-shrine-spawn-5943711", "rw-paspers-shrine-spawn-5943712",
"rw-paspers-shrine-spawn-5943715", "rw-paspers-shrine-spawn-5943716",
],
summary: "Defeat the Blackhorn guards along the last corridor, then confront Blackhorn Hafiz.",
adaptation: "The other Blackhorn bosses and noncombat source interactions are outside this short run.",
},
{
sourceId: "mystic-altar",
id: BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
title: "Mystic Altar: Golem's Antechamber",
location: "Mystic Altar · Golem's Antechamber",
entranceName: "Golem's Antechamber",
start: [-392.475647, -17.665024, 329],
forward: [-1, 0, 0],
bossName: "Cursed lron Golem",
mobSpawnIds: [
"rw-mystic-altar-spawn-13463", "rw-mystic-altar-spawn-13465",
"rw-mystic-altar-spawn-13244", "rw-mystic-altar-spawn-13245",
"rw-mystic-altar-spawn-13248", "rw-mystic-altar-spawn-13332",
],
summary: "Clear the guards, beetles, and Morcus beasts before the Cursed Iron Golem.",
adaptation: "Dorlos, the earlier altar route, and the remaining beetle swarm are outside this short run.",
},
];
function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefinition {
if (source.id !== slice.sourceId) throw new Error(`${slice.id} requires ${slice.sourceId}.`);
const boss = source.objectiveOrder.find((objective) => objective.name === slice.bossName);
const bossSpawn = boss && source.staticSpawns.find((spawn) => spawn.id === boss.id);
if (!boss || !bossSpawn || source.entities[bossSpawn.entityId]?.kind !== "boss") {
throw new Error(`${slice.sourceId} is missing ${slice.bossName}.`);
}
const mobSpawns = slice.mobSpawnIds.map((id) => {
const spawn = source.staticSpawns.find((candidate) => candidate.id === id);
if (!spawn || source.entities[spawn.entityId]?.kind === "boss" || !source.entities[spawn.entityId]) {
throw new Error(`${slice.sourceId} is missing source mob ${id}.`);
}
return spawn;
});
const bossGroup: DungeonEncounterSpawnGroupDefinition = {
id: `${slice.id}:boss`,
spawns: [bossSpawn],
};
const encounter: DungeonEncounterDefinition = {
id: `${slice.id}:progression`,
dungeonId: slice.id,
initialPhase: "approach-mobs",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "approach-mobs", checkpointPhase: "approach-mobs" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedRoMDungeons.ts"],
reviewedBehavior: slice.summary,
adaptations: [slice.adaptation, "The boss appears after the retained source mobs are defeated."],
remainingGaps: ["The start, party route, combat, boss activation, and completion need an in-game pass."],
},
rules: [
{
id: "approach-cleared",
trigger: { kind: "entities-dead", entityIds: mobSpawns.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: ["approach-mobs"] }],
actions: [
{ id: "activate-boss", kind: "activate-spawn-group", spawnGroupId: bossGroup.id },
{ id: "enter-boss-phase", kind: "set-phase", phase: "boss" },
],
},
{
id: "boss-defeated",
trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
guards: [{ kind: "phase", phases: ["boss"] }],
actions: [
{ id: "complete-objective", kind: "complete-objective", objectiveId: boss.id, persistence: "run" },
{ id: "checkpoint", kind: "set-checkpoint", checkpointId: boss.id, persistence: "checkpoint" },
{ id: "complete-run", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "complete-phase", kind: "set-phase", phase: "complete", persistence: "run" },
],
},
],
};
const objective: DungeonBossObjective = { ...boss, eventOnly: true };
const entityIds = new Set([...mobSpawns, bossSpawn].map((spawn) => spawn.entityId));
const entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id)));
const areas = [
{ id: "entrance", name: slice.entranceName, center: slice.start, radius: 22 },
{ id: "boss-room", name: slice.bossName, center: boss.position, radius: 32 },
];
return {
...source,
id: slice.id,
title: slice.title,
lfgDungeonIds: [],
entrance: { name: slice.entranceName, footPosition: slice.start, forward: slice.forward,
yaw: Math.atan2(slice.forward[0], slice.forward[2]) },
presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
unchartedAreaName: slice.entranceName },
areas,
entities,
staticSpawns: mobSpawns,
roamingPacks: [],
bosses: [objective],
objectiveOrder: [objective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: [bossGroup],
serverGameObjects: [],
validation: { ...source.validation, warnings: [...source.validation.warnings,
"This short RoM route is Healer Man-authored from imported population and awaits live traversal review."] },
};
}
export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[0]);
}
export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[1]);
}
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[2]);
}