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

+52
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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`,
+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 "H4sIAAAAAAACCuy9a4/juLUo+lcMnwOcbMBmcy2+Gzgf5rGzk3szO3On90lwETQ2KImqctpl1ZVd3dMzmP9+sUjJli25LJc93dWVzodJl2iJ5OJ6v/jrdJ3fhjv/t1CvF9Vq+hpm03X1UOdh+vrXae7v7v3iZvWDXy3KsN5MX0+Lh9VNqFbz+8V9WC5W4VX7oP0t++e6Wk1n07B6v6ir1V1YbX70+Tt/E9bT1/+Yruv81Y2/C69uwirUfhOKV+Xiffhx6T+G+vv0rT/f3Vf1Zt1+aeCVO7/y601V332zXoft9+MLkz9sAr3v64+T0i+Xmc/f/dv0LS0orx5Wm1B/5zd+Wd1MXz/65Z8eVpvFXWh+3K7F/xLqanP7XVWH76q7uwWBRBaugEJmyqAIFlyucuF4qXMPYAxaUWRlZoPYf/1NhPJfqvzd8EIGf7oFyX1YLrf7+HXq13lYpQOcMvbqL75YvFv/8OP/8+/FYlPVr/LlIqw28/yhrsNq88rXwc8Vvrr3m/x2/v2r77/942IZ1t/FX716Q99mRZbTId5X+W365p+CX4b6B7+av9lUtb8Jr1oUmH+o6nctHqxfxXfmacqjn/7tt9k0r94H+tD09T9+bfHqzwVB49YX1YfSr27m70K4n86md/6eRoToHON6+trQn5vFZkG4JQh1N36zyN/c+w+r9fQ1cDeb1pW/W6xuCEfW09cod/jdnkZe1WH62+xgDYQDHxarYr7eVPk7X4TOMuT+MlR3GdBbhhOHq+AjF1EEX9wtVmHdmVs/AgLEHghMHwRi5Owf/GK5WN3Mc/8+1KvOGuTBMdi9NfDDNaDCwzWYkUuo/S9VXYbV/F3tH5adFZjHEMGOgALgyCVkS5+/K/3mtrqbFyHcd+Fg91eh9+BgenDg0FvFWEx4WBb+zq92cxt+FgQExydD4GZV3YU63HSndwfTy8cpUZje1gWMpcSH1buwmhNXX3aIMBL38SWIHvSlOlyCPBcLi+pDlw4A3WMIoHpo6GTvEMYS4zr39TJs5ncVyadQf+wsw7pH+JHo4aEgrDtYhh65jF8elqWva/9uNz3yx6ZHGMEOQJ5DjHWVv5uv7xd1Bxvw8ChgbxWmRxBK6x5jxLNXUYT7zW0HI1Ac0AWI7jL0gIiS/cNQo3HitlpWd36VdyFxiA32BGdA/WRsWG9qYozLu65wPDwJ151f9aSDFD3WQF8YNf+dr/1DUa26ovnR3fe4gsIeV0A+mi3chHJRh3l+69d3nTXYxzhTXz8AEhVP40wFTX+3Lxp7lIB7lNDjSkb1EECNPYD1rd9sQh2K+a1fLjuEoOQBIezRgejrKc4+mTVmy6oq5uVDvfJdQlASH1kC9NgiCPfkJdyG5TKiQk1WUL3Zg8SBtrR3HNCDhFVPFtTrTfB37/3DctOdXp0HhT4ywlht7WFVhDqrbrqz60dIYUBd1fBkdXW99O/D/D6s9oBvHtk9DpBiTzaJsdD3de43tf+lM7vCx3bvTh/9WDrMqo1fLXLfnVs8JpVdH/LuyXLoLuS3fuXr7uzyLKyz8skqQTIW50uffawXq81tdxHqPOSzT2cA67C5De/e9dig0o8Rvx5QB55ssZLnYr6p7rK9+R/D/wjj/fnh6VjgH/LbsPKLeV5/vN/jgMo+BgR52l4WZjQMbhakHa/nm1DX++LAPsoI+zZj316w5+iH87uq9uuubqjdIwsYEEdP1grCOvf3YV7WZLI+1Kvbatl1Xyj9mGzuswbRt57FWFA8bG58XYQDP44yj7GHAcoEfLpQalZwv1itfN61IJV5jD+MYVJjD2RzG+ar8PNDlyiMfsx27HEGFE9nkTR9lT8s9+e3j21+zBGMFYz5wzL6kapyPmQwKPcom+67k+DJ/rTIndM6qtXeEtxZmGDMk7WUovbvNre+rlYHNKE5P0taOfdks83/8s9qOV+F+iHrzg+P8cee5WT4U/FhewrLxc3tZrVY3XRXgWchwxBajnUs3Tysij1PhuaPOXb7k1vzZH/e+0VF3hzizN357SNH0DPc3FPJwN+u3vnbsJlXy2L+brG6KaqO+arhUX9OX2cQ4sk6Q1nVNyESZPXQ1Zu0wMdUhh4ySniqrN7UC7+kFRCTzG/93f2i60/Qij8CjN6RqKceyf1iE8FAjKE7+2MIIfrUoO2TjcctVdahXIZ8sw8G/agK11/Ik1n0ZrF6F+pNyG/n69vqA3lcd8uweMCjoLuMPmbqx1ZxX1d5KB5qv5y3ccI+ftbVL2E1r4PPb7vOb/uJl1HVN9VmE1ZzigZ1PL/G/t4AeTubFqFcrBaEEOsYqutHjBfDUbvNYrOkD7/Zjkz+7148b1netGHfgr5vKVT7871vw5n50q/Xi3w/ovxDVUREKjbz249ZvSAmWizKcpE/LDcf/+brhV9t0mrj2lZVfeeXez/qPl0TfH7w63dxN8vwPix/8qsYmQQ7E/xtAwZyrXy//cKf977s3/vF0meL5SJ+u/kz6pv3dViHFZ0E7ejX6bLKm38PgGZzG+5ob3+9DyuKrC6LSfj5PhSLhiLXD3d3vqYp/kjic7K5rauHm9vJwZdmkyKUwW8mi816ssWQSV2ReTSb+FUxoVOabG7DhDxWHyd+uXgf2HQ2XVa+WKxufgjrdYzOTn+sw72vF6ubw0kYo98/rPJbX29C8U0d/H/6uPr/0z6b9HdIiHVTVw8rgt//4JYXnI63TKHsvFpWdRzwgMCns+kq+DoFPkv6B2r+22zq7zIKKf+FYLD3mreZdKTXLFYbionTaXCG+NtsehvuFuv721CH7Wvrdx+/a9+0xuWWXChpcdvn3PGMu8MvavnbLPpfI6v0y/5KCsyNK/bfg9m0Kst12BBq4Qz5bG7e/kYEsQkkidbxCw+b6mHzY3StRpqb/ve38D8phyGNEIzp8dvZNFuGVbH/S4y/jAOdH/qCMOh92P+tjL9tx7Y/jwu6p6V8WBSbW3LScwJf2qIgTfWeXlrdJIUgr1ab6qGOH/0B5QS5mnxn9QTATkDiBKWZILcTMDB5I4ScGJhIrieAejqb/iDsBIWbfAfcTcCKCRicoLYTlDy+9kZIOXF6IpyZGDN9S8kDnhI/IrTeL9YPfrmj9ebv2bRcLEODjukZu1kSYjzUy+nr6av0hZR2sc36eCXEfMfIXu29l9+G/N364W76eipyGbBwwdlAyR3gKcsjCAhCATqruQreyqywQiCCy7nnRngP0mau9MYQHS9+CaQpOWuklrOoiKxuluE7otTpa+mQAye+k1fL5SIxw3aL20dzzvn+TveGzt/w0OudfXNX5jrnPKAHV5bS5lBkssisC1lwHJTgWeEsGCyFM5mWAVxhhOeKl4XRvLNvkE73dq2VEpqkX2+bcHybcNk2ob9NrcEaUN4LX1ivMXPGKu4AvNGgnVKaG2G5KFBYVKIE4FmOOULJscid6m5Tk7dl9Dbx+Dbxsm1if5tGgUJZKpuB0T5AlqmsQF14o0whcsMLNEpkhbHG5k4p7mXmlVdlHgRXPutsEznaM7Ypjm9TXLZN0d9mJsBrWaJElfMiV6o0ACgRURjuSlcUygfvFC+5Vl4qE7zNS+Amc2XIfdg7TXvONuXxbcrLtin724QMZOmdzNDmRpaW80Bb4SittKXy0lgpuOG+zPIicy7L8hDKTOfO5hkKtU+beMY21fFtqsu2qQaQFpyTmbdG+TIzOaLFEnMNPlPSCu6Aq4DWe+UKmxVZwKyEIs+ULr3PIbjONqXT55ymPr5Nfdk2dX+bWJZQYm6wENwUWgPXKgvelCXPUQlaPAAE5EE6DZmETAgsLTcoM+1AdraprDxnm+b4Ns1l2zT9bRaZ8oZz9LkXufMebAmGB82DcKVGkUsLKuTaBa1RBlVKNCHPXFEI4VxmO9vUkssztmmPb9Netk07IFAKnxVaCMkLjdpmWns03vtgkHOlsjJAhkUI2jsFGCTm3OoSS1FaLZ3tblNwdQ5tuuPbdJdt0/W3aRW3PGAJJs+1JR3IB5uXhbA+iCwPeWaUEgGkM0qHkgfFpXXWOyOF8WVXoCiFZ8hNOK4FwWVaEAxoQY5jJmwRUIosK0XwZSiCdLbISjRlxrWAQoLjRSlMiZBzzgsJJakGCJnZbVM4ZXmfNo0Wzg1u87gWBJdpQTCkBaHNhc2UlbnOC+sxoEUHzikbrJHgeakLlZU+2KClNFzkmrKfCx5CkF2B4iycowXBcS0ILtOCYEALUrl2xpGx7DKtc2O4hZCZYHTAMpOgMcgAWvKAvOSCo8CAucw8d1KbjkBBEGKI02pj5eA2j2tBcJkWBANakIM8mDx3pjAKtcqQF+C0UForgMJx66xCB17kuS8LG6RRmpc5z7UIvBQdFiQFSOjr7sS5ho/zuBoEl6lBMKAGadJns0ySgiB0UWYotNZ54b0VpeVgtQWvnRXoCpV7DNpJoX2pseTWGb87TjRigDi1EVYNbvO4GgSXqUEwoAbxTFjMBNkjGq0PGc+l1zkXXkmwPuQYhJGYhaLAzGLgeeZK4QuT+ZKXpqsGgTNnSBQ4rgbBZWoQDKhBNoScFy7wkivntEGFmRUyd1qVgtQGFTLIeJlpmwWXo0enkloolQOx26bQQg9s00jHh4nzuBoEl6lBMKAGKZtra4MSFjKvcgyWNsqt8oU2eZAWZeEEYl56JaXmuVYQgpNSYgbS4A5ptZVDrNZaHN7mcTUILlODYEANMrawVntXWAcKDc8zCTJILnymRG69BOS5y0pTostRSe2dVhiw0ELlUIrtNhXnTva1PWcAhwXncTUILlODYEANEkVhAXJZaCkcCc3MWJHlucoK44A7lAK48Ca3RWE9+VGyQvmiLDDnpnQ775CwWgycptFciKFt4nE1CC9Tg3BADZIZ94VXTnEUkqSL0MYbJUqfq0Ipb431DkPBhXK6tDqThZAKClNmJVreMVGMdfIM/QCPq0F4mRqEA2pQ4V2JZZGDcVmQJfnxrJClLzSCFFILine 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