forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in new issue
Block a user