Release Healer Man 0.1.11
This commit is contained in:
1 parent
5f1a14d8de
commit
13970d03c8
18 files changed
+514
-19
No files matched your search
@@ -1520,3 +1520,55 @@ Vite's existing large-chunk advisory remains informational.
|
||||
the production build completed with 1,047 modules transformed, KTX2 and
|
||||
environment-performance audits remained green, and the release-candidate
|
||||
check passed. The existing large-chunk advisory remains informational.
|
||||
|
||||
# 2026-08-31 — Shadowfang Keep navigation freeze regression
|
||||
|
||||
- Reproduced the reported freeze with the saved Human Priest at the Shadowfang
|
||||
Keep entrance. The visible scene fell to roughly 4–11 FPS with 91–225 ms
|
||||
average frames and repeated 500+ ms slow frames, without a runtime exception.
|
||||
- Traced the stall to synchronous construction of the provisional 545,171-
|
||||
triangle party-navigation graph after the dungeon became visible. A direct
|
||||
benchmark blocked the main thread for roughly 8 seconds and consumed about
|
||||
800 MiB of heap; its subsequent entrance surface/path queries also failed.
|
||||
- Added a navigation policy that keeps reviewed, budgeted graphs enabled while
|
||||
unvalidated or over-50,000-triangle graphs use the existing local and
|
||||
breadcrumb party-follow fallback. Shadowfang therefore avoids the unusable
|
||||
graph, while Wailing Caverns retains its reviewed navigation.
|
||||
- Re-entered Shadowfang from a clean reload and kept the live scene open beyond
|
||||
the former graph-build window. Eight consecutive samples held 59–60 FPS at
|
||||
about 16.7 ms per frame; the slowest final sampled frame was 25.8 ms. The party,
|
||||
HUD, minimap, combat state, and full environment remained responsive and
|
||||
visible in the reviewed screenshot. No new browser errors were emitted.
|
||||
- Verification: all 20 focused navigation cases passed, the complete Vitest
|
||||
suite passed all 844 cases across 154 files, the production build completed
|
||||
with 1,048 modules transformed, KTX2 and environment-performance audits
|
||||
remained green, and the release-candidate check passed. The existing
|
||||
large-chunk advisory remains informational.
|
||||
|
||||
# 2026-08-31 — dungeon-fleet startup freeze audit
|
||||
|
||||
- Audited the complete registered fleet: 84 regular-dungeon navigation assets,
|
||||
all 75 Manastorm package assets, and all 470 published Manastorm stages. Every
|
||||
referenced navigation GLB exists and parses. Under the reviewed 50,000-
|
||||
triangle policy, 31 regular and 41 Manastorm package graphs remain enabled;
|
||||
the other 53 regular and 34 Manastorm packages use breadcrumb following.
|
||||
- Added an actual-geometry triangle check before navigation allocations. This
|
||||
prevents stale catalog metadata from bypassing the synchronous-build ceiling;
|
||||
Wailing Caverns metadata was corrected from 32,077 to 45,732 triangles.
|
||||
- Found nine zero-length imported patrol routes that could throw during scene
|
||||
render: one in Scholomance, two in Botanica, two in Magister's Terrace, and
|
||||
four in Escape from Durnholde. Catalog generation now preserves those actors
|
||||
as static spawns, and runtime normalization provides the same defense for
|
||||
future malformed imports. All remaining 752 regular patrols and every
|
||||
effective Manastorm stage patrol have usable routes.
|
||||
- Live entrance checks loaded Scholomance, Escape from Durnholde, and Forgotten
|
||||
Mine without a scene error or startup freeze. Scholomance settled near 28.5
|
||||
FPS at 946 draw calls; Durnholde near 29 FPS at 3,663 draw calls; Forgotten
|
||||
Mine near 50 FPS at 1,502 draw calls after staged loading. These identify
|
||||
remaining environment/population throughput work, but are separate from the
|
||||
fixed startup-crash and synchronous-navigation failure modes.
|
||||
- Verification: the complete Vitest suite passed all 850 cases across 155
|
||||
files, the production build completed with 1,048 modules transformed, all
|
||||
106 environment assets passed their performance audit with no regressions,
|
||||
the KTX2 audit found no fallback textures, and the release-candidate check
|
||||
passed. The existing large-chunk advisory remains informational.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.10",
|
||||
"versionCode": 1010
|
||||
"versionName": "0.1.11",
|
||||
"versionCode": 1011
|
||||
}
|
||||
@@ -1027,7 +1027,13 @@ async function compile() {
|
||||
const waypoints = spawn.waypoints
|
||||
.filter((_, index) => index % stride === 0)
|
||||
.map(mapPosition);
|
||||
if (waypoints.length >= 2) {
|
||||
const origin = waypoints[0];
|
||||
const hasDistinctWaypoint = origin && waypoints.some((point) => Math.hypot(
|
||||
point[0] - origin[0],
|
||||
point[1] - origin[1],
|
||||
point[2] - origin[2],
|
||||
) > 1e-6);
|
||||
if (waypoints.length >= 2 && hasDistinctWaypoint) {
|
||||
roamingPacks.push({
|
||||
id: `patrol-${spawn.guid}`,
|
||||
name: `${entities[spawn.entityId].name} Patrol`,
|
||||
|
||||
@@ -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
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user