Release Healer Man 0.1.11

This commit is contained in:
phenom committed 2026-08-31 14:12:51 -04:00
1 parent 5f1a14d8de
commit 13970d03c8
18 files changed
+514 -19

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ export const WAILING_CAVERNS_DEFINITION = {
assets: {
visual: [{ id: "main", fileName: "wailing-caverns-visual.glb" }],
collision: [{ id: "main", fileName: "wailing-caverns-collision.glb", triangleCount: 90_000 }],
navigation: { id: "main", fileName: "wailing-caverns-navigation.glb", triangleCount: 32_077 },
navigation: { id: "main", fileName: "wailing-caverns-navigation.glb", triangleCount: 45_732 },
editableSource: "wailing-caverns.source.blend",
},
transform: {
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit.
export default "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 truncated
export default "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 truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":326,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":169,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":145,"roamingPacks":8,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
+13
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { WAILING_CAVERNS_DEFINITION } from "./dungeons/wailingCaverns";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
manastormActiveRuntimeIds,
runtimeStages,
@@ -14,6 +15,7 @@ import {
manastormStageRoamingPacks,
registerManastormStageBindingResolver,
} from "./manastormStagePopulation";
import { hasUsableLoopedMobPath } from "./mobPopulation";
const stages = runtimeStages(INSTALLED_MANASTORM_RUNTIME_CATALOG);
registerManastormStageBindingResolver(installedManastormStageBinding);
@@ -51,6 +53,17 @@ describe("installed Manastorm stage population", () => {
}
});
it("keeps every live stage's effective patrol routes usable", () => {
expect(stages).toHaveLength(INSTALLED_MANASTORM_STAGE_BINDINGS.length);
for (const stage of stages) {
const dungeon = requireDungeonDefinition(stage.dungeonId);
const packs = manastormStageRoamingPacks(dungeon.roamingPacks, stage);
for (const pack of packs) {
expect(hasUsableLoopedMobPath(pack.waypoints), `${stage.id}:${pack.id}`).toBe(true);
}
}
});
it("labels unavailable creature art as procedural but reuses exact-name fallback art", () => {
const targorr = stages.find((stage) => stage.bossName === "Targorr the Dread");
const anacondra = stages.find((stage) => stage.bossName === "Lady Anacondra");
+38
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
createLoopedPath,
hasUsableLoopedMobPath,
normalizeMobPopulationPaths,
sampleLoopedPath,
samplePackFormation,
samplePackMember,
@@ -11,6 +13,7 @@ import {
WAILING_CAVERNS_STATIC_SPAWNS,
} from "./mobPopulation";
import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
const square = createLoopedPath([
[0, 0, 0],
@@ -78,6 +81,41 @@ describe("looped mob path sampling", () => {
expect(() => createLoopedPath([[1, 2, 3], [1, 2, 3]])).toThrow(/distinct waypoints/i);
});
it("converts malformed imported patrols into stationary spawns", () => {
const pack = {
id: "stationary-patrol",
name: "Stationary Patrol",
speed: 1,
formationSource: "database-solo",
spawnMask: 2,
waypoints: [[4, 5, 6], [4, 5, 6]],
members: [{
id: "member",
entityId: "guard",
role: "leader",
formationOffset: [1, 0.5, 2],
}],
} as const;
const normalized = normalizeMobPopulationPaths([pack], []);
expect(normalized.roamingPacks).toEqual([]);
expect(normalized.staticSpawns).toEqual([{
id: "stationary-patrol:member",
entityId: "guard",
position: [5, 5.5, 4],
spawnMask: 2,
}]);
});
it("keeps every packaged dungeon patrol on a usable loop", () => {
expect(DUNGEON_DEFINITIONS).toHaveLength(84);
for (const dungeon of DUNGEON_DEFINITIONS) {
for (const pack of dungeon.roamingPacks) {
expect(hasUsableLoopedMobPath(pack.waypoints), `${dungeon.id}:${pack.id}`).toBe(true);
}
}
});
it("keeps the shipped patrol population internally consistent", () => {
expect(WAILING_CAVERNS_ROAMING_PACKS).toHaveLength(6);
+50
View File
@@ -91,6 +91,11 @@ export interface StaticMobSpawnCluster {
readonly spawns: readonly StaticMobSpawnDefinition[];
}
export interface NormalizedMobPopulationPaths {
readonly roamingPacks: readonly MobPackDefinition[];
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
}
/**
* Partitions large imported populations into bounded activation cells. Actors
* in distant cells are not mounted or registered with combat until approached.
@@ -141,6 +146,51 @@ export interface LoopedPath {
const EPSILON = 1e-6;
export function hasUsableLoopedMobPath(waypoints: readonly MobVector3[]): boolean {
if (waypoints.length < 2 || !waypoints.every((point) => point.every(Number.isFinite))) {
return false;
}
const origin = waypoints[0];
return waypoints.some((point) => Math.hypot(
point[0] - origin[0],
point[1] - origin[1],
point[2] - origin[2],
) > EPSILON);
}
/**
* Imported databases occasionally label a stationary spawn as a patrol by
* repeating one waypoint. Keep those actors, but mount them through the static
* path so malformed content can never take down the scene.
*/
export function normalizeMobPopulationPaths(
roamingPacks: readonly MobPackDefinition[],
staticSpawns: readonly StaticMobSpawnDefinition[],
): NormalizedMobPopulationPaths {
const invalidPacks = roamingPacks.filter((pack) => !hasUsableLoopedMobPath(pack.waypoints));
if (!invalidPacks.length) return { roamingPacks, staticSpawns };
const invalidIds = new Set(invalidPacks.map((pack) => pack.id));
const stationarySpawns = invalidPacks.flatMap((pack) => {
const anchor = pack.waypoints.find((point) => point.every(Number.isFinite));
if (!anchor) return [];
return pack.members.map((member): StaticMobSpawnDefinition => ({
id: `${pack.id}:${member.id}`,
entityId: member.entityId,
position: [
anchor[0] + member.formationOffset[0],
anchor[1] + member.formationOffset[1],
anchor[2] - member.formationOffset[2],
],
spawnMask: pack.spawnMask,
}));
});
return {
roamingPacks: roamingPacks.filter((pack) => !invalidIds.has(pack.id)),
staticSpawns: [...staticSpawns, ...stationarySpawns],
};
}
function assertFiniteWaypoint(point: MobVector3, index: number): void {
if (!point.every(Number.isFinite)) {
throw new Error(`Waypoint ${index} contains a non-finite coordinate.`);
+17
View File
@@ -35,6 +35,23 @@ function twoCorridorGraph() {
}
describe("party navigation graph", () => {
it("rejects oversized source geometry before building the graph", () => {
const geometry = triangleGeometry([
[0, 0, 0],
[1, 0, 0],
[0, 0, 1],
[1, 0, 1],
], [
[0, 1, 2],
[1, 3, 2],
]);
expect(() => buildPartyNavigationGraph(geometry, { maxSourceTriangles: 1 }))
.toThrow("Party navigation geometry has 2 triangles; maximum is 1.");
expect(buildPartyNavigationGraph(geometry, { maxSourceTriangles: 2 }).sourceTriangleCount)
.toBe(2);
});
it("welds duplicate seam vertices within one millimeter", () => {
const geometry = triangleGeometry([
[0, 0, 0],
+21 -1
View File
@@ -48,6 +48,8 @@ export interface PartyNavigationGraph {
export interface PartyNavigationBuildOptions {
readonly weldTolerance?: number;
/** Defensive runtime ceiling; checked before graph allocations begin. */
readonly maxSourceTriangles?: number;
readonly offMeshLinks?: readonly {
readonly start: PartyNavigationPosition;
readonly end: PartyNavigationPosition;
@@ -241,6 +243,24 @@ export function buildPartyNavigationGraph(
if (indexCount < 3 || indexCount % 3 !== 0) {
throw new Error("Party navigation geometry must contain complete triangles.");
}
const sourceTriangleCount = indexCount / 3;
if (
options.maxSourceTriangles !== undefined
&& (
!Number.isInteger(options.maxSourceTriangles)
|| options.maxSourceTriangles <= 0
)
) {
throw new Error("Party navigation maximum source triangles must be a positive integer.");
}
if (
options.maxSourceTriangles !== undefined
&& sourceTriangleCount > options.maxSourceTriangles
) {
throw new Error(
`Party navigation geometry has ${sourceTriangleCount} triangles; maximum is ${options.maxSourceTriangles}.`,
);
}
const sourceToWelded = new Int32Array(position.count);
const welded: MutableWeldedNode[] = [];
@@ -418,7 +438,7 @@ export function buildPartyNavigationGraph(
}),
weldTolerance: tolerance,
sourceVertexCount: position.count,
sourceTriangleCount: indexCount / 3,
sourceTriangleCount,
weldedNodeCount: welded.length,
componentCount: components.length,
discardedNodeCount: welded.length - nodes.length,
+145
View File
@@ -0,0 +1,145 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS, dungeonCanEnter } from "./dungeonRegistry";
import manastormRegistry from "./generated/manastormAssetPackages.json";
import type { ManastormStageAssetPackage } from "./manastormStageAssets";
import {
MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
shouldLoadPartyNavigation,
} from "./partyNavigationPolicy";
interface GlbPrimitive {
readonly attributes?: Readonly<Record<string, number>>;
readonly indices?: number;
readonly mode?: number;
}
interface GlbJson {
readonly accessors?: readonly { readonly count?: number }[];
readonly meshes?: readonly { readonly primitives?: readonly GlbPrimitive[] }[];
}
const GLB_MAGIC = 0x46546c67;
const GLB_JSON_CHUNK = 0x4e4f534a;
function readGlbJson(filePath: string): GlbJson {
const glb = readFileSync(filePath);
expect(glb.readUInt32LE(0), filePath).toBe(GLB_MAGIC);
expect(glb.readUInt32LE(4), filePath).toBe(2);
expect(glb.readUInt32LE(16), filePath).toBe(GLB_JSON_CHUNK);
const jsonLength = glb.readUInt32LE(12);
return JSON.parse(glb.toString("utf8", 20, 20 + jsonLength)) as GlbJson;
}
/** Mirrors PartyPopulation's selection of the mesh with the most positions. */
function runtimeSelectedTriangleCount(filePath: string): number {
const glb = readGlbJson(filePath);
let largestVertexCount = 0;
let triangleCount = 0;
for (const mesh of glb.meshes ?? []) {
for (const primitive of mesh.primitives ?? []) {
if (primitive.mode !== undefined && primitive.mode !== 4) continue;
const positionAccessor = primitive.attributes?.POSITION;
const vertexCount = positionAccessor === undefined
? 0
: glb.accessors?.[positionAccessor]?.count ?? 0;
if (vertexCount <= largestVertexCount) continue;
largestVertexCount = vertexCount;
const elementCount = primitive.indices === undefined
? vertexCount
: glb.accessors?.[primitive.indices]?.count ?? 0;
expect(elementCount % 3, filePath).toBe(0);
triangleCount = elementCount / 3;
}
}
expect(triangleCount, filePath).toBeGreaterThan(0);
return triangleCount;
}
function regularNavigationPath(
dungeonId: string,
navigation: { readonly fileName: string; readonly url?: string },
): string {
if (navigation.url?.startsWith("/")) {
return path.join(process.cwd(), "public", ...navigation.url.split("/").filter(Boolean));
}
return path.join(
process.cwd(),
"src",
"assets",
"game",
"dungeons",
dungeonId,
navigation.fileName,
);
}
function manastormNavigationPath(url: string): string {
return path.join(process.cwd(), "public", ...url.split("/").filter(Boolean));
}
describe("party navigation fleet safety", () => {
it("covers every regular dungeon and keeps actual synchronous geometry under budget", () => {
const enabled = [];
const fallback = [];
expect(DUNGEON_DEFINITIONS).toHaveLength(84);
for (const dungeon of DUNGEON_DEFINITIONS) {
expect(dungeonCanEnter(dungeon.id), dungeon.id).toBe(true);
const navigation = dungeon.assets.navigation;
expect(navigation, dungeon.id).toBeDefined();
expect(Number.isInteger(navigation?.triangleCount), dungeon.id).toBe(true);
expect(navigation?.triangleCount, dungeon.id).toBeGreaterThan(0);
const actualTriangles = runtimeSelectedTriangleCount(regularNavigationPath(
dungeon.id,
navigation!,
));
// Metadata may conservatively count multiple primitives, but must never
// under-report the runtime-selected primitive used by the graph builder.
expect(navigation!.triangleCount, dungeon.id).toBeGreaterThanOrEqual(actualTriangles);
if (shouldLoadPartyNavigation("dungeon", dungeon, null)) {
enabled.push(dungeon.id);
expect(dungeon.validation.status, dungeon.id).toBe("green");
expect(actualTriangles, dungeon.id)
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
} else {
fallback.push(dungeon.id);
}
}
expect(enabled).toHaveLength(31);
expect(fallback).toHaveLength(53);
});
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
const packages = manastormRegistry.packages as unknown as readonly ManastormStageAssetPackage[];
const fallbackDungeon = DUNGEON_DEFINITIONS[0];
const enabled = [];
const fallback = [];
expect(packages).toHaveLength(75);
for (const assetPackage of packages) {
const declaredTriangles = assetPackage.navigation.triangleCount;
expect(Number.isInteger(declaredTriangles), assetPackage.id).toBe(true);
expect(declaredTriangles, assetPackage.id).toBeGreaterThan(0);
const actualTriangles = runtimeSelectedTriangleCount(
manastormNavigationPath(assetPackage.navigation.url),
);
expect(declaredTriangles, assetPackage.id).toBe(actualTriangles);
if (shouldLoadPartyNavigation("manastorm", fallbackDungeon, assetPackage)) {
enabled.push(assetPackage.id);
expect(actualTriangles, assetPackage.id)
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
} else {
fallback.push(assetPackage.id);
}
}
expect(enabled).toHaveLength(41);
expect(fallback).toHaveLength(34);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
partyObjectivesForNavigation,
shouldLoadPartyNavigation,
} from "./partyNavigationPolicy";
import type { DungeonDefinition } from "./dungeonTypes";
import type { ManastormStageAssetPackage } from "./manastormStageAssets";
function manastormPackageWithNavigationTriangles(
triangleCount: number | undefined,
): Pick<ManastormStageAssetPackage, "navigation"> {
return {
navigation: {
id: "navigation",
url: "/navigation.glb",
checksum: "0".repeat(64),
compression: "none",
triangleCount,
},
};
}
function dungeonWithNavigation(
status: DungeonDefinition["validation"]["status"],
triangleCount: number,
): Pick<DungeonDefinition, "assets" | "validation"> {
return {
assets: {
visual: [],
collision: [],
navigation: { id: "navigation", fileName: "navigation.glb", triangleCount },
},
validation: { status, blockers: [], warnings: [] },
};
}
describe("party navigation loading policy", () => {
it("keeps reviewed, budgeted dungeon navigation enabled", () => {
const wailingCaverns = requireDungeonDefinition("wailing-caverns");
expect(shouldLoadPartyNavigation("dungeon", wailingCaverns, null)).toBe(true);
});
it("does not schedule Shadowfang Keep's unvalidated oversized navigation graph", () => {
const shadowfangKeep = requireDungeonDefinition("shadowfang-keep");
expect(shadowfangKeep.validation.status).toBe("unvalidated");
expect(shadowfangKeep.assets.navigation?.triangleCount).toBeGreaterThan(
MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
);
expect(shouldLoadPartyNavigation("dungeon", shadowfangKeep, null)).toBe(false);
});
it("keeps unvalidated regular dungeons on breadcrumb following even under budget", () => {
expect(shouldLoadPartyNavigation(
"dungeon",
dungeonWithNavigation("unvalidated", 1_000),
null,
)).toBe(false);
});
it("protects Manastorm from oversized or unmeasured synchronous graphs", () => {
const fallbackDungeon = requireDungeonDefinition("wailing-caverns");
expect(shouldLoadPartyNavigation(
"manastorm",
fallbackDungeon,
manastormPackageWithNavigationTriangles(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES),
)).toBe(true);
expect(shouldLoadPartyNavigation(
"manastorm",
fallbackDungeon,
manastormPackageWithNavigationTriangles(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES + 1),
)).toBe(false);
expect(shouldLoadPartyNavigation(
"manastorm",
fallbackDungeon,
manastormPackageWithNavigationTriangles(undefined),
)).toBe(false);
});
it("exposes objectives only after a usable navigation graph is ready", () => {
const objectives = [{ id: "boss", name: "Boss", position: [1, 2, 3] as const }];
expect(partyObjectivesForNavigation(false, objectives)).toEqual([]);
expect(partyObjectivesForNavigation(true, objectives)).toBe(objectives);
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { GameMode } from "./store";
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
import type { ManastormStageAssetPackage } from "./manastormStageAssets";
/**
* Party navigation graphs are currently built synchronously on the main thread.
* Benchmarks put 50k triangles at roughly half a second on the development
* machine. Larger graphs use breadcrumb following until graph construction can
* move off the main thread.
*/
export const MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES = 50_000;
const NO_ROUTED_PARTY_OBJECTIVES = Object.freeze([]) as readonly DungeonBossObjective[];
export function shouldLoadPartyNavigation(
gameMode: GameMode,
dungeon: Pick<DungeonDefinition, "assets" | "validation">,
manastormPackage: Pick<ManastormStageAssetPackage, "navigation"> | null,
): boolean {
const navigation = gameMode === "manastorm" && manastormPackage
? manastormPackage.navigation
: dungeon.assets.navigation;
if (!navigation) return false;
// Imported dungeon packs already use local/breadcrumb following until their
// routes are reviewed, so building their navigation graph provides no value.
if (gameMode === "dungeon" && dungeon.validation.status !== "green") return false;
const triangleCount = navigation.triangleCount;
return typeof triangleCount === "number"
&& Number.isInteger(triangleCount)
&& triangleCount > 0
&& triangleCount <= MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES;
}
/** Follow the player's safe breadcrumb trail until a usable graph is ready. */
export function partyObjectivesForNavigation(
navigationReady: boolean,
objectives: readonly DungeonBossObjective[],
): readonly DungeonBossObjective[] {
return navigationReady ? objectives : NO_ROUTED_PARTY_OBJECTIVES;
}
+10 -3
View File
@@ -48,6 +48,7 @@ import { stepFearedMobAway } from "../game/mobMotion";
import {
clusterStaticMobSpawns,
createLoopedPath,
normalizeMobPopulationPaths,
samplePackFormation,
} from "../game/mobPopulation";
import { useGameStore } from "../game/store";
@@ -1477,12 +1478,18 @@ export function MobPopulation({
() => allowedRuntimeIds ? new Set(allowedRuntimeIds) : null,
[allowedRuntimeIds],
);
const normalizedPopulation = useMemo(
() => normalizeMobPopulationPaths(roamingPacks, staticSpawns),
[roamingPacks, staticSpawns],
);
const staticClusters = useMemo(() => clusterStaticMobSpawns(
allowedSet ? staticSpawns.filter((spawn) => allowedSet.has(spawn.id)) : staticSpawns,
), [allowedSet, staticSpawns]);
allowedSet
? normalizedPopulation.staticSpawns.filter((spawn) => allowedSet.has(spawn.id))
: normalizedPopulation.staticSpawns,
), [allowedSet, normalizedPopulation.staticSpawns]);
return (
<group name="mob-population">
{roamingPacks.map((pack) => (
{normalizedPopulation.roamingPacks.map((pack) => (
<RoamingPack
key={pack.id}
definition={pack}
+22 -6
View File
@@ -53,6 +53,11 @@ import {
findNearestPartyNavigationSurface,
type PartyNavigationGraph,
} from "../game/partyNavigation";
import {
MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
partyObjectivesForNavigation,
shouldLoadPartyNavigation,
} from "../game/partyNavigationPolicy";
import {
emptyPartyRouteState,
nextPartyRouteRecoveryWaypoint,
@@ -184,7 +189,7 @@ class NavigationAssetBoundary extends Component<NavigationAssetBoundaryProps, Na
componentDidCatch(error: unknown, info: ErrorInfo): void {
this.props.onFailure();
console.warn("Party navigation asset could not be prepared; companions will hold safely.", error, info);
console.warn("Party navigation asset could not be prepared; companions will follow breadcrumbs.", error, info);
}
render(): ReactNode {
@@ -223,7 +228,10 @@ function PartyNavigationLoader({
const graph = useMemo(() => {
const geometry = navigationGeometry(gltf.scene);
try {
return buildPartyNavigationGraph(geometry, { offMeshLinks });
return buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks,
});
} finally {
geometry.dispose();
}
@@ -1412,13 +1420,15 @@ export function PartyPopulation({
: resolveDungeonAssets(dungeon),
[dungeon, manastormPackage, sessionRevision],
);
const navigationEnabled = Boolean(assets.navigation)
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormPackage);
const navigationLinks = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.navigationLinks;
const [navigationState, setNavigationState] = useState<{
readonly url: string;
readonly graph: PartyNavigationGraph;
} | null>(null);
const [failedNavigationUrl, setFailedNavigationUrl] = useState<string | null>(null);
const navigationGraph = navigationState?.url === assets.navigation
const navigationGraph = navigationEnabled && navigationState?.url === assets.navigation
? navigationState.graph
: null;
const navigationFailed = Boolean(
@@ -1437,6 +1447,12 @@ export function PartyPopulation({
if (assets.navigation) setFailedNavigationUrl(assets.navigation);
}, [assets.navigation]);
useEffect(() => {
setNavigationState((current) => (
navigationEnabled && current?.url === assets.navigation ? current : null
));
}, [assets.navigation, navigationEnabled]);
return (
<>
<PartyActors
@@ -1444,11 +1460,11 @@ export function PartyPopulation({
active={active}
simulate={simulate}
navigationGraph={navigationGraph}
navigationFailed={navigationFailed || !assets.navigation}
objectives={objectives}
navigationFailed={navigationFailed || !navigationEnabled}
objectives={partyObjectivesForNavigation(Boolean(navigationGraph), objectives)}
stackAtEntrance={gameMode === "manastorm" || dungeon.validation.status !== "green"}
/>
{assets.navigation ? (
{navigationEnabled && assets.navigation ? (
<NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}>
<Suspense fallback={null}>
<PartyNavigationLoader