Release Healer Man 0.1.33

This commit is contained in:
phenom committed 2026-09-12 14:25:23 -04:00
1 parent 6dcb1034e5
commit 05b25c4341
20 files changed
+573 -51

No files matched your search

+46
View File
@@ -2212,3 +2212,49 @@ Vite's existing large-chunk advisory remains informational.
Effects without source mappings retain fallback. This is representative
desktop browser QA, not exhaustive spell-by-spell fidelity or a physical
Android/GPU performance test. No hosted release was published.
## 2026-09-12 - Stockades party routing and indoor lighting
- Fixed quantized navigation preparation: normalized integer positions now
expand to floats before the GLB node transform is baked. Previously the
Stockades world coordinates clamped into roughly [-1, 1], leaving the live
party unable to attach to its graph even when an offline route test passed.
- Added three reviewed bidirectional links across the 1.537m Recast seams at
the entrance/central stairs and the central/north and central/south stairs.
The shipping 3,538-triangle graph connects the entrance and all five boss
approaches while discarding the remaining small decorative islands.
- Added navigation-only review state and compiler-preserved runtime overrides.
Stockades navigation is green; its wider encounter, wipe/reset, checkpoint,
and completion validation remains unvalidated.
- Leader and follower formation paths now use the graph when a breadcrumb
rejoin is blocked. Fallback comfort goals and recalls attach to the reviewed
surface, preventing an off-boundary stair target from causing a false stuck
loop. Combat approach connectors respect each member's personalized range.
- Replaced the generic dark expansion palette only for Stockades with warmer,
stronger ambient/hemisphere fill and a warm directional light. No dungeon
textures or source meshes were changed, and other dungeon palettes remain
unchanged pending individual visual review.
- Live desktop browser check at 1280 x 720 used offline level-15 Priest
Rolecheck as healer with Blood DK tank Morvane, Combat Rogue Vale, Unholy DK
Thorne, and Frost Mage Ilyra. Fresh Defend mode showed PARTY 5/5, Recall on the
entrance stairs recovered without a route warning, and Attack resumed with
visible party movement down the stairs. The tank advanced into combat and
took Defias Basic Attack/Kick/Backhand aggro; Backstab and Icy Touch events
appeared while the player remained at the entrance.
- Inline task screenshots captured the warm, readable stonework/stairs and
equipped party silhouettes in Defend and Recall-to-Attack states. The final
runtime produced no new navigation errors; the existing deprecated physics
initialization warning remains. Temporary diagnostic logging was removed.
- Automated validation: all 223 Vitest files passed, 1,373 tests passed, and
six existing tests remained skipped. Regressions cover the actual quantized
Stockades GLB through the shipping asset-preparation path, normalized integer
transforms, formation projection, entrance-to-boss routing, and tank melee
approach. This is an entrance/combat smoke pass, not a complete dungeon clear
or physical Android test.
- Production TypeScript/Vite build, texture/performance audits, bundle
verification, and the final release candidate check passed. Existing
bundle-size/import advisories remain. No hosted release was published.
- The optional all-dungeon functionality audit was stopped after an extended
run without output; its existing report files were not changed. Stockades
navigation evidence comes from the actual shipping-asset regressions and
the live smoke pass above, not a new fleet-wide functionality certification.
@@ -39,11 +39,11 @@
"agentMaxSlope": 50
},
"review": {
"entrance": "required",
"entrance": "approved",
"bossOrder": "required",
"optionalEncounters": "required",
"presentation": "required",
"offMeshLinks": "required"
"presentation": "approved",
"offMeshLinks": "approved"
},
"exceptions": []
}
@@ -0,0 +1,56 @@
{
"schemaVersion": 1,
"dungeonId": "stormwind-stockade",
"navigationLinks": [
{
"id": "entrance-to-central-stairs",
"start": [-66.674328, -23.150503, 5.501851],
"end": [-67.664446, -23.755576, 4.493397],
"bidirectional": true,
"authoredReason": "Bridges the reviewed 1.537m Recast seam between the source entrance stairs and the central cell block."
},
{
"id": "central-to-north-wing-stairs",
"start": [-124.46789, -27.349338, 16.099782],
"end": [-125.476344, -27.95441, 17.0899],
"bidirectional": true,
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the north wing stairs."
},
{
"id": "central-to-south-wing-stairs",
"start": [-124.46789, -27.349338, -14.703893],
"end": [-125.476344, -27.95441, -15.694011],
"bidirectional": true,
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the south wing stairs."
}
],
"presentation": {
"background": "#171815",
"fog": {
"color": "#1b1c18",
"near": 44
},
"ambientLight": {
"color": "#d8c9aa",
"intensity": 0.58
},
"hemisphereLight": {
"skyColor": "#cfc3a5",
"groundColor": "#292820",
"intensity": 0.86
},
"directionalLight": {
"color": "#ffe0a8",
"intensity": 1.1,
"offset": [10, 18, -5]
}
},
"validation": {
"navigation": "green",
"report": "PLAYTEST_NOTES.md",
"warnings": [
"Three reviewed bidirectional links bridge the entrance, central block, and both prison wings; minor decorative navmesh islands remain intentionally discarded.",
"Stockades uses a dungeon-specific warm indoor lighting profile because the imported WMO has no baked lightmap or vertex-lighting channel."
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.32",
"version": "0.1.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.32",
"version": "0.1.33",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.32",
"version": "0.1.33",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.32",
"versionCode": 1032
"versionName": "0.1.33",
"versionCode": 1033
}
@@ -8,6 +8,7 @@ import { DEFAULT_AZEROTHCORE_COMMIT } from "./import-azerothcore-world.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
const runtimeOverridesRoot = path.join(projectRoot, "dungeon-pipeline", "runtime-overrides");
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
const gameObjectAssetManifestFile = path.join(
@@ -1445,13 +1446,13 @@ function finiteBounds(points) {
};
}
function presentationFor(recipe, assetPackage, environmentMode, bounds) {
function presentationFor(recipe, assetPackage, environmentMode, bounds, override = {}) {
const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic;
const span = Math.max(
bounds.max[0] - bounds.min[0],
bounds.max[2] - bounds.min[2],
);
return {
const presentation = {
location: assetPackage.location || recipe.title,
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`,
@@ -1480,6 +1481,16 @@ function presentationFor(recipe, assetPackage, environmentMode, bounds) {
],
},
};
return {
...presentation,
...override,
fog: { ...presentation.fog, ...override.fog },
ambientLight: { ...presentation.ambientLight, ...override.ambientLight },
hemisphereLight: { ...presentation.hemisphereLight, ...override.hemisphereLight },
directionalLight: { ...presentation.directionalLight, ...override.directionalLight },
materials: presentation.materials,
map: presentation.map,
};
}
async function compile() {
@@ -1535,6 +1546,19 @@ async function compile() {
const importsBySlug = new Map(
(fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]),
);
const runtimeOverrides = new Map();
for (const recipe of recipes) {
const file = path.join(runtimeOverridesRoot, `${recipe.slug}.json`);
if (!await exists(file)) continue;
const override = await readJson(file);
if (override.schemaVersion !== 1 || override.dungeonId !== recipe.slug) {
throw new Error(`${recipe.slug}: runtime override identity or schema is invalid.`);
}
if (override.validation?.navigation === "green" && !(override.navigationLinks?.length > 0)) {
throw new Error(`${recipe.slug}: green navigation requires at least one reviewed link.`);
}
runtimeOverrides.set(recipe.slug, override);
}
const drafts = new Map();
const wantedSpellIds = new Set();
for (const recipe of recipes) {
@@ -1584,6 +1608,7 @@ async function compile() {
const coverage = [];
for (const recipe of recipes) {
const dungeonImport = importsBySlug.get(recipe.slug);
const runtimeOverride = runtimeOverrides.get(recipe.slug) ?? {};
// Manastorm packages contain the complete optimized dungeon geometry.
// Reuse them for full dungeons so the install ships one map package.
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
@@ -1951,7 +1976,7 @@ async function compile() {
difficultyVariants: recipe.difficultyVariants,
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
availability: "available",
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds),
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds, runtimeOverride.presentation),
assets: {
visual: assetPackage.visual.map(assetChunk),
collision: assetPackage.collision.map(assetChunk),
@@ -1979,7 +2004,13 @@ async function compile() {
roamingPacks,
bosses,
objectiveOrder: bosses,
navigationLinks: [],
navigationLinks: (runtimeOverride.navigationLinks ?? []).map((link) => ({
id: String(link.id),
start: vector(link.start),
end: vector(link.end),
bidirectional: link.bidirectional !== false,
authoredReason: String(link.authoredReason),
})),
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
serverGameObjects,
provenance: {
@@ -2009,8 +2040,17 @@ async function compile() {
},
validation: {
status: "unvalidated",
...(runtimeOverride.validation?.navigation
? { navigation: runtimeOverride.validation.navigation }
: {}),
...(runtimeOverride.validation?.report
? { report: runtimeOverride.validation.report }
: {}),
blockers: [],
warnings: sourceWarnings,
warnings: [
...sourceWarnings,
...(runtimeOverride.validation?.warnings ?? []),
],
},
});
coverage.push({
+2
View File
@@ -209,6 +209,8 @@ export interface DungeonSourceProvenance {
export interface DungeonValidationStatus {
readonly status: "green" | "blocked" | "unvalidated";
/** Navigation-only review state; permits routing without overstating encounter validation. */
readonly navigation?: "green" | "blocked" | "unvalidated";
readonly report?: string;
readonly validatedAt?: string;
readonly blockers: readonly string[];
@@ -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":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
{"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
+4
View File
@@ -126,6 +126,10 @@ describe("party navigation graph", () => {
expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 });
expect(graph.triangles).toBeUndefined();
expect(findNearestPartyNavigationSurface(graph, [0.25, 0.5, 0.25], 1)).toMatchObject({
triangleId: -1,
distance: expect.any(Number),
});
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull();
});
+10 -1
View File
@@ -570,7 +570,16 @@ export function findNearestPartyNavigationSurface(
maxDistance = Number.POSITIVE_INFINITY,
): PartyNavigationSurfaceMatch | null {
const triangles = graph.triangles;
if (!triangles?.length) return null;
if (!triangles?.length) {
const nearest = findNearestPartyNavigationNode(graph, point, maxDistance);
return nearest
? Object.freeze({
position: nearest.node.position,
triangleId: -1,
distance: nearest.distance,
})
: null;
}
const target = pointComponents(point);
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
let nearestPosition: PartyNavigationPosition | null = null;
+29 -1
View File
@@ -1,4 +1,10 @@
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three";
import {
BufferAttribute,
BufferGeometry,
Float32BufferAttribute,
Group,
Mesh,
} from "three";
import { describe, expect, it } from "vitest";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
@@ -33,4 +39,26 @@ describe("party navigation asset cache", () => {
}]);
expect(second).not.toBe(first);
});
it("expands normalized quantized positions before baking the node transform", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Int16Array([
0, 0, 0,
32_767, 0, 0,
0, 0, 32_767,
]), 3, true));
const mesh = new Mesh(geometry);
mesh.position.set(10, 2, -5);
mesh.scale.setScalar(100);
const scene = new Group();
scene.add(mesh);
const graph = preparePartyNavigationGraph("/navigation/quantized-transform.glb", scene, []);
expect(graph.nodes.map((node) => node.position)).toEqual([
[10, 2, -5],
[110, 2, -5],
[10, 2, 95],
]);
});
});
+14 -1
View File
@@ -1,4 +1,4 @@
import { BufferGeometry, type Group, type Mesh } from "three";
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
import type { DungeonNavigationLink } from "./dungeonTypes";
import {
buildPartyNavigationGraph,
@@ -33,6 +33,19 @@ function navigationGeometry(scene: Group): BufferGeometry {
});
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
const geometry = (sourceMesh as Mesh).geometry.clone();
const position = geometry.getAttribute("position");
// BufferAttribute.applyMatrix4 writes back through the source component
// type. Quantized normalized integers therefore clamp world coordinates to
// [-1, 1] while baking their node transform. Expand them to floats first.
if (position.normalized) {
const expanded = new Float32Array(position.count * 3);
for (let index = 0; index < position.count; index += 1) {
expanded[index * 3] = position.getX(index);
expanded[index * 3 + 1] = position.getY(index);
expanded[index * 3 + 2] = position.getZ(index);
}
geometry.setAttribute("position", new Float32BufferAttribute(expanded, 3));
}
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
return geometry;
}
+9 -3
View File
@@ -102,7 +102,10 @@ describe("party navigation fleet safety", () => {
if (shouldLoadPartyNavigation("dungeon", dungeon, null)) {
enabled.push(dungeon.id);
expect(dungeon.validation.status, dungeon.id).toBe("green");
expect(
dungeon.validation.status === "green" || dungeon.validation.navigation === "green",
dungeon.id,
).toBe(true);
expect(actualTriangles, dungeon.id)
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
} else {
@@ -113,8 +116,11 @@ describe("party navigation fleet safety", () => {
// Raksha Temple and The Origin now ship their full source-assembled
// navigation geometry, which correctly moves both above the synchronous
// graph-build ceiling and onto the breadcrumb fallback path.
expect(enabled).toHaveLength(29);
expect(fallback).toHaveLength(55);
// Stockades has a reviewed navigation override without claiming that its
// broader encounter/reset validation is complete.
expect(enabled).toContain("stormwind-stockade");
expect(enabled).toHaveLength(30);
expect(fallback).toHaveLength(54);
});
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
+10 -1
View File
@@ -25,6 +25,7 @@ function manastormPackageWithNavigationTriangles(
function dungeonWithNavigation(
status: DungeonDefinition["validation"]["status"],
triangleCount: number,
navigation?: DungeonDefinition["validation"]["navigation"],
): Pick<DungeonDefinition, "assets" | "validation"> {
return {
assets: {
@@ -32,7 +33,7 @@ function dungeonWithNavigation(
collision: [],
navigation: { id: "navigation", fileName: "navigation.glb", triangleCount },
},
validation: { status, blockers: [], warnings: [] },
validation: { status, navigation, blockers: [], warnings: [] },
};
}
@@ -61,6 +62,14 @@ describe("party navigation loading policy", () => {
)).toBe(false);
});
it("loads an independently reviewed dungeon navigation graph", () => {
expect(shouldLoadPartyNavigation(
"dungeon",
dungeonWithNavigation("unvalidated", 1_000, "green"),
null,
)).toBe(true);
});
it("protects Manastorm from oversized or unmeasured synchronous graphs", () => {
const fallbackDungeon = requireDungeonDefinition("wailing-caverns");
+7 -3
View File
@@ -21,9 +21,13 @@ export function shouldLoadPartyNavigation(
: 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;
// Navigation can be reviewed independently from encounter/checkpoint content.
// Do not make safe melee routing wait for unrelated dungeon validation work.
if (
gameMode === "dungeon"
&& dungeon.validation.status !== "green"
&& dungeon.validation.navigation !== "green"
) return false;
const triangleCount = navigation.triangleCount;
return typeof triangleCount === "number"
+1 -1
View File
@@ -145,7 +145,7 @@ describe("player movement", () => {
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
});
it("walks back up the real Stockades entrance stairs without a jump impulse", () => {
it("walks back up the Stockades interior stairs without a jump impulse", () => {
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
world.createCollider(
+174
View File
@@ -0,0 +1,174 @@
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
import { beforeAll, describe, expect, it } from "vitest";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
findNearestPartyNavigationNode,
findNearestPartyNavigationSurface,
findPartyNavigationPath,
type PartyNavigationGraph,
} from "./partyNavigation";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
import { planPartyRoute } from "./partyPathing";
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
import { derivePartySpawnSlots } from "./partySpawnSlots";
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
const stockades = requireDungeonDefinition("stormwind-stockade");
let graph: PartyNavigationGraph;
beforeAll(async () => {
await MeshoptDecoder.ready;
const document = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
.read(STOCKADES_NAVIGATION);
const meshNode = document.getRoot().listNodes().find((node) => node.getMesh());
const primitive = meshNode?.getMesh()?.listPrimitives()[0];
const accessor = primitive?.getAttribute("POSITION");
const sourcePositions = accessor?.getArray();
if (!meshNode || !accessor || !sourcePositions) {
throw new Error("Stormwind Stockade navigation GLB is missing its triangle surface.");
}
if (!(sourcePositions instanceof Int16Array) || !accessor.getNormalized()) {
throw new Error("Stormwind Stockade navigation regression expects normalized Int16 positions.");
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(sourcePositions, 3, accessor.getNormalized()));
const sourceIndices = primitive?.getIndices()?.getArray();
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
const mesh = new Mesh(geometry);
mesh.matrixAutoUpdate = false;
mesh.matrix.fromArray(meshNode.getWorldMatrix());
const scene = new Group();
scene.add(mesh);
// Exercise the same quantized-buffer and node-transform path as GLTFLoader
// in the shipping runtime, not a separately pre-expanded test geometry.
graph = preparePartyNavigationGraph("/test/stockades-navigation.glb", scene, stockades.navigationLinks);
geometry.dispose();
});
function distance(left: readonly number[], right: readonly number[]): number {
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
}
describe("Stormwind Stockade party navigation", () => {
it("uses the reviewed warm indoor lighting profile", () => {
expect(stockades.presentation).toMatchObject({
background: "#171815",
fog: { color: "#1b1c18", near: 44 },
ambientLight: { color: "#d8c9aa", intensity: 0.58 },
hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 },
directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] },
});
});
it("loads its independently reviewed, budgeted navigation graph", () => {
expect(stockades.validation).toMatchObject({ status: "unvalidated", navigation: "green" });
expect(stockades.navigationLinks).toHaveLength(3);
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
expect(graph).toMatchObject({
sourceTriangleCount: 3_538,
componentCount: 31,
});
expect(graph.discardedNodeCount).toBeGreaterThan(0);
});
it("projects every entrance formation slot onto the retained walkable surface", () => {
const slots = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
});
expect(slots).toHaveLength(5);
expect(findNearestPartyNavigationNode(graph, stockades.entrance.footPosition)?.distance)
.toBeLessThanOrEqual(6);
for (const slot of slots) {
expect(findNearestPartyNavigationSurface(graph, slot.footPosition, 6)).not.toBeNull();
}
});
it("routes from the shipping entrance to every authored boss objective", () => {
const entrance = findNearestPartyNavigationSurface(
graph,
stockades.entrance.footPosition,
6,
);
expect(entrance).not.toBeNull();
for (const objective of stockades.objectiveOrder) {
const approach = findNearestPartyNavigationSurface(graph, objective.position, 15);
expect(approach, objective.name).not.toBeNull();
expect(findPartyNavigationPath(
graph,
entrance!.position,
approach!.position,
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1 },
), objective.name).not.toBeNull();
}
});
it("gives the tank a usable route into melee range of the first enemy", () => {
const tankSlot = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
}, 2)[1];
const tankStart = findNearestPartyNavigationSurface(graph, tankSlot.footPosition, 6);
expect(tankStart).not.toBeNull();
const firstEnemy = [...stockades.staticSpawns].sort((left, right) => (
distance(left.position, stockades.entrance.footPosition)
- distance(right.position, stockades.entrance.footPosition)
))[0];
const desired: [number, number, number] = [0, 0, 0];
expect(partyCombatMovementGoal(
tankStart!.position,
firstEnemy.position,
true,
TANK_RANGE.minimum,
TANK_RANGE.preferred,
TANK_RANGE.maximum,
desired,
)).toBe("approach");
const route = planPartyRoute(
graph,
`tank:${firstEnemy.id}`,
tankStart!.position,
desired,
0,
{
maxOriginSnapDistance: 3,
maxDestinationSnapDistance: 5,
localFinishDistance: TANK_RANGE.maximum,
directSegmentAllowed: () => true,
},
);
expect(route.blocked).toBe(false);
expect(route.waypoints.length).toBeGreaterThan(1);
let current: [number, number, number] = [...tankStart!.position];
const next: [number, number, number] = [0, 0, 0];
for (const waypoint of route.waypoints) {
while (distance(current, waypoint) > 0.01) {
stepPartyPosition(current, waypoint, 0.5, next);
current = [...next];
}
}
expect(distance(current, firstEnemy.position)).toBeLessThanOrEqual(TANK_RANGE.maximum);
expect(partyCombatMovementGoal(
current,
firstEnemy.position,
true,
TANK_RANGE.minimum,
TANK_RANGE.preferred,
TANK_RANGE.maximum,
desired,
)).toBe("hold");
});
});
+156 -25
View File
@@ -92,7 +92,8 @@ const PARTY_COLLISION_RADIUS = 0.34;
const PARTY_COLLISION_HALF_HEIGHT = 0.48;
const PARTY_COLLISION_CENTER_HEIGHT = PARTY_COLLISION_RADIUS + PARTY_COLLISION_HALF_HEIGHT + 0.04;
const PARTY_WALKABLE_STEP_HEIGHT = 0.32;
const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 6;
const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
const PARTY_FLOOR_SAMPLE_SPACING = 0.6;
const PARTY_FLOOR_RAY_HEIGHT = 1.25;
const PARTY_FLOOR_RAY_DEPTH = 2;
@@ -394,6 +395,12 @@ function PartyActors({
const hit = world.castRay(ray, distance, true, queryFlags);
return !hit || hit.timeOfImpact >= distance - 0.04;
}, [rapier, world]);
// Authored-link graphs use their eroded Recast node edges directly. Their
// boundary vertices are already agent-radius safe, while a center ray can
// falsely read the stair floor as a wall across a short vertical seam.
const navigationRouteSegmentAllowed = navigationGraph?.triangles?.length
? routeSurfaceSegmentAllowed
: undefined;
const actorRefs = useRef<Array<Group | null>>([]);
const bodyRefs = useRef<Array<Group | null>>([]);
const positionsRef = useRef(new Map<string, MutablePartyWorldPosition>());
@@ -472,14 +479,22 @@ function PartyActors({
}), [hasStaticLineOfSight, memberIdKey, rapier, world]);
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
const party = usePartyStore.getState();
const navigationRecall = navigationGraphRef.current
? findNearestPartyNavigationSurface(
navigationGraphRef.current,
player,
PARTY_NAVIGATION_SNAP_DISTANCE,
)
: null;
const recallPosition = navigationRecall?.position ?? player;
party.members.forEach((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
// The player's exact point is the only location guaranteed to be on the
// same side of a shortcut, doorway, or ledge. The breadcrumb system
// spreads the party back into a trail once the player moves.
position[0] = player[0];
position[1] = player[1];
position[2] = player[2];
// Attach a recalled squad to the reviewed surface just as initial
// spawns do. An exact visual-floor point can sit between Recast stair
// vertices and leave the first route connector falsely blocked.
position[0] = recallPosition[0];
position[1] = recallPosition[1];
position[2] = recallPosition[2];
positionsRef.current.set(member.id, position);
updatePartyRuntimePosition(member.id, position[0], position[1], position[2]);
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
@@ -495,7 +510,7 @@ function PartyActors({
const firstLiving = party.members.find((member) => member.health > 0);
resetPartyBreadcrumbTrail(playerTrailRef.current, player);
leaderPlayerCursorRef.current.breadcrumbId = null;
resetPartyBreadcrumbTrail(leaderTrailRef.current, player);
resetPartyBreadcrumbTrail(leaderTrailRef.current, recallPosition);
trailLeaderIdRef.current = firstLiving?.id ?? null;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
@@ -756,12 +771,14 @@ function PartyActors({
const leader = leaderMember;
let goalKey: string;
let hasDestination = true;
let leaderLocalFinishDistance = 3;
if (targetPosition && leaderTargetId) {
const profile = movementProfileFor(leader.id, leaderIndex);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(leader),
profile,
);
leaderLocalFinishDistance = Math.max(leaderLocalFinishDistance, rangeBand.maximum);
let combatAnchor = combatAnchorsRef.current.get(leader.id);
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
combatAnchor = {
@@ -817,7 +834,6 @@ function PartyActors({
&& fullDistance(leaderPosition, leaderDestination) > 0.18;
if (leaderFollowingPlayer) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const cursor = leaderPlayerCursorRef.current;
const profile = movementProfileFor(leader.id, leaderIndex);
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
@@ -835,6 +851,7 @@ function PartyActors({
leaderDestination,
);
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
leaderHasWaypoint = partyShouldMoveToComfortTarget(
leaderPosition,
@@ -877,9 +894,63 @@ function PartyActors({
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
}
if (!leaderHasWaypoint && navigationGraph) {
const navigationFollowDestination = findNearestPartyNavigationSurface(
navigationGraph,
leaderDestination,
PARTY_NAVIGATION_SNAP_DISTANCE,
)?.position ?? leaderDestination;
if (partyRouteNeedsPlanning(
routeRef.current,
goalKey,
leaderPosition,
navigationFollowDestination,
routeNow,
{
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
},
)) {
routeRef.current = planPartyRoute(
navigationGraph,
goalKey,
leaderPosition,
navigationFollowDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
}
leaderHasWaypoint = nextPartyRouteWaypoint(
routeRef.current,
leaderPosition,
desired,
);
if (!leaderHasWaypoint && routeRef.current.blocked) {
leaderHasWaypoint = nextPartyRouteRecoveryWaypoint(
routeRef.current,
leaderPosition,
desired,
directSegmentAllowed,
);
}
leaderPlayerTrailBlocked = !leaderHasWaypoint && routeRef.current.blocked;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack;
|| (
(!navigationGraph || leaderPlayerTrailBlocked)
&& planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack
);
}
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
if (
@@ -902,11 +973,11 @@ function PartyActors({
leaderDestination,
routeNow,
{
maxOriginSnapDistance: 3,
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed,
routeSegmentAllowed: routeSurfaceSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
}
@@ -1082,14 +1153,14 @@ function PartyActors({
desired,
routeNow,
{
maxOriginSnapDistance: 3,
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
localFinishDistance: Math.max(3, rangeBand.maximum),
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: routeSurfaceSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
combatRoutesRef.current.set(member.id, route);
@@ -1112,7 +1183,6 @@ function PartyActors({
}
}
} else {
combatRoutesRef.current.delete(member.id);
let cursor = followerCursorsRef.current.get(member.id);
if (!cursor) {
cursor = createPartyBreadcrumbCursor();
@@ -1137,6 +1207,7 @@ function PartyActors({
naturalFollowTarget,
);
if (directSegmentAllowed(current, naturalFollowTarget)) {
combatRoutesRef.current.delete(member.id);
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
hasDesired = partyShouldMoveToComfortTarget(
current,
@@ -1158,6 +1229,7 @@ function PartyActors({
movementExpected = hasDesired;
} else {
directFollowMovingRef.current.set(member.id, false);
let followerTrailBlocked = false;
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
@@ -1177,11 +1249,68 @@ function PartyActors({
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
followerTrailBlocked = true;
}
if (!hasDesired && navigationGraph) {
const goalKey = `member:${member.id}|leader-follow`;
const navigationFollowDestination = findNearestPartyNavigationSurface(
navigationGraph,
naturalFollowTarget,
PARTY_NAVIGATION_SNAP_DISTANCE,
)?.position ?? naturalFollowTarget;
let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState();
if (partyRouteNeedsPlanning(
route,
goalKey,
current,
navigationFollowDestination,
routeNow,
{
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
},
)) {
route = planPartyRoute(
navigationGraph,
goalKey,
current,
navigationFollowDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
combatRoutesRef.current.set(member.id, route);
}
hasDesired = nextPartyRouteWaypoint(route, current, desired);
const routePrefixRecovery = !hasDesired && route.blocked
? nextPartyRouteRecoveryWaypoint(
route,
current,
desired,
directSegmentAllowed,
)
: false;
if (routePrefixRecovery) hasDesired = true;
followerTrailBlocked = !hasDesired && route.blocked;
} else if (hasDesired) {
combatRoutesRef.current.delete(member.id);
}
if (followerTrailBlocked) frameRouteBlocked = true;
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack;
|| (
(!navigationGraph || followerTrailBlocked)
&& planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack
);
}
}
if (memberTargetPosition) {
@@ -1382,6 +1511,8 @@ export function PartyPopulation({
const wailingPhase = useWailingEncounterStore((state) => state.phase);
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const dungeon = requireDungeonDefinition(activeDungeonId);
const navigationReviewed = dungeon.validation.status === "green"
|| dungeon.validation.navigation === "green";
const manastormPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
@@ -1393,9 +1524,9 @@ export function PartyPopulation({
}]
// Generated packages preserve complete objective metadata, but their
// fragmented Recast islands do not claim autonomous route fidelity. Until
// reviewed off-mesh links make the pack green, the player leads and the
// party follows/engages locally instead of displaying a false blocked route.
: dungeon.validation.status === "green"
// reviewed links make navigation green, the player leads and the party
// follows/engages locally instead of displaying a false blocked route.
: navigationReviewed
? activeDungeonId === "wailing-caverns"
&& wailingPhase !== "mutanus"
&& wailingPhase !== "complete"
@@ -1450,7 +1581,7 @@ export function PartyPopulation({
navigationGraph={navigationGraph}
navigationFailed={navigationFailed || !navigationEnabled}
objectives={partyObjectivesForNavigation(Boolean(navigationGraph), objectives)}
stackAtEntrance={gameMode === "manastorm" || dungeon.validation.status !== "green"}
stackAtEntrance={gameMode === "manastorm" || !navigationReviewed}
/>
{navigationEnabled && assets.navigation ? (
<NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}>