2 Commits
Author SHA1 Message Date
phenom c245a199b6 Release Healer Man 0.1.8 2026-08-27 13:29:58 -04:00
phenom eca6b5db9b Release Healer Man 0.1.7 2026-08-22 15:53:43 -04:00
182 changed files with 10173 additions and 375 deletions
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/TempestKeepArcane",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/AuchindounDraenei",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "burning-crusade",
"extraction": {
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/Blackfathom",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [20, 30]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/BlackrockDepths",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [50, 60]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/BlackRockSpire",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [55, 60]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/HellfireDemon",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/TempestKeepAtrium",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
+2
View File
@@ -8,6 +8,8 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/DireMaul",
"environmentMode": "global-wmo",
"coordinateAlignment": "source-transform",
"coordinateTransform": {"origin": [0, 0, 0], "yawDegrees": 180},
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [50, 60]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "burning-crusade",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/GnomeragonInstance",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [25, 40]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+1
View File
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -8,6 +8,8 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/HellfireRampart",
"environmentMode": "global-wmo",
"coordinateAlignment": "source-transform",
"coordinateTransform": {"origin": [1262.4427, -60.8902, 1622.5397], "yawDegrees": 262.143},
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [58, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/Sunwell5ManFix",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [70, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/AuchindounEthereal",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/Mauradon",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [40, 55]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/TempestKeepFactory",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/OrgrimmarInstance",
"environmentMode": "global-wmo",
"globalWmoAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [13, 25]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,8 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/RazorfenDowns",
"environmentMode": "global-wmo",
"coordinateAlignment": "source-transform",
"coordinateTransform": {"origin": [-2402.2809, 62, 1208.3219], "yawDegrees": 0},
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [35, 50]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+2 -1
View File
@@ -8,7 +8,8 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/RazorfenKraulInstance",
"environmentMode": "global-wmo",
"globalWmoSourceBasis": "flip-x",
"coordinateAlignment": "source-transform",
"coordinateTransform": {"origin": [-1952.1803, 87.684, 1587.8923], "yawDegrees": -133},
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [25, 40]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,8 @@
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/MonasteryInstances",
"environmentMode": "detect",
"coordinateAlignment": "source",
"adtSourceBasis": "z-y-negative-x",
"difficultyVariants": [
{ "id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [26, 45] }
],
@@ -8,6 +8,8 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/SchoolofNecromancy",
"environmentMode": "global-wmo",
"coordinateAlignment": "source-transform",
"coordinateTransform": {"origin": [-141.694, 148.0683, 105.6784], "yawDegrees": 1},
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [55, 60]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/AuchindounDemon",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/AuchindounShadow",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [65, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -7,12 +7,14 @@
"expansion": "classic",
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/Shadowfang",
"environmentMode": "global-wmo",
"environmentMode": "detect",
"coordinateAlignment": "source",
"adtSourceBasis": "z-y-negative-x",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [18, 30]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/LD_ShadowFang/LD_ShadowFang.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"extraction": {"adtTiles": "all-present"},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "tileSize": 256, "retainTileIntermediates": false, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "required", "optionalEncounters": "required", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/HellfireMilitary",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/CoilfangDraenei",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/CoilfangPumping",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+2
View File
@@ -8,6 +8,8 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/Stratholme",
"environmentMode": "global-wmo",
"coordinateAlignment": "source-transform",
"coordinateTransform": {"origin": [-3449.6608, 144.3848, -3379.6966], "yawDegrees": 0},
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [55, 60]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/SunkenTemple",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [45, 55]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
+1
View File
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
+1
View File
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/Uldaman",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [
{
"id": "normal",
+1
View File
@@ -8,6 +8,7 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/CoilfangMarsh",
"environmentMode": "global-wmo",
"coordinateAlignment": "source",
"difficultyVariants": [{"id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [60, 70]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
@@ -27,6 +27,7 @@
}
],
"environmentMode": "detect",
"coordinateAlignment": "source",
"exceptions": [],
"expansion": "wrath",
"extraction": {
+2
View File
@@ -8,6 +8,8 @@
"clientBuild": "3.3.5a-Ascension",
"mapDirectory": "World/Maps/TanarisInstance",
"environmentMode": "detect",
"coordinateAlignment": "source",
"adtSourceBasis": "z-y-negative-x",
"difficultyVariants": [
{ "id": "normal", "difficulty": "normal", "spawnMask": 1, "levelRange": [40, 54] }
],
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.6",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.6",
"version": "0.1.8",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+5 -3
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.6",
"version": "0.1.8",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -20,7 +20,8 @@
"coa:sync:live": "node scripts/sync-ascension-coa.mjs --tooltips",
"coa:icons": "node scripts/export-spell-icons.mjs",
"ascension:talents": "node scripts/generate-wow335-talents.mjs ../wow335a/Interface/AddOns/PlayerBotManager/PBM/PBM_TalentData.lua ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc src/game/wow335Talents.generated.ts ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Talent.dbc",
"ascension:sync": "npm run coa:sync && npm run wow335:abilities && npm run ascension:talents && npm run coa:icons",
"catalogs:shard": "vite-node scripts/generate-catalog-shards.ts",
"ascension:sync": "npm run coa:sync && npm run wow335:abilities && npm run ascension:talents && npm run coa:icons && npm run catalogs:shard",
"manastorm:import": "node scripts/manastorm-import/cli.mjs",
"manastorm:forensics": "node scripts/manastorm-import/forensics-cli.mjs",
"manastorm:sync": "npm run manastorm:import && npm run manastorm:forensics && npm run coa:icons",
@@ -91,7 +92,8 @@
"assets:ktx2:creatures": "node scripts/asset-pipeline/compress-creature-ktx2.mjs",
"assets:ktx2:audit": "node scripts/asset-pipeline/audit-ktx2.mjs --visual-only --require-all",
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit",
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
"android:sync": "npm run build:android:web && cap sync android",
@@ -13,8 +13,8 @@
{
"id": "visual",
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
"byteLength": 10266900,
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
"byteLength": 7068884,
"triangleCount": 248302,
"compression": "meshopt+ktx2"
}
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,8 @@
{
"id": "visual",
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
"byteLength": 10266900,
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
"byteLength": 7068884,
"triangleCount": 248302,
"compression": "meshopt+ktx2"
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.6",
"versionCode": 1006
"versionName": "0.1.8",
"versionCode": 1008
}
@@ -0,0 +1,121 @@
#!/usr/bin/env node
import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { analyzeGlbFile } from "./glb-performance.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const baselineFile = path.join(
projectRoot,
"scripts/asset-pipeline/environment-performance-baseline.json",
);
const defaultRoots = [
path.join(projectRoot, "public/assets/game/manastorm"),
path.join(projectRoot, "src/assets/game/dungeons"),
];
const defaultThresholds = {
maxEstimatedDrawCalls: 1_500,
maxUnbatchedDrawCallSavings: 250,
};
async function exists(file) {
try {
await access(file);
return true;
} catch {
return false;
}
}
async function collectVisualGlbs(root, files = []) {
if (!await exists(root)) return files;
const entry = await stat(root);
if (entry.isFile()) {
if (/(?:^|[-_])visual\.glb$/i.test(path.basename(root))) files.push(root);
return files;
}
for (const child of await readdir(root, { withFileTypes: true })) {
const target = path.join(root, child.name);
if (child.isDirectory()) await collectVisualGlbs(target, files);
else if (/(?:^|[-_])visual\.glb$/i.test(child.name)) files.push(target);
}
return files;
}
function projectPath(file) {
return path.relative(projectRoot, file).split(path.sep).join("/");
}
function exceeds(report, limits) {
return report.estimatedDrawCalls > limits.maxEstimatedDrawCalls
|| report.unbatchedDrawCallSavings > limits.maxUnbatchedDrawCallSavings;
}
const args = process.argv.slice(2);
const writeBaseline = args.includes("--write-baseline");
const roots = args.filter((argument) => argument !== "--write-baseline")
.map((argument) => path.resolve(projectRoot, argument));
const files = [...new Set((await Promise.all(
(roots.length ? roots : defaultRoots).map((root) => collectVisualGlbs(root)),
)).flat())].sort();
const reports = await Promise.all(files.map(async (file) => ({
file: projectPath(file),
byteLength: (await stat(file)).size,
...(await analyzeGlbFile(file)),
})));
if (writeBaseline) {
const baseline = {
schemaVersion: 1,
thresholds: defaultThresholds,
exceptions: Object.fromEntries(
reports
.filter((report) => exceeds(report, defaultThresholds))
.map((report) => [report.file, {
maxEstimatedDrawCalls: Math.max(defaultThresholds.maxEstimatedDrawCalls, report.estimatedDrawCalls),
maxUnbatchedDrawCallSavings: Math.max(
defaultThresholds.maxUnbatchedDrawCallSavings,
report.unbatchedDrawCallSavings,
),
}]),
),
};
await mkdir(path.dirname(baselineFile), { recursive: true });
await writeFile(baselineFile, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
console.log(`Wrote ${Object.keys(baseline.exceptions).length} performance exceptions to ${projectPath(baselineFile)}.`);
process.exit(0);
}
if (!await exists(baselineFile)) {
throw new Error(`Missing ${projectPath(baselineFile)}. Run this script with --write-baseline.`);
}
const baseline = JSON.parse(await readFile(baselineFile, "utf8"));
const thresholds = { ...defaultThresholds, ...baseline.thresholds };
const regressions = reports.filter((report) => {
const limits = { ...thresholds, ...baseline.exceptions?.[report.file] };
return exceeds(report, limits);
});
const debt = reports.filter((report) => exceeds(report, thresholds));
const top = [...reports]
.sort((left, right) => right.estimatedDrawCalls - left.estimatedDrawCalls)
.slice(0, 12)
.map((report) => ({
asset: report.file,
calls: report.estimatedDrawCalls,
avoidable: report.unbatchedDrawCallSavings,
gpuBatches: report.gpuInstanceBatches,
nodes: report.nodes,
}));
console.table(top);
console.log(JSON.stringify({
status: regressions.length ? "blocked" : "green",
assets: reports.length,
knownBudgetExceptions: debt.length,
regressions: regressions.map((report) => ({
file: report.file,
estimatedDrawCalls: report.estimatedDrawCalls,
unbatchedDrawCallSavings: report.unbatchedDrawCallSavings,
})),
}, null, 2));
if (regressions.length) process.exitCode = 1;
@@ -0,0 +1,249 @@
{
"schemaVersion": 1,
"thresholds": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 250
},
"exceptions": {
"public/assets/game/manastorm/109-sunkentemple/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 320
},
"public/assets/game/manastorm/129-razorfendowns/visual.glb": {
"maxEstimatedDrawCalls": 1507,
"maxUnbatchedDrawCallSavings": 1406
},
"public/assets/game/manastorm/1771-custom-marsh/visual.glb": {
"maxEstimatedDrawCalls": 2493,
"maxUnbatchedDrawCallSavings": 2114
},
"public/assets/game/manastorm/1774-custom-tropical/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 910
},
"public/assets/game/manastorm/1775-custom-tuskarr/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 500
},
"public/assets/game/manastorm/1776-custom-vrykul1/visual.glb": {
"maxEstimatedDrawCalls": 2223,
"maxUnbatchedDrawCallSavings": 1818
},
"public/assets/game/manastorm/1781-forgottenmine/visual.glb": {
"maxEstimatedDrawCalls": 5756,
"maxUnbatchedDrawCallSavings": 3818
},
"public/assets/game/manastorm/1785-valsharaharena/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 392
},
"public/assets/game/manastorm/1787-baradinholdarena/visual.glb": {
"maxEstimatedDrawCalls": 10462,
"maxUnbatchedDrawCallSavings": 9362
},
"public/assets/game/manastorm/1794-twistingnether/visual.glb": {
"maxEstimatedDrawCalls": 3077,
"maxUnbatchedDrawCallSavings": 2557
},
"public/assets/game/manastorm/209-tanarisinstance/visual.glb": {
"maxEstimatedDrawCalls": 4579,
"maxUnbatchedDrawCallSavings": 3547
},
"public/assets/game/manastorm/229-blackrockspire/visual.glb": {
"maxEstimatedDrawCalls": 3972,
"maxUnbatchedDrawCallSavings": 3598
},
"public/assets/game/manastorm/269-cavernsoftime/visual.glb": {
"maxEstimatedDrawCalls": 5446,
"maxUnbatchedDrawCallSavings": 5289
},
"public/assets/game/manastorm/289-schoolofnecromancy/visual.glb": {
"maxEstimatedDrawCalls": 5140,
"maxUnbatchedDrawCallSavings": 4790
},
"public/assets/game/manastorm/309-zul-gurub/visual.glb": {
"maxEstimatedDrawCalls": 17377,
"maxUnbatchedDrawCallSavings": 15605
},
"public/assets/game/manastorm/329-stratholme/visual.glb": {
"maxEstimatedDrawCalls": 2954,
"maxUnbatchedDrawCallSavings": 2725
},
"public/assets/game/manastorm/34-stormwindjail/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 487
},
"public/assets/game/manastorm/349-mauradon/visual.glb": {
"maxEstimatedDrawCalls": 5624,
"maxUnbatchedDrawCallSavings": 5352
},
"public/assets/game/manastorm/36-deadminesinstance/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 309
},
"public/assets/game/manastorm/389-orgrimmarinstance/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 881
},
"public/assets/game/manastorm/429-diremaul/visual.glb": {
"maxEstimatedDrawCalls": 5021,
"maxUnbatchedDrawCallSavings": 4636
},
"public/assets/game/manastorm/469-blackwinglair/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 452
},
"public/assets/game/manastorm/47-razorfenkraulinstance/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 897
},
"public/assets/game/manastorm/48-blackfathom/visual.glb": {
"maxEstimatedDrawCalls": 2164,
"maxUnbatchedDrawCallSavings": 1950
},
"public/assets/game/manastorm/509-ahnqiraj/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 600
},
"public/assets/game/manastorm/531-ahnqirajtemple/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 953
},
"public/assets/game/manastorm/532-karazahn/visual.glb": {
"maxEstimatedDrawCalls": 8540,
"maxUnbatchedDrawCallSavings": 7892
},
"public/assets/game/manastorm/533-stratholme-raid/visual.glb": {
"maxEstimatedDrawCalls": 4165,
"maxUnbatchedDrawCallSavings": 4014
},
"public/assets/game/manastorm/540-hellfiremilitary/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 1150
},
"public/assets/game/manastorm/542-hellfiredemon/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 444
},
"public/assets/game/manastorm/543-hellfirerampart/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 296
},
"public/assets/game/manastorm/545-coilfangpumping/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 674
},
"public/assets/game/manastorm/546-coilfangmarsh/visual.glb": {
"maxEstimatedDrawCalls": 3104,
"maxUnbatchedDrawCallSavings": 3033
},
"public/assets/game/manastorm/547-coilfangdraenei/visual.glb": {
"maxEstimatedDrawCalls": 1535,
"maxUnbatchedDrawCallSavings": 1455
},
"public/assets/game/manastorm/548-coilfangraid/visual.glb": {
"maxEstimatedDrawCalls": 1916,
"maxUnbatchedDrawCallSavings": 1683
},
"public/assets/game/manastorm/550-tempestkeepraid/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 420
},
"public/assets/game/manastorm/552-tempestkeeparcane/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 999
},
"public/assets/game/manastorm/553-tempestkeepatrium/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 1115
},
"public/assets/game/manastorm/554-tempestkeepfactory/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 872
},
"public/assets/game/manastorm/555-auchindounshadow/visual.glb": {
"maxEstimatedDrawCalls": 2624,
"maxUnbatchedDrawCallSavings": 2516
},
"public/assets/game/manastorm/556-auchindoundemon/visual.glb": {
"maxEstimatedDrawCalls": 2095,
"maxUnbatchedDrawCallSavings": 1987
},
"public/assets/game/manastorm/557-auchindounethereal/visual.glb": {
"maxEstimatedDrawCalls": 3164,
"maxUnbatchedDrawCallSavings": 3026
},
"public/assets/game/manastorm/558-auchindoundraenei/visual.glb": {
"maxEstimatedDrawCalls": 2613,
"maxUnbatchedDrawCallSavings": 2523
},
"public/assets/game/manastorm/560-hillsbradpast/visual.glb": {
"maxEstimatedDrawCalls": 28259,
"maxUnbatchedDrawCallSavings": 24733
},
"public/assets/game/manastorm/568-zulaman/visual.glb": {
"maxEstimatedDrawCalls": 4307,
"maxUnbatchedDrawCallSavings": 3850
},
"public/assets/game/manastorm/574-valgarde70/visual.glb": {
"maxEstimatedDrawCalls": 8271,
"maxUnbatchedDrawCallSavings": 7072
},
"public/assets/game/manastorm/575-utgardepinnacle/visual.glb": {
"maxEstimatedDrawCalls": 16277,
"maxUnbatchedDrawCallSavings": 14018
},
"public/assets/game/manastorm/576-nexus70/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 396
},
"public/assets/game/manastorm/578-nexus80/visual.glb": {
"maxEstimatedDrawCalls": 2283,
"maxUnbatchedDrawCallSavings": 1896
},
"public/assets/game/manastorm/585-sunwell5manfix/visual.glb": {
"maxEstimatedDrawCalls": 1810,
"maxUnbatchedDrawCallSavings": 1618
},
"public/assets/game/manastorm/595-stratholmecot/visual.glb": {
"maxEstimatedDrawCalls": 4353,
"maxUnbatchedDrawCallSavings": 3386
},
"public/assets/game/manastorm/600-draktheronkeep/visual.glb": {
"maxEstimatedDrawCalls": 3518,
"maxUnbatchedDrawCallSavings": 2876
},
"public/assets/game/manastorm/602-ulduar80/visual.glb": {
"maxEstimatedDrawCalls": 1973,
"maxUnbatchedDrawCallSavings": 1295
},
"public/assets/game/manastorm/619-azjol-lowercity/visual.glb": {
"maxEstimatedDrawCalls": 1596,
"maxUnbatchedDrawCallSavings": 1243
},
"public/assets/game/manastorm/658-quarryoftears/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 618
},
"public/assets/game/manastorm/668-hallsofreflection/visual.glb": {
"maxEstimatedDrawCalls": 5464,
"maxUnbatchedDrawCallSavings": 4606
},
"public/assets/game/manastorm/70-uldaman/visual.glb": {
"maxEstimatedDrawCalls": 3268,
"maxUnbatchedDrawCallSavings": 3115
},
"public/assets/game/manastorm/821-tinketechshowdown/visual.glb": {
"maxEstimatedDrawCalls": 2585,
"maxUnbatchedDrawCallSavings": 2379
},
"public/assets/game/manastorm/90-gnomeragoninstance/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 1051
},
"public/assets/game/manastorm/908-alvaencounter/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 1096
}
}
}
@@ -0,0 +1,98 @@
import { open } from "node:fs/promises";
const GLB_MAGIC = 0x46546c67;
const JSON_CHUNK_TYPE = 0x4e4f534a;
const GPU_INSTANCING_EXTENSION = "EXT_mesh_gpu_instancing";
function primitiveCount(json, meshIndex) {
return json.meshes?.[meshIndex]?.primitives?.length ?? 0;
}
function gpuInstanceCount(json, node) {
const attributes = node.extensions?.[GPU_INSTANCING_EXTENSION]?.attributes;
if (!attributes || typeof attributes !== "object") return 0;
return Math.max(
0,
...Object.values(attributes).map((accessorIndex) => (
json.accessors?.[accessorIndex]?.count ?? 0
)),
);
}
export function analyzeGlbJson(json) {
const repeatedMeshes = new Map();
let estimatedDrawCalls = 0;
let expandedPrimitiveInstances = 0;
let meshNodes = 0;
let gpuInstanceBatches = 0;
let gpuInstances = 0;
for (const node of json.nodes ?? []) {
if (!Number.isInteger(node.mesh)) continue;
meshNodes += 1;
const primitives = primitiveCount(json, node.mesh);
const instances = gpuInstanceCount(json, node);
estimatedDrawCalls += primitives;
expandedPrimitiveInstances += primitives * Math.max(1, instances);
if (instances > 0) {
gpuInstanceBatches += 1;
gpuInstances += instances;
continue;
}
repeatedMeshes.set(node.mesh, (repeatedMeshes.get(node.mesh) ?? 0) + 1);
}
let eligibleRepeatedMeshes = 0;
let eligibleRepeatedNodes = 0;
let unbatchedDrawCallSavings = 0;
for (const [meshIndex, count] of repeatedMeshes) {
if (count < 3) continue;
eligibleRepeatedMeshes += 1;
eligibleRepeatedNodes += count;
unbatchedDrawCallSavings += (count - 1) * primitiveCount(json, meshIndex);
}
const materials = json.materials ?? [];
return {
nodes: json.nodes?.length ?? 0,
meshNodes,
meshes: json.meshes?.length ?? 0,
materials: materials.length,
textures: json.textures?.length ?? 0,
doubleSidedMaterials: materials.filter((material) => material.doubleSided === true).length,
blendedMaterials: materials.filter((material) => material.alphaMode === "BLEND").length,
estimatedDrawCalls,
expandedPrimitiveInstances,
gpuInstanceBatches,
gpuInstances,
eligibleRepeatedMeshes,
eligibleRepeatedNodes,
unbatchedDrawCallSavings,
usesGpuInstancing: (json.extensionsUsed ?? []).includes(GPU_INSTANCING_EXTENSION),
};
}
export async function readGlbJson(file) {
const handle = await open(file, "r");
try {
const header = Buffer.alloc(20);
const { bytesRead } = await handle.read(header, 0, header.length, 0);
if (bytesRead !== header.length
|| header.readUInt32LE(0) !== GLB_MAGIC
|| header.readUInt32LE(4) !== 2
|| header.readUInt32LE(16) !== JSON_CHUNK_TYPE) {
throw new Error(`${file} is not a supported GLB 2.0 file.`);
}
const jsonLength = header.readUInt32LE(12);
const jsonBuffer = Buffer.alloc(jsonLength);
const jsonRead = await handle.read(jsonBuffer, 0, jsonLength, 20);
if (jsonRead.bytesRead !== jsonLength) throw new Error(`${file} has a truncated JSON chunk.`);
return JSON.parse(jsonBuffer.toString("utf8"));
} finally {
await handle.close();
}
}
export async function analyzeGlbFile(file) {
return analyzeGlbJson(await readGlbJson(file));
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { analyzeGlbJson } from "./glb-performance.mjs";
test("counts repeated mesh placements as avoidable draw calls", () => {
const report = analyzeGlbJson({
meshes: [
{ primitives: [{}, {}] },
{ primitives: [{}] },
],
nodes: [
{ mesh: 0 },
{ mesh: 0 },
{ mesh: 0 },
{ mesh: 1 },
],
materials: [
{ doubleSided: true, alphaMode: "BLEND" },
{},
],
textures: [{}, {}],
});
assert.equal(report.estimatedDrawCalls, 7);
assert.equal(report.expandedPrimitiveInstances, 7);
assert.equal(report.eligibleRepeatedMeshes, 1);
assert.equal(report.eligibleRepeatedNodes, 3);
assert.equal(report.unbatchedDrawCallSavings, 4);
assert.equal(report.doubleSidedMaterials, 1);
assert.equal(report.blendedMaterials, 1);
});
test("counts one draw-call set for an EXT_mesh_gpu_instancing batch", () => {
const report = analyzeGlbJson({
extensionsUsed: ["EXT_mesh_gpu_instancing"],
accessors: [{ count: 12 }],
meshes: [{ primitives: [{}, {}, {}] }],
nodes: [{
mesh: 0,
extensions: {
EXT_mesh_gpu_instancing: {
attributes: { TRANSLATION: 0 },
},
},
}],
});
assert.equal(report.estimatedDrawCalls, 3);
assert.equal(report.expandedPrimitiveInstances, 36);
assert.equal(report.gpuInstanceBatches, 1);
assert.equal(report.gpuInstances, 12);
assert.equal(report.unbatchedDrawCallSavings, 0);
assert.equal(report.usesGpuInstancing, true);
});
@@ -0,0 +1,122 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { BufferAttribute, BufferGeometry, Vector3 } from "three";
import { MeshBVH } from "three-mesh-bvh";
import { MeshoptDecoder } from "meshoptimizer";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const campaign = await import("../../src/game/generated/dungeonCampaignCatalog.json", {
with: { type: "json" },
}).then((module) => module.default);
const useCollision = process.argv.includes("--collision");
const requestedSlugs = new Set(process.argv.slice(2).filter((argument) => argument !== "--collision"));
const definitions = campaign.definitions.filter((definition) => (
!requestedSlugs.size || requestedSlugs.has(definition.id)
));
if (!definitions.length) {
throw new Error("No matching dungeon definitions. Pass one or more dungeon slugs.");
}
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const point = new Vector3();
function normalizerFor(accessor, array) {
if (!accessor.getNormalized()) return (value) => value;
if (array instanceof Int16Array) return (value) => Math.max(value / 32_767, -1);
if (array instanceof Uint16Array) return (value) => value / 65_535;
if (array instanceof Int8Array) return (value) => Math.max(value / 127, -1);
if (array instanceof Uint8Array) return (value) => value / 255;
return (value) => value;
}
async function geometryTrees(file) {
const document = await io.read(file);
const trees = [];
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = node.getWorldMatrix();
for (const primitive of mesh.listPrimitives()) {
const accessor = primitive.getAttribute("POSITION");
const source = accessor?.getArray();
if (!accessor || !source) continue;
const normalize = normalizerFor(accessor, source);
const positions = new Float32Array(source.length);
for (let offset = 0; offset < source.length; offset += 3) {
const x = normalize(source[offset]);
const y = normalize(source[offset + 1]);
const z = normalize(source[offset + 2]);
positions[offset] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
positions[offset + 1] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
positions[offset + 2] = matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14];
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(positions, 3));
const indices = primitive.getIndices()?.getArray();
if (indices) geometry.setIndex(new BufferAttribute(indices, 1));
trees.push(new MeshBVH(geometry));
}
}
return trees;
}
function nearestDistance(trees, position) {
point.fromArray(position);
let distance = Infinity;
for (const tree of trees) {
const result = tree.closestPointToPoint(point);
distance = Math.min(distance, result?.distance ?? Infinity);
}
return distance;
}
function percentile(sorted, proportion) {
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * proportion))] ?? 0;
}
const results = [];
for (const definition of definitions) {
const geometryUrls = useCollision
? definition.assets.collision.map((chunk) => chunk.url)
: [definition.assets.navigation.url];
const trees = (await Promise.all(geometryUrls.map((url) => (
geometryTrees(path.join(projectRoot, "public", url.replace(/^\//, "")))
)))).flat();
const samples = [
{ id: "entrance", position: definition.entrance.footPosition },
...definition.staticSpawns.map((spawn) => ({ id: spawn.id, position: spawn.position })),
...definition.roamingPacks.flatMap((pack) => (
pack.waypoints.map((position, index) => ({ id: `${pack.id}:${index}`, position }))
)),
];
const distances = samples
.map((sample) => ({ ...sample, distance: nearestDistance(trees, sample.position) }))
.sort((left, right) => left.distance - right.distance);
const values = distances.map((sample) => sample.distance);
results.push({
dungeonId: definition.id,
geometry: useCollision ? "collision" : "navigation",
coordinateAlignment: definition.provenance.coordinateAlignment,
coordinateTransform: definition.provenance.coordinateTransform,
samples: values.length,
withinOneMeter: values.filter((distance) => distance <= 1).length,
medianDistance: percentile(values, 0.5),
p90Distance: percentile(values, 0.9),
maximumDistance: values.at(-1) ?? 0,
furthest: distances.slice(-5).reverse().map(({ id, position, distance }) => ({
id,
position,
distance,
})),
});
}
console.log(JSON.stringify(results, null, 2));
@@ -175,6 +175,10 @@ function interpolate(left, right, alpha) {
return left.map((value, axis) => rounded(value + (right[axis] - value) * alpha));
}
function horizontalDistanceSquared(left, right) {
return (left[0] - right[0]) ** 2 + (left[2] - right[2]) ** 2;
}
function normalizedName(value) {
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "");
}
@@ -201,7 +205,7 @@ const ADT_SOURCE_BASES = new Set([
"z-y-negative-x",
]);
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
const GLOBAL_WMO_ALIGNMENTS = new Set(["player-anchor", "source"]);
const COORDINATE_ALIGNMENTS = new Set(["player-anchor", "source", "source-transform"]);
function sourceBasisFor(recipe, environmentMode) {
if (environmentMode !== "adt-hybrid") {
@@ -222,14 +226,42 @@ function sourceBasisFor(recipe, environmentMode) {
}
function coordinateAlignmentFor(recipe, environmentMode) {
if (environmentMode !== "global-wmo") return "player-anchor";
const alignment = recipe.globalWmoAlignment ?? "player-anchor";
if (!GLOBAL_WMO_ALIGNMENTS.has(alignment)) {
throw new Error(`${recipe.slug}: unsupported global WMO alignment ${alignment}.`);
const legacyGlobalWmoAlignment = environmentMode === "global-wmo"
? recipe.globalWmoAlignment
: undefined;
if (
recipe.coordinateAlignment
&& legacyGlobalWmoAlignment
&& recipe.coordinateAlignment !== legacyGlobalWmoAlignment
) {
throw new Error(`${recipe.slug}: coordinate alignment overrides disagree.`);
}
const alignment = recipe.coordinateAlignment ?? legacyGlobalWmoAlignment ?? "player-anchor";
if (!COORDINATE_ALIGNMENTS.has(alignment)) {
throw new Error(`${recipe.slug}: unsupported coordinate alignment ${alignment}.`);
}
return alignment;
}
function sourceTransformFor(recipe, alignment) {
const transform = recipe.coordinateTransform;
if (alignment !== "source-transform") {
if (transform) {
throw new Error(`${recipe.slug}: coordinateTransform requires source-transform alignment.`);
}
return undefined;
}
if (!transform || !Array.isArray(transform.origin) || transform.origin.length !== 3) {
throw new Error(`${recipe.slug}: source-transform alignment requires a three-value coordinateTransform.origin.`);
}
const origin = transform.origin.map(Number);
const yawDegrees = Number(transform.yawDegrees ?? 0);
if (!origin.every(Number.isFinite) || !Number.isFinite(yawDegrees)) {
throw new Error(`${recipe.slug}: coordinateTransform values must be finite numbers.`);
}
return { origin, yawDegrees };
}
/**
* Runtime drafts retain the original pipeline convention. wow.export ADT
* packages have an additional horizontal basis change baked into their GLBs.
@@ -248,10 +280,39 @@ function sourceToPackageBasis(position, environmentMode, sourceBasis) {
: point;
}
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis, alignment) {
function rotateAroundY(position, yawDegrees) {
const radians = yawDegrees * Math.PI / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
return vector([
position[0] * cosine - position[2] * sine,
position[1],
position[0] * sine + position[2] * cosine,
]);
}
function coordinateMapper(
draft,
environmentMode,
playerAnchor,
sourceBasis,
alignment,
sourceTransform,
) {
if (alignment === "source") {
return (position) => sourceToPackageBasis(position, environmentMode, sourceBasis);
}
if (alignment === "source-transform") {
const basisOrigin = sourceToPackageBasis(
sourceTransform.origin,
environmentMode,
sourceBasis,
);
return (position) => rotateAroundY(
subtract(sourceToPackageBasis(position, environmentMode, sourceBasis), basisOrigin),
sourceTransform.yawDegrees,
);
}
const sourceEntrance = draft?.entrance
?? draft?.spawns?.[0]?.position
?? playerAnchor.position;
@@ -778,6 +839,7 @@ async function compile() {
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
const sourceBasis = sourceBasisFor(recipe, environmentMode);
const coordinateAlignment = coordinateAlignmentFor(recipe, environmentMode);
const sourceTransform = sourceTransformFor(recipe, coordinateAlignment);
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
@@ -788,7 +850,7 @@ async function compile() {
trashAnchor.position[0] - playerAnchor.position[0],
trashAnchor.position[2] - playerAnchor.position[2],
);
const entranceYaw = Number.isFinite(playerAnchor.yaw)
let entranceYaw = Number.isFinite(playerAnchor.yaw)
? playerAnchor.yaw
: inferredEntranceYaw;
@@ -809,6 +871,7 @@ async function compile() {
playerAnchor,
sourceBasis,
coordinateAlignment,
sourceTransform,
);
const entrancePosition = mapPosition(
draft?.entrance
@@ -896,16 +959,49 @@ async function compile() {
const entry = Number(spawn.entityId.replace("entry-", ""));
if (!bossCandidatesByEntry.has(entry)) bossCandidatesByEntry.set(entry, spawn);
}
const mappedNativeRoutePositions = coordinateAlignment !== "player-anchor"
? (draft?.spawns ?? [])
.map((spawn) => mapPosition(spawn.position))
.filter((position) => position.every(Number.isFinite))
.sort((left, right) => (
horizontalDistanceSquared(left, entrancePosition)
- horizontalDistanceSquared(right, entrancePosition)
))
: [];
const entranceDirectionTarget = mappedNativeRoutePositions.find((position) => (
Math.abs(position[1] - entrancePosition[1]) <= 3
&& horizontalDistanceSquared(position, entrancePosition) >= 16
));
if (entranceDirectionTarget) {
entranceYaw = Math.atan2(
entranceDirectionTarget[0] - entrancePosition[0],
entranceDirectionTarget[2] - entrancePosition[2],
);
}
const entranceLevelRoutePositions = mappedNativeRoutePositions.filter(
(position) => Math.abs(position[1] - entrancePosition[1]) <= 2.5,
);
const nativeRoutePositions = (
bossCandidatesByEntry.size === 0
&& entranceLevelRoutePositions.length >= encounters.length
)
? entranceLevelRoutePositions
: mappedNativeRoutePositions;
const usedBossSpawnIds = new Set();
const bossObjectives = encounters.map((encounter, index) => {
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId));
const alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
const routePosition = interpolate(trashAnchor.position, bossAnchor.position, 0.18 + alpha * 0.82);
const routeAlpha = 0.18 + alpha * 0.82;
const nativeRoutePosition = nativeRoutePositions.length
? nativeRoutePositions[Math.round(routeAlpha * (nativeRoutePositions.length - 1))]
: undefined;
const routePosition = nativeRoutePosition
?? interpolate(trashAnchor.position, bossAnchor.position, routeAlpha);
const position = encounter.eventOnly
? [-151.27, -102.82, 252.26]
: candidate
? mapPosition(candidate.position)
: offsetPosition(routePosition, index % 5, 2.4);
: nativeRoutePosition ?? offsetPosition(routePosition, index % 5, 2.4);
const id = encounter.eventOnly
? "mutanus-spawn"
: candidate?.id ?? `encounter-${encounter.encounterId}`;
@@ -1000,8 +1096,9 @@ async function compile() {
const allPoints = [
entrancePosition,
trashAnchor.position,
bossAnchor.position,
...(coordinateAlignment === "player-anchor" || syntheticTrash
? [trashAnchor.position, bossAnchor.position]
: []),
...staticSpawns.map((spawn) => spawn.position),
...roamingPacks.flatMap((pack) => pack.waypoints),
];
@@ -1032,6 +1129,8 @@ async function compile() {
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
: coordinateAlignment === "source"
? [`Server positions use the packaged ${environmentMode} environment's native world coordinates.`]
: coordinateAlignment === "source-transform"
? [`Server positions use the packaged ${environmentMode} environment's authoritative WDT placement transform.`]
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
];
@@ -1078,6 +1177,7 @@ async function compile() {
serverGameObjects: draft?.gameObjects ?? [],
provenance: {
clientBuild: recipe.clientBuild,
coordinateAlignment,
clientArchiveHashes: assetPackage.source.checksums ?? {},
databaseAdapter: databaseKind,
databaseRevision: draft?.spawns?.length
@@ -1090,6 +1190,8 @@ async function compile() {
? sourceBasis === "z-y-negative-x"
? "three(x,y,z)=(wow.y,wow.z,wow.x)"
: "three(x,y,z)=(-wow.y,wow.z,wow.x)"
: coordinateAlignment === "source-transform"
? `three(x,y,z)=rotate-y(${sourceBasis === "flip-x" ? "(-draft.x,draft.y,draft.z)" : "draft(x,y,z)"}-[${sourceTransform.origin.join(",")}],${sourceTransform.yawDegrees}deg)`
: coordinateAlignment === "source"
? sourceBasis === "flip-x"
? "three(x,y,z)=(-draft.x,draft.y,draft.z)"
+106
View File
@@ -0,0 +1,106 @@
import {
mkdirSync,
readFileSync,
readdirSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { basename, resolve } from "node:path";
import {
CLASSES,
COA_CLASS_IDS,
type ClassId,
type CoaClassId,
} from "../src/app/characterCatalog";
import { TALENT_NODES, TALENT_TREES } from "../src/game/talentCatalog";
const projectRoot = resolve(import.meta.dirname, "..");
const talentOutputDirectory = resolve(projectRoot, "src/game/generated/talentUi");
const coaAbilityOutputDirectory = resolve(projectRoot, "src/game/generated/coaAbilities");
function writeJson(file: string, value: unknown): void {
writeFileSync(file, `${JSON.stringify(value)}\n`, "utf8");
console.log(`Wrote ${file}`);
}
function removeStaleJson(directory: string, expectedNames: ReadonlySet<string>): void {
for (const name of readdirSync(directory)) {
if (!name.endsWith(".json") || expectedNames.has(name)) continue;
unlinkSync(resolve(directory, name));
}
}
mkdirSync(talentOutputDirectory, { recursive: true });
const talentClassIds = new Set<ClassId>(TALENT_TREES.map((tree) => tree.classId));
const talentFileNames = new Set([...talentClassIds].map((classId) => `${classId}.json`));
removeStaleJson(talentOutputDirectory, talentFileNames);
for (const classId of talentClassIds) {
const trees = TALENT_TREES.filter((tree) => tree.classId === classId).map((tree) => ({
id: tree.id,
classId: tree.classId,
name: tree.name,
icon: tree.icon,
background: tree.background,
description: tree.description,
system: tree.system,
}));
const treeIds = new Set(trees.map((tree) => tree.id));
const nodes = TALENT_NODES.filter((node) => treeIds.has(node.treeId)).map((node) => ({
id: node.id,
treeId: node.treeId,
index: node.index,
prerequisiteId: node.prerequisiteId,
prerequisiteRank: node.prerequisiteRank,
prerequisites: node.prerequisites,
column: node.column,
row: node.row,
icon: node.icon,
maxRank: node.maxRank,
name: node.name,
description: node.description,
rankDescriptions: node.rankDescriptions,
system: node.system,
entryType: node.entryType,
abilityEssenceCost: node.abilityEssenceCost,
talentEssenceCost: node.talentEssenceCost,
requiredClassPoints: node.requiredClassPoints,
requiredSpecPoints: node.requiredSpecPoints,
requiredLevel: node.requiredLevel,
isPassive: node.isPassive,
}));
writeJson(resolve(talentOutputDirectory, `${classId}.json`), {
schemaVersion: 1,
classId,
trees,
nodes,
});
}
interface CoaAbilitySnapshot {
readonly schemaVersion: 1;
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly unknown[]>>;
readonly resourcesByClass: Readonly<Record<CoaClassId, unknown>>;
readonly triggeredAbilitiesBySpellId: Readonly<Record<string, { readonly classId?: ClassId }>>;
}
const coaAbilityInput = resolve(projectRoot, "src/game/generated/coaAbilityRuntimeCatalog.json");
const coaSnapshot = JSON.parse(readFileSync(coaAbilityInput, "utf8")) as CoaAbilitySnapshot;
mkdirSync(coaAbilityOutputDirectory, { recursive: true });
const coaAbilityFileNames = new Set(COA_CLASS_IDS.map((classId) => `${classId}.json`));
removeStaleJson(coaAbilityOutputDirectory, coaAbilityFileNames);
for (const classId of COA_CLASS_IDS) {
const triggeredAbilitiesBySpellId = Object.fromEntries(Object.entries(
coaSnapshot.triggeredAbilitiesBySpellId,
).filter(([, ability]) => ability.classId === classId));
writeJson(resolve(coaAbilityOutputDirectory, `${classId}.json`), {
schemaVersion: 1,
classId,
abilities: coaSnapshot.abilitiesByClass[classId] ?? [],
resource: coaSnapshot.resourcesByClass[classId],
triggeredAbilitiesBySpellId,
});
}
console.log(`Generated ${talentFileNames.size} talent UI shards and ${coaAbilityFileNames.size} Conquest ability shards from ${basename(coaAbilityInput)}.`);
+20 -3
View File
@@ -404,15 +404,23 @@ async function optimizeStage(slug) {
const source = path.join(staging, entry.path);
const outputName = optimizedChunkName(role, index, entries.length);
const output = path.join(staging, outputName);
if (role === "visual") {
await run(process.execPath, [
cli,
"meshopt",
"optimize",
source,
output,
"--level",
"--compress",
"meshopt",
"--meshopt-level",
"high",
"--instance",
"true",
"--instance-min",
"3",
"--texture-size",
"2048",
]);
if (role === "visual") {
await run(process.execPath, [
path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs"),
output,
@@ -421,6 +429,15 @@ async function optimizeStage(slug) {
"--jobs",
"2",
]);
} else {
await run(process.execPath, [
cli,
"meshopt",
source,
output,
"--level",
"high",
]);
}
const geometry = await inspectGlb(output);
const descriptor = {
+38 -2
View File
@@ -1,5 +1,7 @@
import { lazy, Suspense, useEffect } from "react";
import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from "react";
import { BASE_CLASS_IDS, classById } from "./app/characterCatalog";
import { useShellStore } from "./app/shellStore";
import { preloadAbilityCatalogsForClasses } from "./game/abilityCatalog";
import { useManastormAdminStore } from "./game/manastormAdminStore";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { LoginScreen } from "./ui/LoginScreen";
@@ -18,6 +20,39 @@ function ShellTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
}
function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
const character = useShellStore((state) => (
state.characters.find((candidate) => candidate.id === state.selectedCharacterId) ?? null
));
const classIds = useMemo(() => {
if (!character) return [];
const definition = classById(character.classId);
if (definition.mode === "conquest") return [...BASE_CLASS_IDS, character.classId];
return [character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])];
}, [character]);
const requestKey = classIds.join(":");
const [loadedKey, setLoadedKey] = useState("");
const [failedKey, setFailedKey] = useState("");
useEffect(() => {
if (!requestKey) return;
let active = true;
setFailedKey("");
void preloadAbilityCatalogsForClasses(classIds).then(() => {
if (active) setLoadedKey(requestKey);
}).catch(() => {
if (active) setFailedKey(requestKey);
});
return () => { active = false; };
}, [classIds, requestKey]);
if (!requestKey || loadedKey === requestKey) return children;
if (failedKey === requestKey) {
return <div className="world-transition"><span>!</span><strong>Catalog unavailable</strong><small>Reload the app to retry loading combat data.</small></div>;
}
return <ShellTransition />;
}
export default function App() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
@@ -39,5 +74,6 @@ export default function App() {
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
return <><OnlineGroupCoordinator />{screen}</>;
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
return <><OnlineGroupCoordinator />{requiresRuntimeCatalog ? <RuntimeCatalogGate>{screen}</RuntimeCatalogGate> : screen}</>;
}
+140 -40
View File
@@ -1,13 +1,18 @@
import type { ClassId } from "../app/characterCatalog";
import {
COA_RUNTIME_ABILITIES_BY_CLASS as COA_ABILITIES_BY_CLASS,
COA_RUNTIME_CLASS_RESOURCES as COA_CLASS_RESOURCES,
} from "./coaAbilityRuntimeCatalog";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
BASE_CLASS_IDS,
COA_CLASS_IDS,
ROM_CLASS_IDS,
type BaseClassId,
type ClassId,
type CoaClassId,
type RomClassId,
} from "../app/characterCatalog";
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
export type ResourceType =
| "mana"
| "rage"
@@ -301,7 +306,7 @@ function defineAbility(definition: AbilityDefinitionInput): AbilityDefinition {
};
}
export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> = {
const classResources: Partial<Record<ClassId, ClassResourceProfile>> = {
// The prototype has no auto-attack swing timer yet, so a small passive Rage
// gain stands in for white-hit generation and keeps the full kit usable.
warrior: { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 5 },
@@ -314,7 +319,6 @@ export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> =
mage: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
warlock: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
druid: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
...COA_CLASS_RESOURCES,
"rom-warrior": { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 0 },
"rom-scout": { type: "focus", name: "Focus", color: "#55b995", maximum: 100, initial: 100, regenerationPerSecond: 6 },
"rom-rogue": { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
@@ -327,6 +331,9 @@ export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> =
"rom-champion": { type: "rage", name: "Rage", color: "#bb604c", maximum: 100, initial: 0, regenerationPerSecond: 0 },
};
/** Populated per mode/class by preloadAbilityCatalogsForClasses before gameplay mounts. */
export const CLASS_RESOURCES = classResources as Readonly<Record<ClassId, ClassResourceProfile>>;
const WARRIOR = [
defineAbility({
id: "warrior-heroic-strike", classId: "warrior", dbcSpellId: 78, name: "Heroic Strike",
@@ -789,48 +796,131 @@ function mergeWow335ClassicAbilities(
];
}
export const ABILITIES_BY_CLASS: Readonly<Record<ClassId, readonly AbilityDefinition[]>> = {
warrior: mergeWow335ClassicAbilities(WARRIOR, WOW335_ABILITIES_BY_CLASS.warrior),
paladin: mergeWow335ClassicAbilities(PALADIN, WOW335_ABILITIES_BY_CLASS.paladin),
hunter: mergeWow335ClassicAbilities(HUNTER, WOW335_ABILITIES_BY_CLASS.hunter),
rogue: mergeWow335ClassicAbilities(ROGUE, WOW335_ABILITIES_BY_CLASS.rogue),
priest: mergeWow335ClassicAbilities(PRIEST, WOW335_ABILITIES_BY_CLASS.priest),
"death-knight": mergeWow335ClassicAbilities(DEATH_KNIGHT, WOW335_ABILITIES_BY_CLASS["death-knight"]),
shaman: mergeWow335ClassicAbilities(SHAMAN, WOW335_ABILITIES_BY_CLASS.shaman),
mage: mergeWow335ClassicAbilities(MAGE, WOW335_ABILITIES_BY_CLASS.mage),
warlock: mergeWow335ClassicAbilities(WARLOCK, WOW335_ABILITIES_BY_CLASS.warlock),
druid: mergeWow335ClassicAbilities(DRUID, WOW335_ABILITIES_BY_CLASS.druid),
...COA_ABILITIES_BY_CLASS,
"rom-warrior": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warrior"],
"rom-scout": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-scout"],
"rom-rogue": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-rogue"],
"rom-mage": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-mage"],
"rom-priest": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-priest"],
"rom-knight": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-knight"],
"rom-warden": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warden"],
"rom-druid": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-druid"],
"rom-warlock": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warlock"],
"rom-champion": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-champion"],
const CLASSIC_CORE_ABILITIES: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> = {
warrior: WARRIOR,
paladin: PALADIN,
hunter: HUNTER,
rogue: ROGUE,
priest: PRIEST,
"death-knight": DEATH_KNIGHT,
shaman: SHAMAN,
mage: MAGE,
warlock: WARLOCK,
druid: DRUID,
};
const abilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
...CLASSIC_CORE_ABILITIES,
};
const abilityCatalog: Record<string, AbilityDefinition> = {};
let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
const coaTriggeredAbilities = new Map<number, AbilityDefinition>();
let classicLoad: Promise<void> | null = null;
let romLoad: Promise<void> | null = null;
const coaLoads = new Map<CoaClassId, Promise<void>>();
export const ABILITY_CATALOG: Readonly<Record<string, AbilityDefinition>> = Object.freeze(
Object.fromEntries([...Object.values(ABILITIES_BY_CLASS).flat(), ...ROM_ALL_ABILITIES].map((ability) => [ability.id, ability])),
/** Live read-only views retained for catalog consumers and validation tests. */
export const ABILITIES_BY_CLASS = abilitiesByClass as Readonly<Record<ClassId, readonly AbilityDefinition[]>>;
export const ABILITY_CATALOG = abilityCatalog as Readonly<Record<string, AbilityDefinition>>;
function registerClassAbilities(classId: ClassId, abilities: readonly AbilityDefinition[]): void {
for (const previous of abilitiesByClass[classId] ?? []) delete abilityCatalog[previous.id];
abilitiesByClass[classId] = abilities;
for (const ability of abilities) abilityCatalog[ability.id] = ability;
}
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(classId, CLASSIC_CORE_ABILITIES[classId]);
}
async function preloadClassicAbilityCatalog(): Promise<void> {
if (classicLoad) return classicLoad;
classicLoad = import("./wow335AbilityCatalog").then(({ WOW335_ABILITIES_BY_CLASS }) => {
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(
classId,
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]),
);
}
}).catch((error: unknown) => {
classicLoad = null;
throw error;
});
return classicLoad;
}
async function preloadRomAbilityCatalog(): Promise<void> {
if (romLoad) return romLoad;
romLoad = import("./romAbilityCatalog").then((catalog) => {
for (const classId of ROM_CLASS_IDS) {
registerClassAbilities(classId, catalog.ROM_ORDINARY_ABILITIES_BY_CLASS[classId]);
}
romEliteAbilities = catalog.ROM_ELITE_ABILITIES;
romTriggeredAbilityResolver = catalog.romTriggeredAbilityBySpellId;
for (const ability of catalog.ROM_ALL_ABILITIES) abilityCatalog[ability.id] = ability;
}).catch((error: unknown) => {
romLoad = null;
throw error;
});
return romLoad;
}
async function preloadCoaAbilityCatalog(classId: CoaClassId): Promise<void> {
const cached = coaLoads.get(classId);
if (cached) return cached;
const pending = loadCoaAbilityCatalogShard(classId).then((shard) => {
registerClassAbilities(classId, shard.abilities);
classResources[classId] = shard.resource;
for (const [spellId, ability] of Object.entries(shard.triggeredAbilitiesBySpellId)) {
coaTriggeredAbilities.set(Number(spellId), ability);
}
}).catch((error: unknown) => {
coaLoads.delete(classId);
throw error;
});
coaLoads.set(classId, pending);
return pending;
}
/**
* Loads only the executable catalogs needed by the selected character and its
* party mode. Callers gate gameplay mounting on this promise so combat APIs can
* stay synchronous inside the simulation loop.
*/
export async function preloadAbilityCatalogsForClasses(classIds: readonly ClassId[]): Promise<void> {
const requested = new Set(classIds);
const loads: Promise<void>[] = [];
if (BASE_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadClassicAbilityCatalog());
if (ROM_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadRomAbilityCatalog());
for (const classId of COA_CLASS_IDS) {
if (requested.has(classId)) loads.push(preloadCoaAbilityCatalog(classId));
}
await Promise.all(loads);
}
export function triggeredAbilityBySpellId(
spellId: number,
includeCoa = true,
): AbilityDefinition | null {
return romTriggeredAbilityResolver?.(spellId)
?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined)
?? null;
}
export function abilitiesForClass(classId: ClassId): readonly AbilityDefinition[] {
return ABILITIES_BY_CLASS[classId];
return abilitiesByClass[classId] ?? [];
}
export function abilitiesForCharacter(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
): readonly AbilityDefinition[] {
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return ABILITIES_BY_CLASS[classId];
const primary = ABILITIES_BY_CLASS[classId];
const secondaryGeneral = ABILITIES_BY_CLASS[secondaryClassId].filter((ability) => (
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return abilitiesForClass(classId);
const primary = abilitiesForClass(classId);
const secondaryGeneral = abilitiesForClass(secondaryClassId).filter((ability) => (
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
));
const elites = ROM_ELITE_ABILITIES.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
return [...primary, ...secondaryGeneral, ...elites];
}
@@ -885,7 +975,7 @@ export function abilitiesUnlockedBetweenLevels(
previousLevel: number,
currentLevel: number,
): readonly AbilityDefinition[] {
return ABILITIES_BY_CLASS[classId].filter((ability) => (
return abilitiesForClass(classId).filter((ability) => (
ability.unlockLevel > previousLevel && ability.unlockLevel <= currentLevel
));
}
@@ -920,7 +1010,7 @@ export function defaultActionBarForClass(classId: ClassId): readonly string[] {
|| effect.kind === "rune"
|| effect.kind === "trigger-spell"
)) ? 0 : 1;
const ordered = ABILITIES_BY_CLASS[classId]
const ordered = abilitiesForClass(classId)
.map((ability, index) => ({ ability, index }))
.sort((left, right) => (
left.ability.unlockLevel - right.ability.unlockLevel
@@ -962,5 +1052,15 @@ export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?
}
export function resourceProfileForClass(classId: ClassId): ClassResourceProfile {
return CLASS_RESOURCES[classId];
const profile = classResources[classId];
if (!profile) throw new Error(`Ability catalog for ${classId} was used before it was loaded.`);
return profile;
}
if (import.meta.env.MODE === "test") {
await preloadAbilityCatalogsForClasses([
...BASE_CLASS_IDS,
...COA_CLASS_IDS,
...ROM_CLASS_IDS,
]);
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
import {
COA_RUNTIME_ABILITIES_BY_CLASS,
COA_RUNTIME_CLASS_RESOURCES,
} from "./coaAbilityRuntimeCatalog";
describe("Conquest ability catalog shards", () => {
it.each(["barbarian", "runemaster"] as const)("loads only the %s executable catalog", async (classId) => {
const shard = await loadCoaAbilityCatalogShard(classId);
expect(shard.classId).toBe(classId);
expect(shard.abilities).toEqual(COA_RUNTIME_ABILITIES_BY_CLASS[classId]);
expect(shard.resource).toEqual(COA_RUNTIME_CLASS_RESOURCES[classId]);
expect(Object.values(shard.triggeredAbilitiesBySpellId).every((ability) => (
ability.classId === classId
))).toBe(true);
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { CoaClassId } from "../app/characterCatalog";
import type { AbilityDefinition, ClassResourceProfile } from "./abilityCatalog";
export interface CoaAbilityCatalogShard {
readonly schemaVersion: 1;
readonly classId: CoaClassId;
readonly abilities: readonly AbilityDefinition[];
readonly resource: ClassResourceProfile;
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
}
interface CoaAbilityCatalogShardModule {
readonly default: CoaAbilityCatalogShard;
}
const shardLoaders = import.meta.glob<CoaAbilityCatalogShardModule>("./generated/coaAbilities/*.json");
const loadCache = new Map<CoaClassId, Promise<CoaAbilityCatalogShard>>();
export function loadCoaAbilityCatalogShard(classId: CoaClassId): Promise<CoaAbilityCatalogShard> {
const cached = loadCache.get(classId);
if (cached) return cached;
const key = `./generated/coaAbilities/${classId}.json`;
const loader = shardLoaders[key];
if (!loader) return Promise.reject(new Error(`No Conquest ability catalog exists for ${classId}.`));
const pending = loader().then((module) => {
if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
throw new Error(`Conquest ability catalog ${classId} has incompatible metadata.`);
}
return module.default;
}).catch((error: unknown) => {
loadCache.delete(classId);
throw error;
});
loadCache.set(classId, pending);
return pending;
}
+2 -3
View File
@@ -16,6 +16,7 @@ import {
defaultActionBarForCharacter,
isAbilityUnlocked,
resourceProfileForClass,
triggeredAbilityBySpellId,
type AbilityAmountScaling,
type AbilityDefinition,
type AbilityEffect,
@@ -57,8 +58,6 @@ import {
spendDeathKnightRunes,
} from "./combatResources";
import { resolveHealing, type HealingResolution } from "./combatHealing";
import { romTriggeredAbilityBySpellId } from "./romAbilityCatalog";
import { coaTriggeredAbilityBySpellId } from "./coaAbilityRuntimeCatalog";
import {
awardExperience,
healerManExperienceAward,
@@ -2578,7 +2577,7 @@ function executeTriggeredSpellInWork(
const catalogAbility = work.abilities.find((candidate) => (
candidate.dbcSpellId === spellId
|| candidate.ranks?.some((rank) => rank.spellId === spellId)
)) ?? romTriggeredAbilityBySpellId(spellId) ?? (allowCoaHidden ? coaTriggeredAbilityBySpellId(spellId) : null);
)) ?? triggeredAbilityBySpellId(spellId, allowCoaHidden);
if (!catalogAbility) return;
const ability = abilityAtLevel(catalogAbility, work.progression.level);
const source = playerThreatSource({ talentModifiers: work.talentModifiers }, ability.id, true);
+164 -2
View File
@@ -5,6 +5,61 @@ import generated from "./generated/dungeonCampaignCatalog.json";
import manastormPackages from "./generated/manastormAssetPackages.json";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
const SOURCE_NATIVE_DUNGEON_IDS = [
"ahnkahet-old-kingdom",
"arcatraz",
"auchenai-crypts",
"azjol-nerub",
"black-morass",
"blackfathom-deeps",
"blackrock-depths",
"blackrock-spire",
"blood-furnace",
"botanica",
"culling-of-stratholme",
"draktharon-keep",
"escape-from-durnholde",
"forge-of-souls",
"gnomeregan",
"gundrak",
"halls-of-lightning",
"halls-of-reflection",
"halls-of-stone",
"magisters-terrace",
"mana-tombs",
"maraudon",
"mechanar",
"pit-of-saron",
"ragefire-chasm",
"scarlet-monastery",
"sethekk-halls",
"shadow-labyrinth",
"shadowfang-keep",
"shattered-halls",
"slave-pens",
"steamvault",
"stormwind-stockade",
"sunken-temple",
"the-nexus",
"the-oculus",
"trial-of-the-champion",
"uldaman",
"underbog",
"utgarde-keep",
"utgarde-pinnacle",
"violet-hold",
"zulfarrak",
] as const;
const WDT_PLACEMENT_DUNGEON_IDS = [
"dire-maul",
"hellfire-ramparts",
"razorfen-downs",
"razorfen-kraul",
"scholomance",
"stratholme",
] as const;
describe("generated dungeon campaign catalog", () => {
it("covers only reviewed five-player instances with loadable assets and combat content", () => {
expect(generated.definitions).toHaveLength(54);
@@ -240,17 +295,124 @@ describe("generated dungeon campaign catalog", () => {
expect(targorr?.position).toEqual([-159.582, -25.6062, 1.2531]);
});
it("starts Ragefire Chasm in the dungeon instead of the Orgrimmar portal annex", () => {
const ragefire = requireDungeonDefinition("ragefire-chasm");
const firstSpawn = ragefire.staticSpawns.find(
(spawn) => spawn.id === "creature-38041",
);
const jergosh = ragefire.staticSpawns.find(
(spawn) => spawn.id === "creature-48740",
);
expect(ragefire.environmentMode).toBe("global-wmo");
expect(ragefire.provenance.coordinateTransform)
.toBe("three(x,y,z)=draft(x,y,z)");
expect(ragefire.entrance.footPosition).toEqual([-3.81, -17.84, -14.82]);
expect(firstSpawn?.position).toEqual([45.0298, -21.2917, -27.7645]);
expect(jergosh?.position).toEqual([376.811, -21.801, 209.224]);
});
it("keeps every navigation-audited dungeon in its native source coordinates", () => {
const sourceAligned = generated.definitions
.filter((dungeon) => dungeon.provenance.coordinateAlignment === "source")
.map((dungeon) => dungeon.id)
.sort();
expect(sourceAligned).toEqual([...SOURCE_NATIVE_DUNGEON_IDS].sort());
for (const dungeonId of SOURCE_NATIVE_DUNGEON_IDS) {
const dungeon = requireDungeonDefinition(dungeonId);
expect(dungeon.provenance.coordinateAlignment, dungeonId).toBe("source");
expect(dungeon.provenance.coordinateTransform, dungeonId)
.not.toContain("package-anchor-translation");
expect(
dungeon.validation.warnings.some((warning) => (
warning.includes("native world coordinates")
)),
dungeonId,
).toBe(true);
}
});
it("pins representative global-WMO and ADT entrances to their source basis", () => {
const expectedEntrances = {
"blackfathom-deeps": [151.89, -39.87, 106.96],
"blackrock-depths": [-456.929, -68.0896, 34.0923],
"blood-furnace": [3.9967, -44.8009, 14.6363],
"the-nexus": [-145.87, -16.636, -10.554],
"violet-hold": [803.93, 44.364, 1808.82],
"black-morass": [7034.7, 32.5619, -1496.24],
"culling-of-stratholme": [556.92, 36.69, 1431.1],
} as const;
for (const [dungeonId, expected] of Object.entries(expectedEntrances)) {
expect(requireDungeonDefinition(dungeonId).entrance.footPosition, dungeonId)
.toEqual(expected);
}
});
it("keeps scripted Trial of the Champion objectives on the native arena floor", () => {
const trial = requireDungeonDefinition("trial-of-the-champion");
const [entranceX, , entranceZ] = trial.entrance.footPosition;
expect(trial.objectiveOrder).toHaveLength(5);
for (const objective of trial.objectiveOrder) {
expect(objective.position[1], objective.name).toBeGreaterThanOrEqual(411);
expect(objective.position[1], objective.name).toBeLessThanOrEqual(413);
expect(
Math.hypot(objective.position[0] - entranceX, objective.position[2] - entranceZ),
objective.name,
).toBeLessThan(150);
}
});
it("uses client WDT placement records for rotated or translated root WMOs", () => {
for (const dungeonId of WDT_PLACEMENT_DUNGEON_IDS) {
expect(
requireDungeonDefinition(dungeonId).provenance.coordinateAlignment,
dungeonId,
).toBe("source-transform");
expect(
requireDungeonDefinition(dungeonId).provenance.coordinateTransform,
dungeonId,
).toContain("rotate-y");
expect(
requireDungeonDefinition(dungeonId).provenance.coordinateTransform,
dungeonId,
).not.toContain("package-anchor-translation");
}
});
it("pins every map found by the portal-map coordinate audit to its dungeon entrance", () => {
const expectedEntrances = {
arcatraz: [1.2316, -0.2043, 0.0143],
"dire-maul": [44.4499, -2.712, 154.822],
"hellfire-ramparts": [5.7204, 129.1393, -94.4661],
"razorfen-downs": [-190.2691, -10.71, -100.8219],
"razorfen-kraul": [-37.901, -5.684, 22.7908],
"scarlet-monastery": [1053.48, 18.6775, 1688.99],
scholomance: [-55.0407, -13.1583, 20.4141],
"shadowfang-keep": [2109.18, 76.8898, -229.135],
stratholme: [-143.4892, -5.8848, -266.8634],
zulfarrak: [841.59, 8.93, 1213.52],
} as const;
for (const [dungeonId, expected] of Object.entries(expectedEntrances)) {
expect(requireDungeonDefinition(dungeonId).entrance.footPosition, dungeonId)
.toEqual(expected);
}
});
it("starts Razorfen Kraul inside the portal and maps its first pack into the WMO", () => {
const razorfenKraul = requireDungeonDefinition("razorfen-kraul");
const firstPackSpawn = razorfenKraul.staticSpawns.find(
(spawn) => spawn.id === "creature-87284",
);
expect(razorfenKraul.entrance.footPosition).toEqual([-33.81, -5.2481, 21.522]);
expect(razorfenKraul.entrance.footPosition).toEqual([-37.901, -5.684, 22.7908]);
expect(firstPackSpawn).toBeTruthy();
expect(firstPackSpawn!.position[0]).toBeGreaterThan(razorfenKraul.entrance.footPosition[0]);
expect(firstPackSpawn!.position[0]).toBeLessThan(100);
expect(razorfenKraul.provenance.coordinateTransform)
.toBe("three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation");
.toBe("three(x,y,z)=rotate-y(draft(x,y,z)-[-1952.1803,87.684,1587.8923],-133deg)");
});
});
+1
View File
@@ -191,6 +191,7 @@ export interface DungeonPresentation {
export interface DungeonSourceProvenance {
readonly clientBuild: string;
readonly coordinateAlignment?: "player-anchor" | "source" | "source-transform";
readonly clientArchiveHashes: Readonly<Record<string, string>>;
readonly databaseAdapter: string;
readonly databaseRevision?: string;

Some files were not shown because too many files have changed in this diff Show More